├── .babelrc ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── pr-actions.yml ├── .gitignore ├── .nvmrc ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── Readme.md ├── backend ├── .eslintrc.js ├── .gitignore ├── .prettierrc.json ├── @types │ └── express │ │ └── index.d.ts ├── Dockerfile ├── babel.config.js ├── config │ ├── Config.d.ts │ ├── default.json │ ├── deployment │ │ ├── docker.json │ │ └── gh.json │ └── env │ │ ├── development.json │ │ └── test.json ├── jest.config.js ├── knexfile.ts ├── nodemon.json ├── package.json ├── src │ ├── alert-runner.ts │ ├── alerts │ │ ├── base.ts │ │ ├── go-alert.ts │ │ └── kafka.ts │ ├── api │ │ ├── crud │ │ │ ├── cache.ts │ │ │ ├── controller.ts │ │ │ ├── list.ts │ │ │ └── schemas.ts │ │ ├── index.ts │ │ ├── middleware │ │ │ ├── aejo-errors.ts │ │ │ ├── auth.ts │ │ │ ├── client-errors.ts │ │ │ ├── error-handler.ts │ │ │ └── objection-errors.ts │ │ ├── oas.ts │ │ └── routes │ │ │ ├── alerts │ │ │ ├── agg.ts │ │ │ ├── delete.ts │ │ │ ├── distinct.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ └── view.ts │ │ │ ├── allow_list │ │ │ ├── cache.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ ├── update.ts │ │ │ └── view.ts │ │ │ ├── auth │ │ │ ├── get-oauth.ts │ │ │ ├── index.ts │ │ │ ├── local-login.ts │ │ │ ├── logout.ts │ │ │ ├── oauth-callback.ts │ │ │ ├── ready.ts │ │ │ └── session.ts │ │ │ ├── health │ │ │ └── check.ts │ │ │ ├── iocs │ │ │ ├── bulk-create.ts │ │ │ ├── cache-view.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ ├── update.ts │ │ │ └── view.ts │ │ │ ├── queues │ │ │ ├── index.ts │ │ │ └── jobs.ts │ │ │ ├── scan_logs │ │ │ ├── distinct.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ └── view.ts │ │ │ ├── scans │ │ │ ├── bulk-delete.ts │ │ │ ├── delete.ts │ │ │ ├── handlers.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── summary.ts │ │ │ └── view.ts │ │ │ ├── secrets │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── get-types.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ ├── update.ts │ │ │ └── view.ts │ │ │ ├── seen_strings │ │ │ ├── cache.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── distinct.ts │ │ │ ├── get-cache.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ ├── update.ts │ │ │ └── view.ts │ │ │ ├── sites │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ ├── update.ts │ │ │ └── view.ts │ │ │ ├── sources │ │ │ ├── create-test.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── handlers.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ └── view.ts │ │ │ ├── users │ │ │ ├── create-admin.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ ├── schemas.ts │ │ │ ├── update.ts │ │ │ └── view.ts │ │ │ └── util.ts │ ├── app.ts │ ├── express-boot.ts │ ├── global.d.ts │ ├── jobs │ │ ├── index.ts │ │ └── queues.ts │ ├── knexfile.ts │ ├── lib │ │ ├── oauth.ts │ │ ├── queues.ts │ │ └── utils.ts │ ├── loaders │ │ └── logger.ts │ ├── migrations │ │ ├── 20200710172307_sources.ts │ │ ├── 20200711113336_create_sites.ts │ │ ├── 20200713140650_iocs.ts │ │ ├── 20200729163107_seen_domains.ts │ │ ├── 20200729172407_scans.ts │ │ ├── 20200729172408_alerts.ts │ │ ├── 20200805084107_scan_logs.ts │ │ ├── 20200908150341_files.ts │ │ ├── 20200916082555_seen_strings.ts │ │ ├── 20200917140050_allow_list.ts │ │ ├── 20201119120041_add_alert_context.ts │ │ ├── 20201207124807_secrets.ts │ │ ├── 20201207124817_source_secrets.ts │ │ ├── 20210114090325_add_scan_id_index.ts │ │ ├── 20210121102116_add_gin_index_to_scans.ts │ │ └── 20210429133446_add_user_table.ts │ ├── models │ │ ├── alerts.ts │ │ ├── allow_list.ts │ │ ├── base.ts │ │ ├── files.ts │ │ ├── index.ts │ │ ├── iocs.ts │ │ ├── scan_logs.ts │ │ ├── scans.ts │ │ ├── secrets.ts │ │ ├── seen_strings.ts │ │ ├── sites.ts │ │ ├── source_secrets.ts │ │ ├── sources.ts │ │ └── users.ts │ ├── repos │ │ └── redis.ts │ ├── scan-runner.ts │ ├── services │ │ ├── alert.ts │ │ ├── allow_list.ts │ │ ├── auth.ts │ │ ├── ioc.ts │ │ ├── oauth.ts │ │ ├── scan.ts │ │ ├── scan_logs.ts │ │ ├── secret.ts │ │ ├── seen_string.ts │ │ ├── site.ts │ │ ├── source.ts │ │ └── user.ts │ ├── subscribers │ │ └── http.ts │ └── tests │ │ ├── alert.controller.test.ts │ │ ├── alert.service.test.ts │ │ ├── allow_list.controller.test.ts │ │ ├── auth.controller.test.ts │ │ ├── cache.unit.test.ts │ │ ├── factories │ │ ├── alert.factory.ts │ │ ├── allow_list.factory.ts │ │ ├── base.ts │ │ ├── iocs.factory.ts │ │ ├── scan_log.factory.ts │ │ ├── scans.factory.ts │ │ ├── secrets.factory.ts │ │ ├── seen_strings.factory.ts │ │ ├── sites.factory.ts │ │ ├── sources.factory.ts │ │ └── user.factory.ts │ │ ├── go-alert.alert.test.ts │ │ ├── health-check.controller.test.ts │ │ ├── iocs.controller.test.ts │ │ ├── scan.service.test.ts │ │ ├── scan_log.controller.test.ts │ │ ├── scan_log.service.test.ts │ │ ├── scans.controller.test.ts │ │ ├── secret.service.test.ts │ │ ├── secrets.controller.test.ts │ │ ├── seen_strings.controller.test.ts │ │ ├── seen_strings.service.test.ts │ │ ├── sites.controller.test.ts │ │ ├── sites.service.test.ts │ │ ├── source.service.test.ts │ │ ├── sources.controller.test.ts │ │ ├── user.controller.test.ts │ │ ├── utils.test.ts │ │ └── utils │ │ └── index.ts └── tsconfig.json ├── docker-compose.all.yml ├── docker-compose.yml ├── docs ├── .nojekyll ├── README.md └── index.html ├── frontend ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierrc.json ├── Caddyfile ├── Dockerfile ├── README.md ├── babel.config.js ├── cypress.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ ├── board-header.webp │ │ ├── cyber-header.webp │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── mmk.svg │ │ ├── sass │ │ │ ├── _footer.scss │ │ │ ├── app.scss │ │ │ └── scan-logs.scss │ │ ├── sky.webp │ │ └── tunnel.webp │ ├── components │ │ ├── scans │ │ │ └── ScanSummary.vue │ │ └── utils │ │ │ └── Confirm.vue │ ├── global.d.ts │ ├── main.ts │ ├── mixins │ │ ├── notify.ts │ │ └── table.ts │ ├── plugins │ │ └── vuetify.ts │ ├── router │ │ └── index.ts │ ├── services │ │ ├── alerts.ts │ │ ├── allow_list.ts │ │ ├── auth.ts │ │ ├── index.ts │ │ ├── iocs.ts │ │ ├── queues.ts │ │ ├── scan_logs.ts │ │ ├── scans.ts │ │ ├── secrets.ts │ │ ├── seen_strings.ts │ │ ├── sites.ts │ │ ├── sources.ts │ │ └── user.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ ├── store │ │ └── index.ts │ ├── types │ │ └── types.d.ts │ └── views │ │ ├── Login.vue │ │ ├── Overview.vue │ │ ├── allow_list │ │ └── AllowListForm.vue │ │ ├── dashboard │ │ ├── Alerts.vue │ │ ├── AllowList.vue │ │ ├── Dashboard.vue │ │ ├── IOCs.vue │ │ ├── Index.vue │ │ ├── Scans.vue │ │ ├── Secrets.vue │ │ ├── SeenStrings.vue │ │ ├── Sites.vue │ │ ├── Sources.vue │ │ ├── Users.vue │ │ └── components │ │ │ └── core │ │ │ ├── AppBar.vue │ │ │ ├── Drawer.vue │ │ │ ├── Footer.vue │ │ │ └── View.vue │ │ ├── iocs │ │ └── IocForm.vue │ │ ├── scans │ │ └── ScanLog.vue │ │ ├── secrets │ │ └── SecretForm.vue │ │ ├── seen_strings │ │ └── SeenStringForm.vue │ │ ├── sites │ │ ├── Site.vue │ │ └── SiteForm.vue │ │ ├── sources │ │ └── SourceForm.vue │ │ └── users │ │ └── UserForm.vue ├── tests │ └── e2e │ │ ├── .eslintrc.js │ │ ├── plugins │ │ └── index.js │ │ └── support │ │ ├── commands.js │ │ └── index.js ├── tsconfig.json └── vue.config.js ├── merrymaker.code-workspace ├── package.json ├── scanner ├── .eslintrc.js ├── .prettierrc.json ├── Dockerfile ├── config │ ├── Config.d.ts │ ├── default.json │ ├── deployment │ │ └── docker.json │ └── env │ │ ├── development.json │ │ └── test.json ├── env_secrets_expand.sh ├── jest.config.js ├── nodemon.json ├── package.json ├── scripts │ └── protoc.sh ├── src │ ├── globals.d.ts │ ├── lib │ │ ├── bull-worker.ts │ │ ├── redis.ts │ │ ├── scan-event-handler.ts │ │ ├── utils.ts │ │ └── yara-sync.ts │ ├── loaders │ │ └── logger.ts │ ├── rules │ │ ├── base.ts │ │ ├── google-analytics.ts │ │ ├── html-snapshot.ts │ │ ├── index.ts │ │ ├── ioc.domain.ts │ │ ├── ioc.payload.ts │ │ ├── ioc.payloads.yara │ │ ├── skimmer.yara │ │ ├── unknown-domain.ts │ │ ├── websocket.ts │ │ └── yara.ts │ ├── tests │ │ ├── rules.ioc.payload.test.ts │ │ ├── rules.skimmer.test.ts │ │ ├── rules.unknown.domain.test.ts │ │ ├── rules.websocket.test.ts │ │ └── samples │ │ │ ├── basic.js │ │ │ ├── caesar.js │ │ │ ├── cryptojs.core.min.js │ │ │ ├── freshchat.js.sample │ │ │ ├── frontend-ioc-hit.js │ │ │ ├── frontend-ioc-miss.js │ │ │ ├── gibberish-aes.js │ │ │ ├── gibberish-obf.js │ │ │ ├── give-basic-sample │ │ │ ├── glytic.js.sample │ │ │ ├── jsencrypt.js │ │ │ ├── loop-commerce.js │ │ │ └── slow-aes.js │ └── worker.ts └── tsconfig.json ├── vetur.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { "node": "current" }, 5 | }] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | test 3 | data/* 4 | *.log 5 | node_modules 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | prettier.semi = false 12 | -------------------------------------------------------------------------------- /.github/workflows/pr-actions.yml: -------------------------------------------------------------------------------- 1 | name: pr-actions 2 | on: [pull_request] 3 | 4 | jobs: 5 | backend-jest-test: 6 | runs-on: ubuntu-latest 7 | container: node:16.15.0-alpine 8 | 9 | services: 10 | postgres: 11 | image: postgres:12.7 12 | env: 13 | POSTGRES_USER: admin 14 | POSTGRES_DB: merrymaker 15 | POSTGRES_PASSWORD: password 16 | ports: 17 | - 5432:5432 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | redis: 24 | image: redis 25 | 26 | steps: 27 | - name: Check out repo code 28 | uses: actions/checkout@v2 29 | 30 | - name: Install yara 31 | run: | 32 | echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories 33 | echo "http://dl-cdn.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories 34 | apk add --no-cache libcrypto3 yara-dev openssh bash git 35 | apk add --no-cache --virtual .gyp python3 make g++ 36 | env: 37 | LANG: C.UTF.8 38 | - name: Install deps 39 | run: yarn install --frozen-lockfile 40 | 41 | - name: Audit deps 42 | run: /bin/bash -c 'yarn audit; [[ $? -ge 8 ]] & exit || exit 0' 43 | 44 | - name: Linting & testing 45 | run: | 46 | cd backend 47 | yarn install 48 | yarn lint:eslint 49 | yarn test 50 | cd ../frontend 51 | yarn lint 52 | cd ../scanner 53 | yarn install 54 | yarn lint:eslint 55 | yarn test 56 | env: 57 | NODE_ENV: test 58 | MMK_REDIS_HOST: redis 59 | DEPLOYMENT: gh 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output 3 | .DS_Store 4 | .sass-cache 5 | .vscode 6 | .vim 7 | files/*.js 8 | dist/ 9 | yarn-error.log 10 | screenshots 11 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.15.0 2 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint' 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'prettier', 12 | ], 13 | rules: { 14 | 'no-return-await': ['error'], 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | coverage/* 2 | test.log 3 | config/deployment/stage.json 4 | config/deployment/prod.json 5 | -------------------------------------------------------------------------------- /backend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /backend/@types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | declare module 'express-session' { 3 | import 'express-session' 4 | export interface Session { 5 | data: UserSession 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.15.0-alpine 2 | MAINTAINER Merrymaker Team "merrymaker@target.com" 3 | 4 | RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/main openssl 5 | RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing \ 6 | yara 7 | 8 | RUN apk add wget ca-certificates 9 | RUN update-ca-certificates --fresh 10 | 11 | RUN apk add --no-cache --virtual .gyp \ 12 | bash \ 13 | git \ 14 | openssh \ 15 | python3 \ 16 | make \ 17 | g++ 18 | 19 | ENV HOME=/home/merrymaker 20 | 21 | RUN mkdir -p /home/merrymaker/app 22 | 23 | ENV APP_HOME=$HOME/app 24 | 25 | WORKDIR $APP_HOME 26 | COPY package.json yarn.lock $APP_HOME/ 27 | RUN mkdir $APP_HOME/backend 28 | 29 | COPY ./backend/package.json $APP_HOME/backend/ 30 | COPY ./backend/config $APP_HOME/backend/config 31 | 32 | RUN yarn workspace backend install 33 | 34 | COPY ./backend/ $APP_HOME/backend/ 35 | 36 | WORKDIR $APP_HOME/backend 37 | 38 | RUN yarn build 39 | 40 | RUN rm -rf node_modules 41 | RUN yarn workspace backend install --prod 42 | 43 | FROM node:16.15.0-alpine 44 | 45 | RUN mkdir -p /app 46 | 47 | COPY --from=0 /home/merrymaker/app/node_modules /app/node_modules 48 | COPY --from=0 /home/merrymaker/app/backend/dist /app 49 | COPY --from=0 /home/merrymaker/app/backend/package.json /app 50 | COPY --from=0 /home/merrymaker/app/backend/config /app/config 51 | 52 | RUN addgroup -S -g 992 merrymaker 53 | RUN adduser -S -G merrymaker merrymaker 54 | 55 | RUN chown -R merrymaker:merrymaker /app 56 | 57 | USER merrymaker 58 | 59 | WORKDIR /app 60 | CMD ["node", "/app/app.js"] 61 | -------------------------------------------------------------------------------- /backend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /backend/config/deployment/docker.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3001, 3 | "postgres": { 4 | "host": "postgres", 5 | "user": "admin", 6 | "password": "password", 7 | "database": "merrymaker", 8 | "secure": false 9 | }, 10 | "redis": { 11 | "uri": "redis://redis:6379", 12 | "nodes": [], 13 | "useSentinel": false 14 | }, 15 | "auth": { 16 | "strategy": "local" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/config/deployment/gh.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3001, 3 | "postgres": { 4 | "host": "postgres", 5 | "user": "admin", 6 | "password": "password", 7 | "database": "merrymaker", 8 | "secure": false 9 | }, 10 | "redis": { 11 | "uri": "redis://redis:6379", 12 | "nodes": [], 13 | "useSentinel": false 14 | }, 15 | "oauth": { 16 | "authURL": "https://localhost/oauth/auth", 17 | "tokenURL": "https://localhost/oauth/token", 18 | "clientID": "token", 19 | "secret": "no-a-secret", 20 | "redirectURL": "https://localhost", 21 | "scope": "openid" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/config/env/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3030, 3 | "server": { 4 | "uri": "http://localhost:8080", 5 | "ca": "/path/to/cert.crt" 6 | }, 7 | "auth": { 8 | "strategy": "local" 9 | }, 10 | "postgres": { 11 | "host": "localhost", 12 | "user": "admin", 13 | "password": "password", 14 | "database": "merrymaker", 15 | "secure": false, 16 | "ca": "none" 17 | }, 18 | "redis": { 19 | "uri": "redis://localhost:6379", 20 | "useSentinel": false, 21 | "nodes": ["localhost"], 22 | "sentinelPort": 26379, 23 | "master": "", 24 | "sentinelPassword": "" 25 | }, 26 | "authorizations": { 27 | "admin": "CN=MerryMaker-Admin", 28 | "user": "CN=MerryMaker-User", 29 | "transport": "CN=MerryMaker-transport" 30 | }, 31 | "transport": { 32 | "enabled": true, 33 | "port": 3031, 34 | "mTLS": false 35 | }, 36 | "quantumTunnel": { 37 | "enabled": "false", 38 | "clientID": "fake", 39 | "secret": "fake", 40 | "user": "string", 41 | "password": "string", 42 | "key": "string", 43 | "url": "string" 44 | }, 45 | "oauth": { 46 | "authURL": "string", 47 | "tokenURL": "string", 48 | "clientID": "string", 49 | "secret": "string", 50 | "redirectURL": "string", 51 | "scope": "string" 52 | }, 53 | "alerts": { 54 | "kafka": { 55 | "clientID": "mmk2", 56 | "enabled": false, 57 | "host": "localhost", 58 | "port": 9093, 59 | "topic": "merrymaker-raw", 60 | "cert": "path/to/cert.crt", 61 | "key": "path/to/key.key" 62 | }, 63 | "goAlert": { 64 | "enabled": false, 65 | "url": "https://webalertdomain.com" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /backend/config/env/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3001, 3 | "postgres": { 4 | "host": "localhost", 5 | "user": "admin", 6 | "password": "password", 7 | "database": "merrymaker_test", 8 | "secure": false 9 | }, 10 | "auth": { 11 | "strategy": "local" 12 | }, 13 | "redis": { 14 | "uri": "redis://localhost:6379", 15 | "useSentinel": false, 16 | "nodes": ["localhost"], 17 | "sentinelPort": 26379, 18 | "master": "", 19 | "sentinelPassword": "" 20 | }, 21 | "oauth": { 22 | "authURL": "https://localhost/oauth/auth", 23 | "tokenURL": "https://localhost/oauth/token", 24 | "clientID": "token", 25 | "secret": "not-a-secret", 26 | "redirectURL": "https://localhost", 27 | "scope": "openid" 28 | }, 29 | "alerts": { 30 | "goAlert": { 31 | "enabled": false 32 | }, 33 | "kafka": { 34 | "enabled": false 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | silent: false, 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /backend/knexfile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { ConnectionOptions } from 'tls' 3 | import { config } from 'node-config-ts' 4 | import { Knex } from 'knex' 5 | 6 | const connOptions: Knex.PgConnectionConfig = { 7 | host: config.postgres.host, 8 | user: config.postgres.user, 9 | database: config.postgres.database, 10 | password: config.postgres.password 11 | } 12 | 13 | if (config.postgres.secure) { 14 | connOptions.ssl = { 15 | rejectUnauthorized: false, 16 | ca: fs.readFileSync(config.postgres.ca).toString() 17 | } as ConnectionOptions 18 | } 19 | 20 | const knexConfig = { 21 | client: 'pg', 22 | useNullAsDefault: true, 23 | connection: connOptions, 24 | pool: { 25 | min: 2, 26 | max: 10 27 | }, 28 | migrations: { 29 | tableName: 'knex_migrations', 30 | directory: './src/migrations', 31 | disableTransactions: true 32 | } 33 | } 34 | 35 | module.exports = { 36 | test: knexConfig, 37 | development: knexConfig, 38 | staging: knexConfig, 39 | production: knexConfig 40 | } 41 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | "config" 5 | ], 6 | "ext": "js,ts,json", 7 | "ignore": [ 8 | "src/**/*.spec.ts" 9 | ], 10 | "exec": "ts-node --transpile-only ./src/app.ts" 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/alert-runner.ts: -------------------------------------------------------------------------------- 1 | import MerryMaker from '@merrymaker/types' 2 | import AlertService from './services/alert' 3 | 4 | ;(async () => { 5 | // error 6 | await AlertService.process({ 7 | level: 'error', 8 | entry: 'error', 9 | scan_id: '000-000', 10 | event: { 11 | message: 'test error message', 12 | }, 13 | }) 14 | // rule-alert 15 | await AlertService.process({ 16 | level: 'info', 17 | entry: 'rule-alert', 18 | rule: 'test.rule', 19 | scan_id: '000-0000', 20 | event: { 21 | alert: true, 22 | name: 'test.rule', 23 | error: false, 24 | level: 'test', 25 | description: 'test rule alert - ignore', 26 | playbook: 'http://test/playbook', 27 | context: { key: 'value' }, 28 | message: 'rule alert', 29 | } as MerryMaker.RuleAlert, 30 | } as MerryMaker.RuleAlertEvent) 31 | })() 32 | -------------------------------------------------------------------------------- /backend/src/alerts/base.ts: -------------------------------------------------------------------------------- 1 | /** Alert type Base */ 2 | import MerryMaker from '@merrymaker/types' 3 | 4 | export interface AlertEvent { 5 | type: 'info' | 'error' | 'warning' 6 | name: string 7 | scan_id: string 8 | message: string 9 | details: string 10 | body?: object 11 | } 12 | 13 | export interface AlertSinkBase { 14 | name: string 15 | enabled: boolean 16 | send: ( 17 | evt: MerryMaker.EventResult | MerryMaker.RuleAlertEvent | AlertEvent 18 | ) => Promise 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/alerts/go-alert.ts: -------------------------------------------------------------------------------- 1 | /* HTTP alert type */ 2 | import fetch from 'node-fetch' 3 | import fs from 'fs' 4 | import https from 'https' 5 | import queryString from 'querystring' 6 | 7 | import { config } from 'node-config-ts' 8 | import logger from '../loaders/logger' 9 | import { AlertSinkBase, AlertEvent } from './base' 10 | 11 | const MAX_GO_ALERT_LEN = 128 12 | 13 | /** 14 | * queryFromAlert 15 | * 16 | * formats AlertEvent into GoAlert query string 17 | */ 18 | export const queryFromAlert = (evt: AlertEvent, token: string): string => 19 | queryString.stringify({ 20 | summary: `${evt.name} - ${evt.message}`.substring(0, MAX_GO_ALERT_LEN), 21 | details: `${evt.details}`.substring(0, MAX_GO_ALERT_LEN), 22 | token, 23 | }) 24 | 25 | /** 26 | * goAlert 27 | * 28 | * sends goAlert message from AlertEvent 29 | */ 30 | export const init = (goAlertConfig: typeof config.alerts.goAlert) => async ( 31 | evt: AlertEvent 32 | ): Promise => { 33 | logger.info({ 34 | task: 'go-alert/send', 35 | action: 'requested to send alert', 36 | }) 37 | if (!goAlertConfig.enabled) return 38 | 39 | const agent = new https.Agent({ 40 | rejectUnauthorized: true, 41 | ca: [fs.readFileSync(config.server.ca)], 42 | }) 43 | 44 | const query = queryFromAlert(evt, goAlertConfig.token) 45 | try { 46 | const res = await fetch(`${goAlertConfig.url}?${query}`, { 47 | method: 'post', 48 | agent, 49 | }) 50 | const body = await res.text() 51 | logger.info({ 52 | task: 'go-alert/send', 53 | result: body, 54 | }) 55 | return true 56 | } catch (e) { 57 | logger.error({ 58 | task: 'go-alert/send', 59 | error: e.message, 60 | }) 61 | throw e 62 | } 63 | } 64 | 65 | export default { 66 | name: 'HTTP Alert Sink', 67 | enabled: config.alerts.goAlert?.enabled === true, 68 | send: init(config.alerts.goAlert), 69 | } as AlertSinkBase 70 | -------------------------------------------------------------------------------- /backend/src/api/crud/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema, PathParam, ParamSchema } from 'aejo' 2 | 3 | export const uuidParams = PathParam({ 4 | name: 'id', 5 | description: 'Record ID', 6 | schema: { 7 | type: 'string', 8 | format: 'uuid', 9 | }, 10 | }) 11 | 12 | export const validationContext: ParamSchema = { 13 | type: 'object', 14 | properties: { 15 | keyword: { 16 | type: 'string', 17 | enum: ['format'], 18 | }, 19 | dataPath: { 20 | type: 'string', 21 | }, 22 | schemaPath: { 23 | type: 'string', 24 | }, 25 | params: { 26 | type: 'object', 27 | properties: { 28 | format: { 29 | type: 'string', 30 | }, 31 | }, 32 | }, 33 | message: { 34 | type: 'string', 35 | }, 36 | }, 37 | } 38 | 39 | export const validationErrorResponse: MediaSchema = { 40 | description: 'ValidationError', 41 | content: { 42 | 'application/json': { 43 | schema: { 44 | type: 'object', 45 | properties: { 46 | name: { 47 | type: 'string', 48 | enum: ['ValidationError'], 49 | }, 50 | context: { 51 | type: 'array', 52 | items: validationContext, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | } 59 | 60 | export const uuidFormat = 61 | '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' 62 | -------------------------------------------------------------------------------- /backend/src/api/middleware/aejo-errors.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { ValidationError } from 'aejo/dist/lib/errors' 3 | 4 | import httpEvent from '../../subscribers/http' 5 | 6 | export default function (err: Error, req: Request, res: Response): boolean { 7 | const evt = { req, res, err } 8 | if (err instanceof ValidationError) { 9 | const context = err.context[0] 10 | httpEvent.emit('clientWarning', { 11 | ...evt, 12 | details: { 13 | reason: err.message, 14 | type: context.keyword, 15 | name: context.message, 16 | }, 17 | }) 18 | res.status(422).send({ 19 | message: 'Validation error', 20 | type: 'ValidationError', 21 | data: { 22 | reason: `${context.instancePath.substr(1)}: ${context.message}`, 23 | path: context.instancePath, 24 | }, 25 | }) 26 | return true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/api/middleware/client-errors.ts: -------------------------------------------------------------------------------- 1 | type ErrorContextTypes = 2 | | 'timeout' 3 | | 'client' 4 | | 'forbidden' 5 | | 'unauthorized' 6 | | 'invalid_creds' 7 | 8 | interface ClientErrorContext { 9 | type: ErrorContextTypes 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | event: any 12 | } 13 | 14 | export class ClientError extends Error { 15 | public readonly context: ClientErrorContext 16 | constructor(message: string, context?: ClientErrorContext) { 17 | super(message) 18 | this.context = context || { type: 'client', event: 'general' } 19 | Object.setPrototypeOf(this, ClientError.prototype) 20 | Error.captureStackTrace(this, ClientError) 21 | } 22 | } 23 | 24 | export class ForbiddenError extends ClientError { 25 | constructor(resource: string, access: unknown) { 26 | super('You do not have permission to perform this action', { 27 | type: 'forbidden', 28 | event: { resource, access }, 29 | }) 30 | } 31 | } 32 | 33 | export class InvalidCreds extends ClientError { 34 | constructor(resource: string, access: unknown) { 35 | super('Your login/password was not accepted', { 36 | type: 'invalid_creds', 37 | event: { resource, access }, 38 | }) 39 | } 40 | } 41 | 42 | export class UnauthorizedError extends ClientError { 43 | constructor(resource: string, user: string) { 44 | super(`You must be logged in to access this resouce - ${resource}`, { 45 | type: 'unauthorized', 46 | event: { user }, 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/api/middleware/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { ClientError } from './client-errors' 3 | import httpEvent from '../../subscribers/http' 4 | 5 | export default function (err: unknown, req: Request, res: Response): boolean { 6 | if (!(err instanceof ClientError)) { 7 | return false 8 | } 9 | switch (err.context.type) { 10 | case 'forbidden': 11 | res 12 | .status(403) 13 | .send({ message: err.message, type: 'Forbidden', data: err.context }) 14 | break 15 | case 'unauthorized': 16 | case 'invalid_creds': 17 | res 18 | .status(401) 19 | .send({ message: err.message, type: 'Unauthorized', data: err.context }) 20 | break 21 | default: 22 | res 23 | .status(422) 24 | .send({ message: err.message, type: 'ClientError', data: err.context }) 25 | } 26 | httpEvent.emit('clientWarning', { 27 | req, 28 | res, 29 | err, 30 | details: err.context, 31 | }) 32 | return true 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/api/oas.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import { PathItem } from 'aejo' 3 | 4 | export default (paths: PathItem) => ({ 5 | openapi: '3.0.0', 6 | info: { 7 | version: '2.0.0', 8 | title: 'MerryMaker', 9 | description: 'MerryMaker API Schema', 10 | }, 11 | paths, 12 | }) 13 | -------------------------------------------------------------------------------- /backend/src/api/routes/alerts/agg.ts: -------------------------------------------------------------------------------- 1 | import { AsyncGet, QueryParam } from 'aejo' 2 | import { Request, Response, NextFunction } from 'express' 3 | import sub from 'date-fns/sub' 4 | import AlertService from '../../../services/alert' 5 | 6 | export default AsyncGet({ 7 | tags: ['alerts'], 8 | description: 'Alert Aggregation', 9 | parameters: [ 10 | QueryParam({ 11 | name: 'interval_hours', 12 | description: 'Group alerts by hours', 13 | schema: { 14 | type: 'number', 15 | minimum: 1, 16 | default: 1 17 | } 18 | }), 19 | QueryParam({ 20 | name: 'start_time', 21 | description: 'Start range', 22 | schema: { 23 | type: 'string', 24 | format: 'date-time', 25 | default: 'one week ago' 26 | } 27 | }), 28 | QueryParam({ 29 | name: 'end_time', 30 | description: 'Start range', 31 | schema: { 32 | type: 'string', 33 | format: 'date-time', 34 | default: 'now' 35 | } 36 | }) 37 | ], 38 | middleware: [ 39 | async (req: Request, res: Response, next: NextFunction): Promise => { 40 | const { interval_hours, start_time, end_time } = req.query 41 | let local_interval = 1 42 | if (interval_hours && typeof interval_hours == 'number') { 43 | local_interval = interval_hours 44 | } 45 | let local_start_time = sub(new Date(), { weeks: 1 }) 46 | if (start_time) { 47 | local_start_time = new Date(local_start_time) 48 | } 49 | let local_end_time = new Date() 50 | if (end_time && typeof end_time == 'string') { 51 | local_end_time = new Date(end_time) 52 | } 53 | const agg = await AlertService.dateHist({ 54 | starttime: local_start_time, 55 | endtime: local_end_time, 56 | interval: local_interval 57 | }) 58 | res.set('Cache-Control', 'no-store') 59 | res.status(200).send({ rows: agg.rows }) 60 | next() 61 | } 62 | ] 63 | }) 64 | -------------------------------------------------------------------------------- /backend/src/api/routes/alerts/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams } from './schemas' 4 | import AlertService from '../../../services/alert' 5 | 6 | export default AsyncDelete({ 7 | tags: ['alerts'], 8 | description: 'Delete Alert', 9 | parameters: [uuidParams], 10 | responses: { 11 | '200': { 12 | description: 'OK', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | total: { 19 | type: 'integer', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | '404': { 27 | description: 'Not Found', 28 | }, 29 | }, 30 | middleware: [ 31 | async (req: Request, res: Response, next: NextFunction): Promise => { 32 | const result = AlertService.destroy(req.params.id) 33 | res.status(200).send(result) 34 | next() 35 | }, 36 | ], 37 | }) 38 | -------------------------------------------------------------------------------- /backend/src/api/routes/alerts/distinct.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet, QueryParam } from 'aejo' 3 | import AlertService from '../../../services/alert' 4 | import Alert from '../../../models/alerts' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | const selectable = Alert.selectAble() as string[] 8 | 9 | export default AsyncGet({ 10 | tags: ['alerts'], 11 | description: 'Fetches distinct column values from Alerts', 12 | parameters: [ 13 | QueryParam({ 14 | name: 'column', 15 | description: 'Distinct column', 16 | schema: { 17 | type: 'string', 18 | enum: selectable, 19 | }, 20 | }), 21 | ], 22 | responses: { 23 | '200': { 24 | description: 'Ok', 25 | content: { 26 | 'application/json': { 27 | schema: { 28 | type: 'array', 29 | items: { 30 | type: 'object', 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | '422': validationErrorResponse, 37 | }, 38 | middleware: [ 39 | async (req: Request, res: Response, next: NextFunction): Promise => { 40 | const result = await AlertService.distinct(req.query.column as string) 41 | res.status(200).send(result) 42 | next() 43 | }, 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /backend/src/api/routes/alerts/index.ts: -------------------------------------------------------------------------------- 1 | // api/routes/alerts.ts 2 | import { Router } from 'express' 3 | import { AuthPathOp, Path, PathItem, Route, Scope } from 'aejo' 4 | import { Authorized, AuthScope } from '../../middleware/auth' 5 | import { uuidFormat } from '../../crud/schemas' 6 | 7 | import listRoute from './list' 8 | import viewRoute from './view' 9 | import deleteRoute from './delete' 10 | import distinctRoute from './distinct' 11 | import aggRoute from './agg' 12 | 13 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 14 | 15 | export default (router: Router): { paths: PathItem[]; router: Router } => 16 | Route( 17 | router, 18 | Path('/', AuthScope(listRoute)), 19 | Path('/agg', AuthScope(aggRoute)), 20 | Path(`/:id(${uuidFormat})`, AuthScope(viewRoute), AdminScope(deleteRoute)), 21 | Path('/distinct', AuthScope(distinctRoute)) 22 | ) 23 | -------------------------------------------------------------------------------- /backend/src/api/routes/alerts/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema, PathParam } from 'aejo' 2 | import { Schema } from '../../../models/alerts' 3 | 4 | export const uuidParams = PathParam({ 5 | name: 'id', 6 | description: 'Alert ID', 7 | schema: { 8 | type: 'string', 9 | format: 'uuid', 10 | }, 11 | }) 12 | 13 | export const alertResponse: MediaSchema = { 14 | description: 'Ok', 15 | content: { 16 | 'application/json': { 17 | schema: { 18 | type: 'object', 19 | properties: Schema, 20 | }, 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/api/routes/alerts/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { validationErrorResponse } from '../../crud/schemas' 4 | import AlertService from '../../../services/alert' 5 | import { alertResponse, uuidParams } from './schemas' 6 | 7 | export default AsyncGet({ 8 | tags: ['alerts'], 9 | description: 'View Alert', 10 | parameters: [uuidParams], 11 | responses: { 12 | '200': alertResponse, 13 | '404': { 14 | description: 'Not Found', 15 | }, 16 | '422': validationErrorResponse, 17 | }, 18 | middleware: [ 19 | async (req: Request, res: Response, next: NextFunction): Promise => { 20 | const record = await AlertService.view(req.params.id) 21 | res.status(200).send(record) 22 | next() 23 | }, 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/cache.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { cacheViewParams, cacheViewSchema } from '../../crud/cache' 4 | import { validationErrorResponse } from '../../crud/schemas' 5 | import AllowListService from '../../../services/allow_list' 6 | import { AllowListType } from '../../../models/allow_list' 7 | 8 | export default AsyncGet({ 9 | tags: ['allow_list'], 10 | description: 'Checks if record is found in cache', 11 | parameters: cacheViewParams, 12 | middleware: [ 13 | async (req: Request, res: Response, next: NextFunction): Promise => { 14 | const { type, key } = req.query as Record 15 | const hit = await AllowListService.cached_view({ type, key }) 16 | // if not in cache, check the DB 17 | if (!hit.has) { 18 | const dbHit = await AllowListService.findOne({ 19 | type: typeof AllowListType, 20 | key, 21 | }) 22 | if (dbHit) { 23 | hit.store = 'database' 24 | hit.has = true 25 | } 26 | } 27 | res.status(200).send(hit) 28 | next() 29 | }, 30 | ], 31 | responses: { 32 | '200': { 33 | description: 'Ok', 34 | content: { 35 | 'application/json': { 36 | schema: cacheViewSchema, 37 | }, 38 | }, 39 | }, 40 | '422': validationErrorResponse, 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { allowListBody, allowListResponse } from './schemas' 4 | import AllowListService from '../../../services/allow_list' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | export default AsyncPost({ 8 | tags: ['allow_list'], 9 | description: 'Create Allow List', 10 | requestBody: allowListBody, 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const created = await AllowListService.create(req.body.allow_list) 14 | res.status(200).send(created) 15 | next() 16 | }, 17 | ], 18 | responses: { 19 | '200': allowListResponse, 20 | '422': validationErrorResponse, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import AllowListService from '../../../services/allow_list' 5 | 6 | export default AsyncDelete({ 7 | tags: ['allow_list'], 8 | description: 'Delete Allow List', 9 | parameters: [uuidParams], 10 | responses: { 11 | '200': { 12 | description: 'OK', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | total: { 19 | type: 'integer', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | '404': { 27 | description: 'Not Found', 28 | }, 29 | '422': validationErrorResponse, 30 | }, 31 | middleware: [ 32 | async (req: Request, res: Response, next: NextFunction): Promise => { 33 | const deleted = await AllowListService.destroy(req.params.id) 34 | res.status(200).send({ total: deleted }) 35 | next() 36 | }, 37 | ], 38 | }) 39 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/index.ts: -------------------------------------------------------------------------------- 1 | // api/routes/allow_list.ts 2 | import { Path, PathItem, Route, AuthPathOp, Scope } from 'aejo' 3 | import { Router } from 'express' 4 | import { Authorized, AuthScope } from '../../middleware/auth' 5 | import { uuidFormat } from '../../crud/schemas' 6 | 7 | import listRoute from './list' 8 | import createRoute from './create' 9 | import cacheRoute from './cache' 10 | import viewRoute from './view' 11 | import updateRoute from './update' 12 | import deleteRoute from './delete' 13 | 14 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 15 | const TransportScope = AuthPathOp(Scope(Authorized, 'transport')) 16 | 17 | export default (router: Router): { paths: PathItem[]; router: Router } => 18 | Route( 19 | router, 20 | Path( 21 | '/', 22 | // All auth users 23 | AuthScope(listRoute), 24 | // Admin users 25 | AuthPathOp(Scope(Authorized, 'admin', 'transport'))(createRoute) 26 | ), 27 | Path('/_cache', TransportScope(cacheRoute)), 28 | Path( 29 | `/:id(${uuidFormat})`, 30 | AuthScope(viewRoute), 31 | AdminScope(updateRoute), 32 | AdminScope(deleteRoute) 33 | ) 34 | ) 35 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/allow_list' 3 | 4 | export const allowListBody: MediaSchema = { 5 | description: 'Allow List Object', 6 | content: { 7 | 'application/json': { 8 | schema: { 9 | type: 'object', 10 | properties: { 11 | allow_list: { 12 | type: 'object', 13 | properties: { 14 | type: Schema.type, 15 | key: Schema.key, 16 | }, 17 | required: ['type', 'key'], 18 | additionalProperties: false, 19 | }, 20 | }, 21 | additionalProperties: false, 22 | required: ['allow_list'], 23 | }, 24 | }, 25 | }, 26 | } 27 | 28 | export const allowListResponse: MediaSchema = { 29 | description: 'OK', 30 | content: { 31 | 'application/json': { 32 | schema: { 33 | type: 'object', 34 | properties: Schema, 35 | }, 36 | }, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/update.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPut } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { allowListBody, allowListResponse } from './schemas' 5 | import AllowListService from '../../../services/allow_list' 6 | 7 | export default AsyncPut({ 8 | tags: ['allow_list'], 9 | description: 'Update Allow List', 10 | parameters: [uuidParams], 11 | requestBody: allowListBody, 12 | responses: { 13 | '200': allowListResponse, 14 | '404': { 15 | description: 'Not Found', 16 | }, 17 | '422': validationErrorResponse, 18 | }, 19 | middleware: [ 20 | async (req: Request, res: Response, next: NextFunction): Promise => { 21 | const updated = await AllowListService.update( 22 | req.params.id, 23 | req.body.allow_list 24 | ) 25 | res.status(200).send(updated) 26 | next() 27 | }, 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /backend/src/api/routes/allow_list/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { allowListResponse } from './schemas' 5 | import AllowListService from '../../../services/allow_list' 6 | 7 | export default AsyncGet({ 8 | tags: ['allow_list'], 9 | description: 'View Allow List', 10 | parameters: [uuidParams], 11 | responses: { 12 | '200': allowListResponse, 13 | '404': { 14 | description: 'Not Found', 15 | }, 16 | '422': validationErrorResponse, 17 | }, 18 | middleware: [ 19 | async (req: Request, res: Response, next: NextFunction): Promise => { 20 | const record = await AllowListService.view(req.params.id) 21 | res.status(200).send(record) 22 | next() 23 | }, 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/auth/get-oauth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { config } from 'node-config-ts' 4 | 5 | import { Oauth } from '../../../lib/oauth' 6 | import OauthService from '../../../services/oauth' 7 | 8 | export default AsyncGet({ 9 | tags: ['auth'], 10 | middleware: [ 11 | async (req: Request, res: Response, next: NextFunction): Promise => { 12 | if (config.auth.strategy !== 'oauth') { 13 | res.status(404).send({ message: 'not enabled' }) 14 | return next() 15 | } 16 | const nonce = Oauth.generateNonce 17 | req.session.data = { nonce } 18 | res.redirect(OauthService.client.redirectURL(nonce)) 19 | }, 20 | ], 21 | }) 22 | -------------------------------------------------------------------------------- /backend/src/api/routes/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Path, PathItem, Route } from 'aejo' 3 | import { AuthScope, OauthScope } from '../../middleware/auth' 4 | 5 | import localLoginRoute from './local-login' 6 | import logoutRoute from './logout' 7 | import getOauthRoute from './get-oauth' 8 | import oauthCallBackRoute from './oauth-callback' 9 | import readyRoute from './ready' 10 | import sessionRoute from './session' 11 | 12 | export default (router: Router): { paths: PathItem[]; router: Router } => 13 | Route( 14 | router, 15 | Path('/login', localLoginRoute), 16 | Path('/logout', AuthScope(logoutRoute)), 17 | Path('/oauth', getOauthRoute), 18 | Path('/oauth_callback', OauthScope(oauthCallBackRoute)), 19 | Path('/ready', readyRoute), 20 | Path('/session', sessionRoute) 21 | ) 22 | -------------------------------------------------------------------------------- /backend/src/api/routes/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | 4 | export default AsyncGet({ 5 | tags: ['auth'], 6 | description: 'Local User Logout', 7 | responses: { 8 | '200': { 9 | description: 'Ok', 10 | content: { 11 | 'application/json': { 12 | schema: { 13 | type: 'object', 14 | properties: { 15 | logout: { 16 | type: 'boolean', 17 | }, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | middleware: [ 25 | async (req: Request, res: Response, next: NextFunction): Promise => { 26 | req.session.destroy((err) => { 27 | if (err) { 28 | res.status(500).send({ message: 'failed' }) 29 | } 30 | res.status(200).send({ logout: true }) 31 | next() 32 | }) 33 | }, 34 | ], 35 | }) 36 | -------------------------------------------------------------------------------- /backend/src/api/routes/auth/oauth-callback.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { JwtDecode } from 'jwt-js-decode' 3 | import { config } from 'node-config-ts' 4 | import { AsyncGet, QueryParam } from 'aejo' 5 | 6 | import OauthService from '../../../services/oauth' 7 | import { 8 | UnauthorizedError, 9 | ForbiddenError, 10 | } from '../../middleware/client-errors' 11 | 12 | export default AsyncGet({ 13 | tags: ['auth'], 14 | parameters: [ 15 | QueryParam({ 16 | name: 'code', 17 | description: 'Oauth callback code', 18 | required: true, 19 | schema: { 20 | type: 'string', 21 | }, 22 | }), 23 | ], 24 | middleware: [ 25 | async (req: Request, res: Response, next: NextFunction): Promise => { 26 | if (config.auth.strategy !== 'oauth') { 27 | res.status(404).send({ message: 'not enabled' }) 28 | return next() 29 | } 30 | let idToken: JwtDecode 31 | const { nonce } = req.session.data 32 | try { 33 | idToken = await OauthService.tokenRequest( 34 | nonce, 35 | req.query.code as string 36 | ) 37 | } catch (e) { 38 | throw new UnauthorizedError('oauth', e.message) 39 | } 40 | 41 | if (OauthService.oauthAuthorize(req.session, idToken)) { 42 | res.redirect(301, config.server.uri) 43 | } else { 44 | throw new ForbiddenError('oauth-callback', 'guest') 45 | } 46 | }, 47 | ], 48 | }) 49 | -------------------------------------------------------------------------------- /backend/src/api/routes/auth/ready.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { config } from 'node-config-ts' 4 | 5 | import OauthService from '../../../services/oauth' 6 | import UserService from '../../../services/user' 7 | 8 | export default AsyncGet({ 9 | tags: ['auth'], 10 | description: 'Verify auth strategies are ready', 11 | responses: { 12 | '200': { 13 | description: 'Ok', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | ready: { 20 | type: 'boolean', 21 | }, 22 | strategy: { 23 | type: 'string', 24 | enum: ['local', 'oauth'], 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | middleware: [ 33 | async (_req: Request, res: Response, next: NextFunction): Promise => { 34 | if (config.auth.strategy === 'oauth') { 35 | res 36 | .status(200) 37 | .send({ strategy: 'oauth', ready: OauthService.client !== undefined }) 38 | } else { 39 | const adminInst = await UserService.findOne({ 40 | login: 'admin', 41 | }) 42 | res 43 | .status(200) 44 | .send({ strategy: 'local', ready: adminInst !== undefined }) 45 | } 46 | next() 47 | }, 48 | ], 49 | }) 50 | -------------------------------------------------------------------------------- /backend/src/api/routes/health/check.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response, NextFunction } from 'express' 2 | import { Get, Path, PathItem, Route } from 'aejo' 3 | 4 | export default (router: Router): { paths: PathItem[]; router: Router } => 5 | Route( 6 | router, 7 | Path( 8 | '', 9 | Get({ 10 | tags: ['health check'], 11 | description: 'API Health Check', 12 | middleware: [ 13 | (_req: Request, res: Response, next: NextFunction) => { 14 | res.status(200).send('ok') 15 | next() 16 | } 17 | ], 18 | responses: { 19 | 200: { 20 | description: 'Ok', 21 | content: { 22 | 'text/plain': { 23 | schema: { 24 | type: 'string', 25 | example: 'ok' 26 | } 27 | } 28 | } 29 | } 30 | } 31 | }) 32 | ) 33 | ) 34 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/cache-view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { cacheViewParams, cacheViewSchema } from '../../crud/cache' 4 | import { validationErrorResponse } from '../../crud/schemas' 5 | import IocService from '../../../services/ioc' 6 | import { IocType } from '../../../models/iocs' 7 | 8 | export default AsyncGet({ 9 | tags: ['iocs'], 10 | description: 'Checks if record is found in cache', 11 | parameters: cacheViewParams, 12 | middleware: [ 13 | async (req: Request, res: Response, next: NextFunction): Promise => { 14 | const { type, key } = req.query as Record 15 | const hit = await IocService.cached_view({ type, key }) 16 | if (!hit.has) { 17 | const dbHit = await IocService.findOne({ 18 | type: type as IocType, 19 | value: key, 20 | }) 21 | if (dbHit) { 22 | hit.store = 'database' 23 | hit.has = true 24 | } 25 | } 26 | res.status(200).send(hit) 27 | next() 28 | }, 29 | ], 30 | responses: { 31 | '200': { 32 | description: 'Ok', 33 | content: { 34 | 'application/json': { 35 | schema: cacheViewSchema, 36 | }, 37 | }, 38 | }, 39 | '400': validationErrorResponse, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { iocBody, iocResponse } from './schemas' 4 | import IocService from '../../../services/ioc' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | export default AsyncPost({ 8 | tags: ['iocs'], 9 | description: 'Create IOC', 10 | requestBody: iocBody, 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const created = await IocService.create(req.body.ioc) 14 | res.status(200).send(created) 15 | next() 16 | }, 17 | ], 18 | responses: { 19 | '200': iocResponse, 20 | '422': validationErrorResponse, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | 5 | import IocService from '../../../services/ioc' 6 | 7 | export default AsyncDelete({ 8 | tags: ['iocs'], 9 | description: 'Delete IOC', 10 | parameters: [uuidParams], 11 | responses: { 12 | '200': { 13 | description: 'Ok', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | total: { 20 | type: 'integer', 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | '404': { 28 | description: 'Not Found', 29 | }, 30 | '422': validationErrorResponse, 31 | }, 32 | middleware: [ 33 | async (req: Request, res: Response, next: NextFunction): Promise => { 34 | const deleted = await IocService.destroy(req.params.id) 35 | res.status(200).send({ total: deleted }) 36 | next() 37 | }, 38 | ], 39 | }) 40 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import listRoute from './list' 3 | import createRoute from './create' 4 | import bulkCreateRoute from './bulk-create' 5 | import cacheViewRoute from './cache-view' 6 | import viewRoute from './view' 7 | import updateRoute from './update' 8 | import deleteRoute from './delete' 9 | 10 | import { Authorized, AuthScope } from '../../middleware/auth' 11 | import { AuthPathOp, Scope, Route, PathItem, Path } from 'aejo' 12 | import { uuidFormat } from '../../crud/schemas' 13 | 14 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 15 | const TransportScope = AuthPathOp(Scope(Authorized, 'transport')) 16 | 17 | export default (router: Router): { paths: PathItem[]; router: Router } => 18 | Route( 19 | router, 20 | Path('/', AuthScope(listRoute), AdminScope(createRoute)), 21 | Path('/bulk', AdminScope(bulkCreateRoute)), 22 | Path('/_cache', TransportScope(cacheViewRoute)), 23 | Path( 24 | `/:id(${uuidFormat})`, 25 | AuthScope(viewRoute), 26 | AdminScope(updateRoute), 27 | AdminScope(deleteRoute) 28 | ) 29 | ) 30 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/iocs' 3 | 4 | export const iocBody: MediaSchema = { 5 | description: 'IOC Object', 6 | content: { 7 | 'application/json': { 8 | schema: { 9 | type: 'object', 10 | properties: { 11 | ioc: { 12 | type: 'object', 13 | properties: { 14 | value: Schema.value, 15 | type: Schema.type, 16 | enabled: Schema.enabled, 17 | }, 18 | required: ['value', 'type', 'enabled'], 19 | additionalProperties: false, 20 | }, 21 | }, 22 | required: ['ioc'], 23 | additionalProperties: false, 24 | }, 25 | }, 26 | }, 27 | } 28 | 29 | export const iocResponse: MediaSchema = { 30 | description: 'Ok', 31 | content: { 32 | 'application/json': { 33 | schema: { 34 | type: 'object', 35 | properties: Schema, 36 | }, 37 | }, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/update.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPut } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { iocBody, iocResponse } from './schemas' 5 | 6 | import IocService from '../../../services/ioc' 7 | 8 | export default AsyncPut({ 9 | tags: ['iocs'], 10 | description: 'Update IOC', 11 | parameters: [uuidParams], 12 | requestBody: iocBody, 13 | responses: { 14 | '200': iocResponse, 15 | '404': { 16 | description: 'Not Found', 17 | }, 18 | '422': validationErrorResponse, 19 | }, 20 | middleware: [ 21 | async (req: Request, res: Response, next: NextFunction): Promise => { 22 | const updated = await IocService.update(req.params.id, req.body.ioc) 23 | res.status(200).send(updated) 24 | next() 25 | }, 26 | ], 27 | }) 28 | -------------------------------------------------------------------------------- /backend/src/api/routes/iocs/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { validationErrorResponse } from '../../crud/schemas' 4 | import { uuidParams } from '../../crud/schemas' 5 | import { iocResponse } from './schemas' 6 | import IocService from '../../../services/ioc' 7 | 8 | export default AsyncGet({ 9 | tags: ['iocs'], 10 | description: 'View IOC', 11 | parameters: [uuidParams], 12 | responses: { 13 | '200': iocResponse, 14 | '404': { 15 | description: 'Not Found', 16 | }, 17 | '422': validationErrorResponse, 18 | }, 19 | middleware: [ 20 | async (req: Request, res: Response, next: NextFunction): Promise => { 21 | const record = await IocService.view(req.params.id) 22 | res.status(200).send(record) 23 | next() 24 | }, 25 | ], 26 | }) 27 | -------------------------------------------------------------------------------- /backend/src/api/routes/queues/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Route, Path, PathItem, Scope, AuthPathOp } from 'aejo' 3 | import { Authorized } from '../../middleware/auth' 4 | 5 | import jobsRoute from './jobs' 6 | 7 | const UserScope = AuthPathOp(Scope(Authorized, 'user')) 8 | export default (router: Router): { paths: PathItem[]; router: Router } => 9 | Route(router, Path('/', UserScope(jobsRoute))) 10 | -------------------------------------------------------------------------------- /backend/src/api/routes/queues/jobs.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { redisClient } from '../../../repos/redis' 4 | 5 | export default AsyncGet({ 6 | tags: ['queues'], 7 | description: 'Fetch queue counts', 8 | responses: { 9 | '200': { 10 | description: 'Ok', 11 | content: { 12 | 'application/json': { 13 | schema: { 14 | type: 'object', 15 | properties: { 16 | event: { 17 | description: 'Number of events waiting to be processed', 18 | type: 'integer', 19 | }, 20 | scanner: { 21 | description: 'Number of active scans', 22 | type: 'integer', 23 | }, 24 | schedule: { 25 | description: 'Number of scheduled scans waiting to be run', 26 | type: 'integer', 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | '404': { 34 | description: 'Queue missing from cache', 35 | }, 36 | }, 37 | middleware: [ 38 | async (_req: Request, res: Response, next: NextFunction): Promise => { 39 | const queues = await redisClient.get('job-queue') 40 | if (queues !== null) { 41 | res.status(200).send(JSON.parse(queues)) 42 | } else { 43 | res.sendStatus(404) 44 | } 45 | next() 46 | }, 47 | ], 48 | }) 49 | -------------------------------------------------------------------------------- /backend/src/api/routes/scan_logs/distinct.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet, QueryParam } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import ScanLog from '../../../models/scan_logs' 5 | import ScanLogService from '../../../services/scan_logs' 6 | 7 | const selectable = ScanLog.selectAble() as string[] 8 | 9 | export default AsyncGet({ 10 | tags: ['scan_logs'], 11 | description: 'Fetches distinct column values from a single ScanLog', 12 | parameters: [ 13 | uuidParams, 14 | QueryParam({ 15 | name: 'column', 16 | required: true, 17 | description: 'Distinct column', 18 | schema: { 19 | type: 'string', 20 | enum: selectable, 21 | }, 22 | }), 23 | ], 24 | responses: { 25 | '200': { 26 | description: 'Ok', 27 | content: { 28 | 'application/json': { 29 | schema: { 30 | type: 'array', 31 | items: { 32 | type: 'object', 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | '422': validationErrorResponse, 39 | '404': { 40 | description: 'Not Found', 41 | }, 42 | }, 43 | middleware: [ 44 | async (req: Request, res: Response, next: NextFunction): Promise => { 45 | const result = await ScanLogService.distinct(req.query.column as string, { 46 | scan_id: req.params.id, 47 | }) 48 | res.status(200).send(result) 49 | next() 50 | }, 51 | ], 52 | }) 53 | -------------------------------------------------------------------------------- /backend/src/api/routes/scan_logs/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthPathOp, Path, PathItem, Route, Scope } from 'aejo' 2 | import { Router } from 'express' 3 | import { uuidFormat } from '../..//crud/schemas' 4 | import { Authenticated } from '../../middleware/auth' 5 | 6 | import listRoute from './list' 7 | import viewRoute from './view' 8 | import distinctRoute from './distinct' 9 | 10 | const AuthScope = AuthPathOp(Scope(Authenticated, 'user')) 11 | 12 | export default (router: Router): { paths: PathItem[]; router: Router } => 13 | Route( 14 | router, 15 | Path('/', AuthScope(listRoute)), 16 | Path(`/:id(${uuidFormat})`, AuthScope(viewRoute)), 17 | Path(`/:id(${uuidFormat})/distinct`, AuthScope(distinctRoute)) 18 | ) 19 | -------------------------------------------------------------------------------- /backend/src/api/routes/scan_logs/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/scan_logs' 3 | 4 | export const scanLogResponse: MediaSchema = { 5 | description: 'OK', 6 | content: { 7 | 'application/json': { 8 | schema: { 9 | type: 'object', 10 | properties: Schema, 11 | }, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/api/routes/scan_logs/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { scanLogResponse } from './schemas' 5 | import ScanLogService from '../../../services/scan_logs' 6 | 7 | export default AsyncGet({ 8 | tags: ['scan_logs'], 9 | description: 'View ScanLog', 10 | parameters: [uuidParams], 11 | responses: { 12 | '200': scanLogResponse, 13 | '404': { 14 | description: 'Not Found', 15 | }, 16 | '422': validationErrorResponse, 17 | }, 18 | middleware: [ 19 | async (req: Request, res: Response, next: NextFunction): Promise => { 20 | const record = await ScanLogService.view(req.params.id) 21 | res.status(200).send(record) 22 | next() 23 | }, 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/scans/bulk-delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { validationErrorResponse } from '../../crud/schemas' 4 | 5 | import ScanService from '../../../services/scan' 6 | import { ClientError } from '../..//middleware/client-errors' 7 | 8 | export default AsyncPost({ 9 | tags: ['scans'], 10 | description: 'Bulk Delete Scan', 11 | requestBody: { 12 | description: 'Bulk Delete Scan Request', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | scans: { 19 | type: 'object', 20 | properties: { 21 | ids: { 22 | type: 'array', 23 | items: { 24 | type: 'string', 25 | format: 'uuid', 26 | }, 27 | }, 28 | }, 29 | required: ['ids'], 30 | 31 | additionalProperties: false, 32 | }, 33 | }, 34 | required: ['scans'], 35 | additionalProperties: false, 36 | }, 37 | }, 38 | }, 39 | }, 40 | responses: { 41 | '200': { 42 | description: 'Ok', 43 | }, 44 | '422': validationErrorResponse, 45 | }, 46 | middleware: [ 47 | async (req: Request, res: Response, next: NextFunction): Promise => { 48 | const { ids } = req.body.scans as Record 49 | if (ids && Array.isArray(ids)) { 50 | const hasActives = await ScanService.isBulkActive(ids) 51 | if (hasActives) { 52 | throw new ClientError('Cannot delete active scans') 53 | } 54 | await ScanService.bulkDelete(ids) 55 | res.status(200).send({ message: 'ok' }) 56 | } 57 | next() 58 | }, 59 | ], 60 | }) 61 | -------------------------------------------------------------------------------- /backend/src/api/routes/scans/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | 5 | import ScanService from '../../../services/scan' 6 | import { ClientError } from '../../middleware/client-errors' 7 | 8 | export default AsyncDelete({ 9 | tags: ['scans'], 10 | description: 'Delete Scan', 11 | parameters: [uuidParams], 12 | responses: { 13 | '200': { 14 | description: 'Ok', 15 | content: { 16 | 'application/json': { 17 | schema: { 18 | type: 'object', 19 | properties: { 20 | total: { 21 | type: 'integer', 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | }, 28 | '404': { 29 | description: 'Not Found', 30 | }, 31 | '422': validationErrorResponse, 32 | }, 33 | middleware: [ 34 | async (req: Request, res: Response, next: NextFunction): Promise => { 35 | const { id } = req.params 36 | const active = await ScanService.isActive(id) 37 | if (active) { 38 | throw new ClientError('Cannot delete active scan') 39 | } 40 | const result = ScanService.destroy(id) 41 | res.status(200).send(result) 42 | next() 43 | }, 44 | ], 45 | }) 46 | -------------------------------------------------------------------------------- /backend/src/api/routes/scans/handlers.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from 'objection' 2 | import { Source, Site, Scan } from '../../../models' 3 | 4 | export const eagerLoad = ( 5 | eagers: string[], 6 | builder: QueryBuilder 7 | ): void => { 8 | if (eagers.includes('sources')) { 9 | builder.withGraphFetched('source(selectName)').modifiers({ 10 | // only include the name 11 | selectName(builder: QueryBuilder) { 12 | builder.select('name') 13 | }, 14 | }) 15 | } 16 | if (eagers.includes('sites')) { 17 | builder.withGraphFetched('site(selectName)').modifiers({ 18 | // only include the name 19 | selectName(builder: QueryBuilder) { 20 | builder.select('name') 21 | }, 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/api/routes/scans/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthPathOp, Path, PathItem, Route, Scope } from 'aejo' 2 | import { Router } from 'express' 3 | import { uuidFormat } from '../..//crud/schemas' 4 | import { AuthScope, Authorized } from '../../middleware/auth' 5 | 6 | import listRoute from './list' 7 | import viewRoute from './view' 8 | import deleteRoute from './delete' 9 | import bulkDeleteRoute from './bulk-delete' 10 | import summaryRoute from './summary' 11 | 12 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 13 | 14 | export default (router: Router): { paths: PathItem[]; router: Router } => 15 | Route( 16 | router, 17 | Path('/', AuthScope(listRoute)), 18 | Path(`/:id(${uuidFormat})`, AuthScope(viewRoute), AdminScope(deleteRoute)), 19 | Path('/bulk_delete', AdminScope(bulkDeleteRoute)), 20 | Path(`/:id(${uuidFormat})/summary`, summaryRoute) 21 | ) 22 | -------------------------------------------------------------------------------- /backend/src/api/routes/scans/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet, QueryParam } from 'aejo' 3 | 4 | import { QueryBuilder } from 'objection' 5 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 6 | import Scan, { Schema } from '../../../models/scans' 7 | import { eagerLoad } from './handlers' 8 | import ScanService from '../../../services/scan' 9 | 10 | export default AsyncGet({ 11 | tags: ['scans'], 12 | description: 'View Scan', 13 | parameters: [ 14 | uuidParams, 15 | QueryParam({ 16 | name: 'eager', 17 | description: 'Eager load Site or Source', 18 | schema: { 19 | type: 'array', 20 | items: { 21 | type: 'string', 22 | enum: ['sources', 'sites'], 23 | }, 24 | }, 25 | }), 26 | ], 27 | responses: { 28 | '200': { 29 | description: 'Ok', 30 | content: { 31 | 'application/json': { 32 | schema: { 33 | type: 'object', 34 | properties: Schema, 35 | // TODO - missing optional eagers 36 | }, 37 | }, 38 | }, 39 | }, 40 | '404': { 41 | description: 'Not Found', 42 | }, 43 | '422': validationErrorResponse, 44 | }, 45 | middleware: [ 46 | async (req: Request, res: Response, next: NextFunction): Promise => { 47 | const { eager } = req.query as Record 48 | res.locals.whereBuilder = (builder: QueryBuilder) => { 49 | if (eager && Array.isArray(eager)) { 50 | eagerLoad(req.query.eager as string[], builder) 51 | } 52 | } 53 | next() 54 | }, 55 | async (req: Request, res: Response, next: NextFunction): Promise => { 56 | const record = await ScanService.view(req.params.id) 57 | res.status(200).send(record) 58 | next() 59 | }, 60 | ], 61 | }) 62 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { Schema } from '../../../models/secrets' 4 | import SecretService from '../../../services/secret' 5 | import { secretResponse } from './schemas' 6 | import { validationErrorResponse } from '../../crud/schemas' 7 | 8 | export default AsyncPost({ 9 | tags: ['secrets'], 10 | description: 'Create Secret', 11 | requestBody: { 12 | description: 'Secret Object', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | secret: { 19 | type: 'object', 20 | properties: { 21 | name: Schema.name, 22 | type: Schema.type, 23 | value: Schema.value, 24 | }, 25 | required: ['name', 'type', 'value'], 26 | additionalProperties: false, 27 | }, 28 | }, 29 | required: ['secret'], 30 | additionalProperties: false, 31 | }, 32 | }, 33 | }, 34 | }, 35 | middleware: [ 36 | async (req: Request, res: Response, next: NextFunction): Promise => { 37 | const created = await SecretService.create(req.body.secret) 38 | res.status(200).send(created) 39 | next() 40 | }, 41 | ], 42 | responses: { 43 | '200': secretResponse, 44 | '422': validationErrorResponse, 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 3 | import { AsyncDelete } from 'aejo' 4 | import { ClientError } from '../../middleware/client-errors' 5 | import SecretService from '../../../services/secret' 6 | 7 | export default AsyncDelete({ 8 | tags: ['secrets'], 9 | description: 'Delete Secret', 10 | parameters: [uuidParams], 11 | responses: { 12 | '200': { 13 | description: 'Ok', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | total: { 20 | type: 'integer', 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | '404': { 28 | description: 'Not Found', 29 | }, 30 | '422': validationErrorResponse, 31 | }, 32 | middleware: [ 33 | async (req: Request, res: Response, next: NextFunction): Promise => { 34 | const active = await SecretService.isInUse(req.params.id) 35 | if (active) { 36 | throw new ClientError('Cannot delete active secret') 37 | } 38 | const deleted = await SecretService.destroy(req.params.id) 39 | res.status(200).send({ total: deleted }) 40 | next() 41 | }, 42 | ], 43 | }) 44 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/get-types.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { config } from 'node-config-ts' 4 | 5 | const types = [ 6 | 'manual', 7 | ...(config.quantumTunnel.enabled === 'true' ? ['qt'] : []), 8 | ] 9 | 10 | export default AsyncGet({ 11 | tags: ['secrets'], 12 | description: 'Get Secret Types', 13 | responses: { 14 | '200': { 15 | description: 'Ok', 16 | content: { 17 | 'application/json': { 18 | schema: { 19 | type: 'object', 20 | properties: { 21 | types: { 22 | type: 'array', 23 | items: { 24 | type: 'string', 25 | enum: types, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | middleware: [ 35 | async (_req: Request, res: Response, next: NextFunction) => { 36 | res.status(200).send({ types }) 37 | next() 38 | }, 39 | ], 40 | }) 41 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { AuthPathOp, Scope, PathItem, Path, Route } from 'aejo' 3 | import { Authorized } from '../../middleware/auth' 4 | 5 | import { uuidFormat } from '../../crud/schemas' 6 | 7 | import listRoute from './list' 8 | import createRoute from './create' 9 | import updateRoute from './update' 10 | import getTypesRoute from './get-types' 11 | import viewRoute from './view' 12 | import deleteRoute from './delete' 13 | 14 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 15 | 16 | export default (router: Router): { paths: PathItem[]; router: Router } => 17 | Route( 18 | router, 19 | Path('/', AdminScope(listRoute), AdminScope(createRoute)), 20 | Path( 21 | `/:id(${uuidFormat})`, 22 | AdminScope(viewRoute), 23 | AdminScope(updateRoute), 24 | AdminScope(deleteRoute) 25 | ), 26 | Path('/types', AdminScope(getTypesRoute)) 27 | ) 28 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/secrets' 3 | 4 | export const secretResponse: MediaSchema = { 5 | description: 'Ok', 6 | content: { 7 | 'application/json': { 8 | schema: { 9 | type: 'object', 10 | properties: Schema, 11 | }, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/update.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPut } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { Schema } from '../../../models/secrets' 5 | import { secretResponse } from './schemas' 6 | import SecretService from '../../../services/secret' 7 | 8 | export default AsyncPut({ 9 | tags: ['secrets'], 10 | description: 'Update Secret', 11 | parameters: [uuidParams], 12 | requestBody: { 13 | description: 'Secret Object', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | secret: { 20 | type: 'object', 21 | properties: { 22 | type: Schema.type, 23 | value: Schema.value, 24 | }, 25 | required: ['type', 'value'], 26 | additionalProperties: false, 27 | }, 28 | }, 29 | required: ['secret'], 30 | additionalProperties: false, 31 | }, 32 | }, 33 | }, 34 | }, 35 | responses: { 36 | '200': secretResponse, 37 | '404': { 38 | description: 'Not Found', 39 | }, 40 | '422': validationErrorResponse, 41 | }, 42 | middleware: [ 43 | async (req: Request, res: Response, next: NextFunction): Promise => { 44 | const updated = await SecretService.update(req.params.id, req.body.secret) 45 | res.status(200).send(updated) 46 | next() 47 | }, 48 | ], 49 | }) 50 | -------------------------------------------------------------------------------- /backend/src/api/routes/secrets/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { validationErrorResponse } from '../../crud/schemas' 3 | import { AsyncGet } from 'aejo' 4 | import { secretResponse } from './schemas' 5 | import SecretService from '../../../services/secret' 6 | 7 | export default AsyncGet({ 8 | tags: ['secrets'], 9 | description: 'View Secret', 10 | responses: { 11 | '200': secretResponse, 12 | '404': { 13 | description: 'Not Found', 14 | }, 15 | '422': validationErrorResponse, 16 | }, 17 | middleware: [ 18 | async (req: Request, res: Response, next: NextFunction): Promise => { 19 | const record = await SecretService.view(req.params.id) 20 | res.status(200).send(record) 21 | next() 22 | }, 23 | ], 24 | }) 25 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/cache.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { seenStringBody } from './schemas' 4 | import { cacheViewSchema } from '../../crud/cache' 5 | import SeenStringService from '../../../services/seen_string' 6 | import { validationErrorResponse } from '../../crud/schemas' 7 | 8 | export default AsyncPost({ 9 | tags: ['seen_string'], 10 | description: 'Read-through cache', 11 | requestBody: seenStringBody, 12 | middleware: [ 13 | async (req: Request, res: Response, next: NextFunction): Promise => { 14 | const { type, key } = req.body.seen_string as Record 15 | const hit = await SeenStringService.cached_view({ type, key }) 16 | if (!hit.has) { 17 | const dbHit = await SeenStringService.findOne({ 18 | type, 19 | key, 20 | }) 21 | if (dbHit) { 22 | await SeenStringService.update(dbHit.id, { last_cached: new Date() }) 23 | hit.store = 'database' 24 | hit.has = true 25 | } else { 26 | await SeenStringService.create({ 27 | type, 28 | key, 29 | }) 30 | await SeenStringService.cached_write_view({ key, type }, 'database') 31 | } 32 | } 33 | res.status(200).send(hit) 34 | next() 35 | }, 36 | ], 37 | responses: { 38 | '200': { 39 | description: 'Ok', 40 | content: { 41 | 'application/json': { 42 | schema: cacheViewSchema, 43 | }, 44 | }, 45 | }, 46 | '422': validationErrorResponse, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/create.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { seenStringBody, seenStringResponse } from './schemas' 4 | import SeenStringService from '../../../services/seen_string' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | export default AsyncPost({ 8 | tags: ['seen_string'], 9 | description: 'Create Seen String', 10 | requestBody: seenStringBody, 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const created = await SeenStringService.create(req.body.seen_string) 14 | res.status(200).send(created) 15 | next() 16 | }, 17 | ], 18 | responses: { 19 | '200': seenStringResponse, 20 | '422': validationErrorResponse, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import SeenStringService from '../../../services/seen_string' 5 | 6 | export default AsyncDelete({ 7 | tags: ['seen_string'], 8 | description: 'Delete Seen String', 9 | parameters: [uuidParams], 10 | responses: { 11 | '200': { 12 | description: 'OK', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | total: { 19 | type: 'integer', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | '404': { 27 | description: 'Not Found', 28 | }, 29 | '422': validationErrorResponse, 30 | }, 31 | middleware: [ 32 | async (req: Request, res: Response, next: NextFunction): Promise => { 33 | const deleted = await SeenStringService.destroy(req.params.id) 34 | res.status(200).send({ total: deleted }) 35 | next() 36 | }, 37 | ], 38 | }) 39 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/distinct.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet, QueryParam } from 'aejo' 3 | import SeenStringService from '../../../services/seen_string' 4 | import SeenString from '../../../models/seen_strings' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | const selectable = SeenString.selectAble() as string[] 8 | 9 | export default AsyncGet({ 10 | tags: ['seen_string'], 11 | description: 'Fetches distinct column values from Seen Strings', 12 | parameters: [ 13 | QueryParam({ 14 | name: 'column', 15 | description: 'Distinct column', 16 | schema: { 17 | type: 'string', 18 | enum: selectable 19 | } 20 | }) 21 | ], 22 | responses: { 23 | '200': { 24 | description: 'Ok', 25 | content: { 26 | 'application/json': { 27 | schema: { 28 | type: 'array', 29 | items: { 30 | type: 'object' 31 | } 32 | } 33 | } 34 | } 35 | }, 36 | '422': validationErrorResponse 37 | }, 38 | middleware: [ 39 | async (req: Request, res: Response, next: NextFunction): Promise => { 40 | const result = await SeenStringService.distinct( 41 | req.query.column as string 42 | ) 43 | res.status(200).send(result) 44 | next() 45 | } 46 | ] 47 | }) 48 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/get-cache.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request, NextFunction } from 'express' 2 | import { cacheViewParams, cacheViewSchema } from '../../crud/cache' 3 | import { AsyncGet } from 'aejo' 4 | import SeenStringService from '../../../services/seen_string' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | export default AsyncGet({ 8 | tags: ['seen_string'], 9 | description: 'Checks if record is found in cache', 10 | parameters: cacheViewParams, 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const { type, key } = req.query as Record 14 | const hit = await SeenStringService.cached_view({ type, key }) 15 | if (!hit.has) { 16 | const dbHit = await SeenStringService.findOne({ 17 | type, 18 | key, 19 | }) 20 | if (dbHit) { 21 | hit.store = 'database' 22 | hit.has = true 23 | await SeenStringService.update(dbHit.id, { last_cached: new Date() }) 24 | } 25 | } 26 | res.status(200).send(hit) 27 | next() 28 | }, 29 | ], 30 | responses: { 31 | '200': { 32 | description: 'Ok', 33 | content: { 34 | 'application/json': { 35 | schema: cacheViewSchema, 36 | }, 37 | }, 38 | }, 39 | '422': validationErrorResponse, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { AuthPathOp, Path, PathItem, Route, Scope } from 'aejo' 3 | import { Authorized, AuthScope } from '../../middleware/auth' 4 | import { uuidFormat } from '../../crud/schemas' 5 | 6 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 7 | const TransportScope = AuthPathOp(Scope(Authorized, 'transport')) 8 | 9 | import listRoute from './list' 10 | import viewRoute from './view' 11 | import distinctRoute from './distinct' 12 | import updateRoute from './update' 13 | import deleteRoute from './delete' 14 | import createRoute from './create' 15 | import getCacheRoute from './get-cache' 16 | import cacheRoute from './cache' 17 | 18 | export default (router: Router): { paths: PathItem[]; router: Router } => 19 | Route( 20 | router, 21 | Path('/', AdminScope(listRoute), AdminScope(createRoute)), 22 | Path('/distinct', AuthScope(distinctRoute)), 23 | Path('/_cache', TransportScope(cacheRoute), TransportScope(getCacheRoute)), 24 | Path( 25 | `/:id(${uuidFormat})`, 26 | AdminScope(viewRoute), 27 | AdminScope(updateRoute), 28 | AdminScope(deleteRoute) 29 | ) 30 | ) 31 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/seen_strings' 3 | 4 | export const seenStringBody: MediaSchema = { 5 | description: 'Seen String Object', 6 | content: { 7 | 'application/json': { 8 | schema: { 9 | type: 'object', 10 | properties: { 11 | seen_string: { 12 | type: 'object', 13 | properties: { 14 | type: Schema.type, 15 | key: Schema.key, 16 | }, 17 | required: ['type', 'key'], 18 | additionalProperties: false, 19 | }, 20 | }, 21 | required: ['seen_string'], 22 | additionalProperties: false, 23 | }, 24 | }, 25 | }, 26 | } 27 | 28 | export const seenStringResponse: MediaSchema = { 29 | description: 'OK', 30 | content: { 31 | 'application/json': { 32 | schema: { 33 | type: 'object', 34 | properties: Schema, 35 | }, 36 | }, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/update.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPut } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { seenStringBody, seenStringResponse } from './schemas' 5 | import SeenStringService from '../../../services/seen_string' 6 | 7 | export default AsyncPut({ 8 | tags: ['seen_string'], 9 | description: 'Update Seen String', 10 | parameters: [uuidParams], 11 | requestBody: seenStringBody, 12 | responses: { 13 | '200': seenStringResponse, 14 | '404': { 15 | description: 'Not Found', 16 | }, 17 | '422': validationErrorResponse, 18 | }, 19 | middleware: [ 20 | async (req: Request, res: Response, next: NextFunction): Promise => { 21 | const updated = await SeenStringService.update( 22 | req.params.id, 23 | req.body.seen_string 24 | ) 25 | res.status(200).send(updated) 26 | next() 27 | }, 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /backend/src/api/routes/seen_strings/view.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | 4 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 5 | import SeenStringService from '../../../services/seen_string' 6 | import { seenStringResponse } from './schemas' 7 | 8 | export default AsyncGet({ 9 | tags: ['seen_string'], 10 | description: 'View Seen String', 11 | parameters: [uuidParams], 12 | middleware: [ 13 | async (req: Request, res: Response, next: NextFunction): Promise => { 14 | const record = await SeenStringService.view(req.params.id) 15 | res.status(200).send(record) 16 | next() 17 | }, 18 | ], 19 | responses: { 20 | '200': seenStringResponse, 21 | '404': { 22 | description: 'Not Found', 23 | }, 24 | '422': validationErrorResponse, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import SiteService from '../../../services/site' 4 | import { validationErrorResponse } from '../../crud/schemas' 5 | import { siteBody, siteResponse } from './schemas' 6 | 7 | export default AsyncPost({ 8 | tags: ['sites'], 9 | description: 'Create Site', 10 | requestBody: siteBody, 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const created = await SiteService.create(req.body.site) 14 | res.status(200).send(created) 15 | next() 16 | }, 17 | ], 18 | responses: { 19 | '200': siteResponse, 20 | '422': validationErrorResponse, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams } from '../../crud/schemas' 4 | import SiteService from '../../../services/site' 5 | 6 | export default AsyncDelete({ 7 | tags: ['sites'], 8 | description: 'Delte Site', 9 | parameters: [uuidParams], 10 | responses: { 11 | '200': { 12 | description: 'Ok', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | total: { 19 | type: 'integer', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | '404': { 27 | description: 'Not Found', 28 | }, 29 | }, 30 | middleware: [ 31 | async (req: Request, res: Response, next: NextFunction): Promise => { 32 | const deleted = await SiteService.destroy(req.params.id) 33 | res.status(200).send({ total: deleted }) 34 | next() 35 | }, 36 | ], 37 | }) 38 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { Route, PathItem, Path, AuthPathOp, Scope } from 'aejo' 3 | import { Authorized } from '../../middleware/auth' 4 | import { uuidFormat } from '../../crud/schemas' 5 | 6 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 7 | const UserScope = AuthPathOp(Scope(Authorized, 'user')) 8 | 9 | import listRoute from './list' 10 | import createRoute from './create' 11 | import updateRoute from './update' 12 | import viewRoute from './view' 13 | import deleteRoute from './delete' 14 | 15 | export default (router: Router): { paths: PathItem[]; router: Router } => 16 | Route( 17 | router, 18 | Path('/', UserScope(listRoute), AdminScope(createRoute)), 19 | Path( 20 | `/:id(${uuidFormat})`, 21 | UserScope(viewRoute), 22 | AdminScope(updateRoute), 23 | AdminScope(deleteRoute) 24 | ) 25 | ) 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/list.ts: -------------------------------------------------------------------------------- 1 | import { AsyncGet, QueryParam } from 'aejo' 2 | import { listHandler, ListQueryParams } from '../../crud/list' 3 | import Site, { Schema } from '../../../models/sites' 4 | 5 | const selectable = Site.selectAble() 6 | 7 | export default AsyncGet({ 8 | tags: ['sites'], 9 | description: 'List Sites', 10 | parameters: [ 11 | ...ListQueryParams, 12 | QueryParam({ 13 | name: 'fields', 14 | description: 'Select fields from results', 15 | schema: { 16 | type: 'array', 17 | items: { 18 | type: 'string', 19 | enum: selectable, 20 | }, 21 | }, 22 | }), 23 | ], 24 | middleware: [listHandler(Site, selectable)], 25 | responses: { 26 | '200': { 27 | description: 'Ok', 28 | content: { 29 | 'application/json': { 30 | schema: { 31 | type: 'object', 32 | properties: { 33 | results: { 34 | type: 'array', 35 | items: { 36 | type: 'object', 37 | properties: { 38 | ...Schema, 39 | }, 40 | }, 41 | }, 42 | total: { 43 | type: 'integer', 44 | description: 'Total number of results', 45 | }, 46 | }, 47 | additionalProperties: false, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/sites' 3 | 4 | export const siteResponse: MediaSchema = { 5 | description: 'Ok', 6 | content: { 7 | 'application/json': { 8 | schema: { 9 | type: 'object', 10 | properties: Schema, 11 | }, 12 | }, 13 | }, 14 | } 15 | 16 | export const siteBody: MediaSchema = { 17 | description: 'Site Object', 18 | content: { 19 | 'application/json': { 20 | schema: { 21 | type: 'object', 22 | properties: { 23 | site: { 24 | type: 'object', 25 | properties: { 26 | name: Schema.name, 27 | active: Schema.active, 28 | source_id: Schema.source_id, 29 | run_every_minutes: Schema.run_every_minutes, 30 | }, 31 | required: ['name', 'active', 'source_id', 'run_every_minutes'], 32 | additionalProperties: false, 33 | }, 34 | }, 35 | required: ['site'], 36 | additionalProperties: false, 37 | }, 38 | }, 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/update.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPut } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { siteBody, siteResponse } from './schemas' 5 | import SiteService from '../../../services/site' 6 | 7 | export default AsyncPut({ 8 | tags: ['sites'], 9 | description: 'Update Site', 10 | parameters: [uuidParams], 11 | requestBody: siteBody, 12 | responses: { 13 | '200': siteResponse, 14 | '404': { 15 | description: 'Not Found', 16 | }, 17 | '422': validationErrorResponse, 18 | }, 19 | middleware: [ 20 | async (req: Request, res: Response, next: NextFunction): Promise => { 21 | const updated = await SiteService.update(req.params.id, req.body.site) 22 | res.status(200).send(updated) 23 | next() 24 | }, 25 | ], 26 | }) 27 | -------------------------------------------------------------------------------- /backend/src/api/routes/sites/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import { siteResponse } from './schemas' 5 | import SiteService from '../../../services/site' 6 | 7 | export default AsyncGet({ 8 | tags: ['sites'], 9 | description: 'View Site', 10 | parameters: [uuidParams], 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const record = await SiteService.view(req.params.id) 14 | res.status(200).send(record) 15 | next() 16 | }, 17 | ], 18 | responses: { 19 | '200': siteResponse, 20 | '404': { 21 | description: 'Not Found', 22 | }, 23 | '422': validationErrorResponse, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/create-test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import SourceService from '../../../services/source' 4 | import ScanService from '../../../services/scan' 5 | import { sourceBody } from './schemas' 6 | import { getScannerQueue } from '../../../lib/queues' 7 | import Queue from 'bull' 8 | import { validationErrorResponse } from '../../crud/schemas' 9 | 10 | let scannerQueue: Queue.Queue 11 | ;(async () => { 12 | scannerQueue = await getScannerQueue() 13 | })() 14 | 15 | export default AsyncPost({ 16 | tags: ['sources'], 17 | description: 'Create temporary test source', 18 | requestBody: sourceBody, 19 | middleware: [ 20 | async (req: Request, res: Response, next: NextFunction): Promise => { 21 | const { source } = req.body 22 | const tmp = await SourceService.create({ 23 | ...source, 24 | test: true, 25 | name: `tmp${new Date().valueOf()}`, 26 | }) 27 | const scheduledJob = await ScanService.schedule(scannerQueue, { 28 | source: tmp, 29 | test: true, 30 | }) 31 | res.status(200).send({ 32 | scan_id: scheduledJob.scan.id, 33 | source_id: tmp.id, 34 | }) 35 | next() 36 | }, 37 | ], 38 | responses: { 39 | '200': { 40 | description: 'Ok', 41 | content: { 42 | 'application/json': { 43 | schema: { 44 | type: 'object', 45 | properties: { 46 | scan_id: { 47 | description: 'Scheduled Scan', 48 | type: 'string', 49 | format: 'uuidv4', 50 | }, 51 | source_id: { 52 | description: 'Temporary Source ID', 53 | type: 'string', 54 | format: 'uuidv4', 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | '422': validationErrorResponse, 62 | }, 63 | }) 64 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import SourceService from '../../../services/source' 4 | import { sourceBody, sourceResponse } from './schemas' 5 | import { validationErrorResponse } from '../../crud/schemas' 6 | 7 | export default AsyncPost({ 8 | tags: ['sources'], 9 | description: 'Create Source', 10 | requestBody: sourceBody, 11 | middleware: [ 12 | async (req: Request, res: Response, next: NextFunction): Promise => { 13 | const { source } = req.body 14 | const record = await SourceService.create(source) 15 | res.status(200).send(record) 16 | next() 17 | }, 18 | ], 19 | responses: { 20 | '200': sourceResponse, 21 | '422': validationErrorResponse, 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/delete.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncDelete } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import SourceService from '../../../services/source' 5 | 6 | export default AsyncDelete({ 7 | tags: ['sources'], 8 | description: 'Delete Source', 9 | parameters: [uuidParams], 10 | middleware: [ 11 | async (req: Request, res: Response, next: NextFunction): Promise => { 12 | const deleted = await SourceService.destroy(req.params.id) 13 | res.status(200).send({ total: deleted }) 14 | next() 15 | }, 16 | ], 17 | responses: { 18 | '200': { 19 | description: 'OK', 20 | content: { 21 | 'application/json': { 22 | schema: { 23 | type: 'object', 24 | properties: { 25 | total: { 26 | type: 'integer', 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | '404': { 34 | description: 'Not found', 35 | }, 36 | '422': validationErrorResponse, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/handlers.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from 'objection' 2 | import Secret from '../../../models/secrets' 3 | import Scan from '../../../models/scans' 4 | import Source from '../../../models/sources' 5 | 6 | export const eagerLoad = ( 7 | eagers: string[], 8 | builder: QueryBuilder 9 | ): void => { 10 | if (eagers.includes('scans')) { 11 | builder.withGraphFetched('scans(selectID)').modifiers({ 12 | selectID(builder: QueryBuilder) { 13 | builder.select('id') 14 | }, 15 | }) 16 | } 17 | // eager load secrets 18 | if (eagers.includes('secrets')) { 19 | builder.withGraphFetched('secrets(selectSecret)').modifiers({ 20 | selectSecret(builder: QueryBuilder) { 21 | builder.select('id', 'name', 'type') 22 | }, 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { PathItem, Route, Path, AuthPathOp, Scope } from 'aejo' 3 | import { Authorized } from '../../middleware/auth' 4 | 5 | import { uuidFormat } from '../../crud/schemas' 6 | 7 | import listRoute from './list' 8 | import deleteRoute from './delete' 9 | import viewRoute from './view' 10 | import createRoute from './create' 11 | import createTestRoute from './create-test' 12 | 13 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 14 | 15 | export default (router: Router): { paths: PathItem[]; router: Router } => 16 | Route( 17 | router, 18 | Path('/', AdminScope(listRoute), AdminScope(createRoute)), 19 | Path(`/:id(${uuidFormat})`, AdminScope(viewRoute), AdminScope(deleteRoute)), 20 | Path('/test', AdminScope(createTestRoute)) 21 | ) 22 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/schemas.ts: -------------------------------------------------------------------------------- 1 | import { ParamSchema, MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/sources' 3 | import { Schema as SecretSchema } from '../../../models/secrets' 4 | 5 | export const sourceBody: MediaSchema = { 6 | description: 'Source Body', 7 | content: { 8 | 'application/json': { 9 | schema: { 10 | type: 'object', 11 | properties: { 12 | source: { 13 | type: 'object', 14 | properties: { 15 | name: Schema.name, 16 | value: Schema.value, 17 | secret_ids: { 18 | description: 'Array of associated Secret IDs', 19 | type: 'array', 20 | items: { 21 | type: 'string', 22 | format: 'uuid', 23 | }, 24 | }, 25 | }, 26 | required: ['name', 'value'], 27 | additionalProperties: false, 28 | }, 29 | }, 30 | required: ['source'], 31 | additionalProperties: false, 32 | }, 33 | }, 34 | }, 35 | } 36 | 37 | export const eagerResponse: { [prop: string]: ParamSchema } = { 38 | scans: { 39 | type: 'array', 40 | description: 'ID of related scans', 41 | items: { 42 | type: 'object', 43 | properties: { 44 | id: { 45 | type: 'string', 46 | format: 'uuid', 47 | }, 48 | }, 49 | }, 50 | }, 51 | secrets: { 52 | type: 'array', 53 | description: 'related secrets', 54 | items: { 55 | type: 'object', 56 | properties: { 57 | id: SecretSchema.id, 58 | name: SecretSchema.name, 59 | type: SecretSchema.type, 60 | }, 61 | }, 62 | }, 63 | } 64 | 65 | export const sourceResponse: MediaSchema = { 66 | description: 'OK', 67 | content: { 68 | 'application/json': { 69 | schema: { 70 | type: 'object', 71 | properties: Schema, 72 | }, 73 | }, 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /backend/src/api/routes/sources/view.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from 'objection' 2 | import { Request, Response, NextFunction } from 'express' 3 | import { AsyncGet, QueryParam } from 'aejo' 4 | 5 | import Source from '../../../models/sources' 6 | import { uuidParams } from '../../crud/schemas' 7 | import { Schema } from '../../../models/sources' 8 | import { eagerLoad } from './handlers' 9 | import SourceService from '../../../services/source' 10 | 11 | export default AsyncGet({ 12 | tags: ['sources'], 13 | description: 'View Source', 14 | parameters: [ 15 | uuidParams, 16 | QueryParam({ 17 | name: 'eager', 18 | description: 'Eager load Scans or Secrets', 19 | schema: { 20 | type: 'array', 21 | items: { 22 | type: 'string', 23 | enum: ['scans', 'secrets'], 24 | }, 25 | }, 26 | }), 27 | ], 28 | responses: { 29 | '200': { 30 | description: 'Ok', 31 | content: { 32 | 'application/json': { 33 | schema: { 34 | type: 'object', 35 | properties: { 36 | ...Schema, 37 | ...eagerLoad, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | middleware: [ 45 | // handler eager loaded related attributes 46 | async (req: Request, res: Response, next: NextFunction): Promise => { 47 | res.locals.whereBuilder = (builder: QueryBuilder) => { 48 | if ( 49 | req.query.eager && 50 | Array.isArray(req.query.eager) && 51 | req.query.eager.length > 0 52 | ) { 53 | const eagers = req.query.eager as string[] 54 | eagerLoad(eagers, builder) 55 | } 56 | } 57 | next() 58 | }, 59 | // view 60 | async (req: Request, res: Response, next: NextFunction): Promise => { 61 | const record = await SourceService.view(req.params.id) 62 | res.status(200).send(record) 63 | next() 64 | }, 65 | ], 66 | }) 67 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/create-admin.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import UserService from '../../../services/user' 4 | import AuthService from '../../../services/auth' 5 | import { UnauthorizedError } from '../../middleware/client-errors' 6 | import { userResponse } from './schemas' 7 | import { validationErrorResponse } from '../../crud/schemas' 8 | 9 | export default AsyncPost({ 10 | tags: ['users'], 11 | description: 'First-run local admin create endpoint', 12 | requestBody: { 13 | description: 'Admin Password', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | password: { 20 | type: 'string', 21 | minLength: 8, 22 | maxLength: 32, 23 | }, 24 | }, 25 | required: ['password'], 26 | additionalProperties: false, 27 | }, 28 | }, 29 | }, 30 | }, 31 | middleware: [ 32 | async ( 33 | _req: Request, 34 | _res: Response, 35 | next: NextFunction 36 | ): Promise => { 37 | const hasAdmin = await UserService.findOne({ login: 'admin' }) 38 | if (hasAdmin !== undefined) { 39 | throw new UnauthorizedError('create_admin', 'guest') 40 | } 41 | return next() 42 | }, 43 | async (req: Request, res: Response, next: NextFunction): Promise => { 44 | const created = await UserService.create({ 45 | login: 'admin', 46 | role: 'admin', 47 | password: req.body.password, 48 | }) 49 | req.session.data = AuthService.buildSession(created) 50 | delete created.password 51 | delete created.password_hash 52 | res.status(200).send(created) 53 | next() 54 | }, 55 | ], 56 | responses: { 57 | '200': userResponse, 58 | '422': validationErrorResponse, 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPost } from 'aejo' 3 | import { validationErrorResponse } from '../../crud/schemas' 4 | import { userResponse } from './schemas' 5 | import { Schema } from '../../../models/users' 6 | import UserService from '../../../services/user' 7 | 8 | export default AsyncPost({ 9 | tags: ['users'], 10 | description: 'Create User', 11 | requestBody: { 12 | description: 'User Object', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | user: { 19 | type: 'object', 20 | properties: { 21 | login: Schema.login, 22 | role: Schema.role, 23 | password: Schema.password, 24 | }, 25 | required: ['login', 'role', 'password'], 26 | additionalProperties: false, 27 | }, 28 | }, 29 | required: ['user'], 30 | additionalProperties: false, 31 | }, 32 | }, 33 | }, 34 | }, 35 | middleware: [ 36 | async (req: Request, res: Response, next: NextFunction): Promise => { 37 | const created = await UserService.create(req.body.user) 38 | res.status(200).send(created) 39 | next() 40 | }, 41 | ], 42 | responses: { 43 | '200': userResponse, 44 | '422': validationErrorResponse, 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/delete.ts: -------------------------------------------------------------------------------- 1 | import { AsyncDelete } from 'aejo' 2 | import { Request, Response, NextFunction } from 'express' 3 | import { uuidParams, validationErrorResponse } from '../../crud/schemas' 4 | import UserService from '../../../services/user' 5 | 6 | export default AsyncDelete({ 7 | tags: ['users'], 8 | description: 'Delete User', 9 | parameters: [uuidParams], 10 | responses: { 11 | '200': { 12 | description: 'OK', 13 | content: { 14 | 'application/json': { 15 | schema: { 16 | type: 'object', 17 | properties: { 18 | total: { 19 | type: 'integer', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | '404': { 27 | description: 'Not Found', 28 | }, 29 | '422': validationErrorResponse, 30 | }, 31 | middleware: [ 32 | async (req: Request, res: Response, next: NextFunction): Promise => { 33 | const deleted = await UserService.destroy(req.params.id) 34 | res.status(200).send({ total: deleted }) 35 | next() 36 | }, 37 | ], 38 | }) 39 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/index.ts: -------------------------------------------------------------------------------- 1 | import { AuthPathOp, Path, PathItem, Route, Scope } from 'aejo' 2 | import { Router } from 'express' 3 | import { Authorized } from '../../middleware/auth' 4 | import listRoute from './list' 5 | import createRoute from './create' 6 | import viewRoute from './view' 7 | import createAdminRoute from './create-admin' 8 | import updateRoute from './update' 9 | import deleteRoute from './delete' 10 | import { uuidFormat } from '../../crud/schemas' 11 | 12 | const AdminScope = AuthPathOp(Scope(Authorized, 'admin')) 13 | 14 | export default (router: Router): { paths: PathItem[]; router: Router } => 15 | Route( 16 | router, 17 | Path('/', AdminScope(listRoute), AdminScope(createRoute)), 18 | Path('/create_admin', createAdminRoute), 19 | Path( 20 | `/:id(${uuidFormat})`, 21 | AdminScope(updateRoute), 22 | AdminScope(viewRoute), 23 | AdminScope(deleteRoute) 24 | ) 25 | ) 26 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/schemas.ts: -------------------------------------------------------------------------------- 1 | import { MediaSchema } from 'aejo' 2 | import { Schema } from '../../../models/users' 3 | 4 | export const userSchema = { 5 | id: Schema.id, 6 | login: Schema.login, 7 | role: Schema.role, 8 | created_at: Schema.created_at, 9 | updated_at: Schema.updated_at, 10 | } 11 | 12 | export const userResponse: MediaSchema = { 13 | description: 'OK', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: userSchema, 19 | }, 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/update.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncPut } from 'aejo' 3 | import { uuidParams, validationErrorResponse } from '../..//crud/schemas' 4 | import { Schema } from '../../../models/users' 5 | import { userResponse } from './schemas' 6 | import UserService from '../../../services/user' 7 | 8 | export default AsyncPut({ 9 | tags: ['users'], 10 | description: 'Update User', 11 | parameters: [uuidParams], 12 | requestBody: { 13 | description: 'User Object', 14 | content: { 15 | 'application/json': { 16 | schema: { 17 | type: 'object', 18 | properties: { 19 | user: { 20 | type: 'object', 21 | properties: { 22 | login: Schema.login, 23 | role: Schema.role, 24 | password: Schema.password, 25 | }, 26 | required: ['login', 'role'], 27 | additionalProperties: false, 28 | }, 29 | }, 30 | required: ['user'], 31 | additionalProperties: false, 32 | }, 33 | }, 34 | }, 35 | }, 36 | responses: { 37 | '200': userResponse, 38 | '404': { 39 | description: 'Not Found', 40 | }, 41 | '422': validationErrorResponse, 42 | }, 43 | middleware: [ 44 | async (req: Request, res: Response, next: NextFunction): Promise => { 45 | const updated = await UserService.update(req.params.id, req.body.user) 46 | res.status(200).send(updated) 47 | next() 48 | }, 49 | ], 50 | }) 51 | -------------------------------------------------------------------------------- /backend/src/api/routes/users/view.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { AsyncGet } from 'aejo' 3 | import { validationErrorResponse, uuidParams } from '../../crud/schemas' 4 | import { userResponse } from './schemas' 5 | import UserService from '../../../services/user' 6 | 7 | export default AsyncGet({ 8 | tags: ['users'], 9 | description: 'Get user', 10 | parameters: [uuidParams], 11 | responses: { 12 | '200': userResponse, 13 | '404': { 14 | description: 'Not Found', 15 | }, 16 | '422': validationErrorResponse, 17 | }, 18 | middleware: [ 19 | async (req: Request, res: Response, next: NextFunction): Promise => { 20 | const record = await UserService.view(req.params.id) 21 | res.status(200).send(record) 22 | next() 23 | }, 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /backend/src/global.d.ts: -------------------------------------------------------------------------------- 1 | interface UserSession { 2 | lanid?: string 3 | nonce?: string 4 | firstName?: string 5 | lastName?: string 6 | role?: UserRole 7 | email?: string 8 | isAuth?: boolean 9 | exp?: number 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/jobs/queues.ts: -------------------------------------------------------------------------------- 1 | import Queue from 'bull' 2 | import EventEmitter from 'events' 3 | import { Redis } from 'ioredis' 4 | import MerryMaker from '@merrymaker/types' 5 | import { createClient } from '../repos/redis' 6 | 7 | EventEmitter.defaultMaxListeners = 14 8 | 9 | const redisClient = createClient() 10 | const redisSubscriber = createClient() 11 | 12 | const openClients: Redis[] = [redisClient, redisSubscriber] 13 | 14 | const resolveClient = (type: 'client' | 'subscriber' | 'bclient') => { 15 | if (type === 'client') { 16 | return redisClient 17 | } else if (type === 'subscriber') { 18 | return redisSubscriber 19 | } else { 20 | const c = createClient() 21 | openClients.push(c) 22 | return c 23 | } 24 | } 25 | 26 | const scannerScheduler = new Queue('scanner-scheduler', { 27 | createClient: resolveClient 28 | }) 29 | 30 | const scannerQueue = new Queue('scanner-queue', { 31 | createClient: resolveClient 32 | }) 33 | 34 | const scannerEventQueue = new Queue('scan-log-queue', { 35 | createClient 36 | }) 37 | 38 | const localQueue = new Queue('local', { 39 | createClient: resolveClient 40 | }) 41 | 42 | const qtSecretRefresh = new Queue('qt-secret-refresh', { 43 | createClient: resolveClient 44 | }) 45 | 46 | const alertQueue = new Queue('alert-queue', { 47 | createClient: resolveClient 48 | }) 49 | 50 | export default { 51 | localQueue, 52 | scannerScheduler, 53 | scannerQueue, 54 | scannerEventQueue, 55 | qtSecretRefresh, 56 | alertQueue 57 | } 58 | -------------------------------------------------------------------------------- /backend/src/knexfile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { config } from 'node-config-ts' 3 | import { Knex } from 'knex' 4 | 5 | const connOptions: Knex.PgConnectionConfig = { 6 | host: config.postgres.host, 7 | user: config.postgres.user, 8 | database: config.postgres.database, 9 | password: config.postgres.password, 10 | ssl: false, 11 | } 12 | 13 | if (config.postgres.secure) { 14 | connOptions.ssl = { 15 | rejectUnauthorized: false, 16 | ca: fs.readFileSync(config.postgres.ca).toString(), 17 | } 18 | } 19 | 20 | const knexConfig = { 21 | client: 'pg', 22 | useNullAsDefault: true, 23 | connection: connOptions, 24 | pool: { 25 | min: 2, 26 | max: 10, 27 | }, 28 | migrations: { 29 | tableName: 'knex_migrations', 30 | directory: './migrations', 31 | }, 32 | } 33 | 34 | console.log(knexConfig) 35 | 36 | module.exports = { 37 | test: knexConfig, 38 | development: knexConfig, 39 | staging: knexConfig, 40 | production: knexConfig, 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/lib/queues.ts: -------------------------------------------------------------------------------- 1 | import Queue from 'bull' 2 | import { redisClient, createClient } from '../repos/redis' 3 | 4 | const redisSubscriber = createClient() 5 | 6 | const resolveClient = (type: string) => { 7 | if (type === 'client') { 8 | return redisClient 9 | } else if (type === 'subscriber') { 10 | return redisSubscriber 11 | } else { 12 | return createClient() 13 | } 14 | } 15 | 16 | let scannerQueue: Queue.Queue 17 | 18 | // Create new or use existing instance 19 | export async function getScannerQueue(): Promise { 20 | if (!scannerQueue) { 21 | scannerQueue = new Queue('scanner-queue', { createClient: resolveClient }) 22 | } 23 | await scannerQueue.isReady() 24 | return scannerQueue 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * stripJSONUnicode 3 | * 4 | * Removes unicode and null characters from a JSON object 5 | */ 6 | export const stripJSONUnicode = (obj: unknown) => 7 | JSON.parse(JSON.stringify(obj, null).replace(/([^ -~]|\\u0000)+/g, '')) 8 | -------------------------------------------------------------------------------- /backend/src/loaders/logger.ts: -------------------------------------------------------------------------------- 1 | import { default as Pino } from 'pino' 2 | import { config } from 'node-config-ts' 3 | 4 | export default Pino({ 5 | name: 'mmk', 6 | level: config.env === 'test' ? 'silent' : 'debug', 7 | }) 8 | -------------------------------------------------------------------------------- /backend/src/migrations/20200710172307_sources.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('sources', (table) => { 5 | table.uuid('id').notNullable().primary().comment('Primary key (uuid)') 6 | table.string('name').notNullable().unique().comment('Name of source') 7 | table.boolean('test').defaultTo(false) 8 | table.text('value').notNullable().comment('Source Code to Run') 9 | table.timestamp('created_at') 10 | }) 11 | } 12 | 13 | export async function down(knex: Knex): Promise { 14 | return knex.schema.dropTable('sources') 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/migrations/20200711113336_create_sites.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('sites', (table) => { 5 | table 6 | .uuid('id') 7 | .notNullable() 8 | .unique() 9 | .primary() 10 | .comment('Primary key (uuid)') 11 | table.string('name').notNullable().unique().comment('Name of site') 12 | table.dateTime('last_run').comment('Last run time') 13 | table.boolean('active').defaultTo(true).comment('Site is actively running') 14 | table.integer('run_every_minutes').defaultTo(60).notNullable() 15 | table.uuid('source_id').references('sources.id') 16 | table.timestamps(true, true) 17 | }) 18 | } 19 | 20 | export async function down(knex: Knex): Promise { 21 | return knex.schema.dropTable('sites') 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/migrations/20200713140650_iocs.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('iocs', (table) => { 5 | table 6 | .uuid('id') 7 | .notNullable() 8 | .unique() 9 | .primary() 10 | .comment('Primary key (uuid)') 11 | table.string('type').notNullable().comment('IOC type') 12 | table.text('value').comment('IOC value') 13 | table.boolean('enabled').defaultTo(true).comment('IOC is active') 14 | table.timestamp('created_at') 15 | table.unique(['type', 'value']) 16 | }) 17 | } 18 | 19 | export async function down(knex: Knex): Promise { 20 | return knex.schema.dropTable('iocs') 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/migrations/20200729163107_seen_domains.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('seen_domains', (table) => { 5 | table 6 | .string('name', 1024) 7 | .notNullable() 8 | .unique() 9 | .primary() 10 | .comment('Domain name') 11 | table.timestamp('created_at') 12 | table.uuid('site_id').references('id').inTable('sites') 13 | }) 14 | } 15 | 16 | export async function down(knex: Knex): Promise { 17 | return knex.schema.dropTable('seen_domains') 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/migrations/20200729172407_scans.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('scans', (table) => { 5 | table.uuid('id').notNullable().primary().comment('Primary key (uuid)') 6 | table.string('state').notNullable().comment('Scan State') 7 | table.boolean('test').defaultTo(false) 8 | table.timestamp('created_at') 9 | table.timestamp('updated_at') 10 | table.uuid('source_id').references('id').inTable('sources').notNullable() 11 | table.uuid('site_id').references('id').inTable('sites').onDelete('CASCADE') 12 | }) 13 | } 14 | 15 | export async function down(knex: Knex): Promise { 16 | return knex.schema.dropTable('scans') 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/migrations/20200729172408_alerts.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('alerts', (table) => { 5 | table.uuid('id').notNullable().primary().comment('Primary key (uuid)') 6 | table.string('rule').notNullable() 7 | table.uuid('scan_id').references('id').inTable('scans').onDelete('SET NULL') 8 | table.uuid('site_id').references('id').inTable('sites').onDelete('SET NULL') 9 | table.text('message').notNullable().comment('Alert message') 10 | table.timestamp('created_at') 11 | }) 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | return knex.schema.dropTable('alerts') 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/migrations/20200805084107_scan_logs.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('scan_logs', (table) => { 5 | table.uuid('id').notNullable().primary().comment('Primary key (uuid)') 6 | table.text('entry').notNullable().comment('Log entry') 7 | table.string('level').notNullable().comment('Log level') 8 | table.json('event').comment('Scan event payload') 9 | table.timestamp('created_at') 10 | table 11 | .uuid('scan_id') 12 | .references('id') 13 | .inTable('scans') 14 | .notNullable() 15 | .onDelete('CASCADE') 16 | }) 17 | } 18 | 19 | export async function down(knex: Knex): Promise { 20 | return knex.schema.dropTable('scan_logs') 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/migrations/20200908150341_files.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('files', (table) => { 5 | table.uuid('id').notNullable().primary().comment('Primary key (uuid)') 6 | table.string('url').notNullable().comment('Source URL') 7 | table.string('filename').notNullable().comment('Resolved filename') 8 | table.string('sha256').notNullable().unique().comment('File SHA256 digest') 9 | table.json('headers') 10 | table.timestamp('created_at') 11 | table 12 | .uuid('scan_id') 13 | .references('id') 14 | .inTable('scans') 15 | .notNullable() 16 | .onDelete('CASCADE') 17 | }) 18 | } 19 | 20 | export async function down(knex: Knex): Promise { 21 | return knex.schema.dropTable('files') 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/migrations/20200916082555_seen_strings.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('seen_strings', (table) => { 5 | table.uuid('id').unique().primary().comment('Seen String ID') 6 | table.string('key', 1024).notNullable().comment('String Key') 7 | table.string('type', 255).notNullable() 8 | table.timestamp('created_at') 9 | table.timestamp('last_cached') 10 | table.unique(['key', 'type']) 11 | }) 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | return knex.schema.dropTable('seen_strings') 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/migrations/20200917140050_allow_list.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('allow_list', (table) => { 5 | table.uuid('id').unique().primary().comment('Allow list ID') 6 | table.string('key', 1024).notNullable() 7 | table.string('type', 255).notNullable() 8 | table.timestamp('created_at') 9 | table.timestamp('updated_at') 10 | table.unique(['key', 'type']) 11 | }) 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | return knex.schema.dropTable('allow_list') 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/migrations/20201119120041_add_alert_context.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.table('alerts', (table) => { 5 | table.jsonb('context').nullable().comment('Alert Context') 6 | }) 7 | } 8 | 9 | export async function down(knex: Knex): Promise { 10 | return knex.schema.table('alerts', (table) => { 11 | table.dropColumn('context') 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/migrations/20201207124807_secrets.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('secrets', (table) => { 5 | table.uuid('id').unique().primary().comment('Secret ID') 6 | table.string('name', 255).unique().notNullable() 7 | table.text('value').notNullable() 8 | table.string('type').notNullable() 9 | table.timestamp('created_at') 10 | table.timestamp('updated_at') 11 | }) 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | return knex.schema.dropTable('secrets') 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/migrations/20201207124817_source_secrets.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('source_secrets', (table) => { 5 | table 6 | .uuid('secret_id') 7 | .references('id') 8 | .inTable('secrets') 9 | .notNullable() 10 | .onDelete('CASCADE') 11 | table 12 | .uuid('source_id') 13 | .references('id') 14 | .inTable('sources') 15 | .notNullable() 16 | .onDelete('CASCADE') 17 | }) 18 | } 19 | 20 | export async function down(knex: Knex): Promise { 21 | return knex.schema.dropTable('source_secrets') 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/migrations/20210114090325_add_scan_id_index.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw('CREATE INDEX scan_log_scan_idx ON scan_logs(scan_id)') 5 | } 6 | 7 | export async function down(knex: Knex): Promise { 8 | return knex.raw('DROP INDEX scan_log_scan_idx') 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/migrations/20210121102116_add_gin_index_to_scans.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw( 5 | "CREATE INDEX scan_log_event_gin_idx ON scan_logs USING gin ( to_tsvector('english', event) )" 6 | ) 7 | } 8 | 9 | export async function down(knex: Knex): Promise { 10 | return knex.raw('DROP INDEX IF EXISTS scan_log_event_gin_idx') 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/migrations/20210429133446_add_user_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex' 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.schema.createTable('users', (table) => { 5 | table 6 | .uuid('id') 7 | .notNullable() 8 | .unique() 9 | .primary() 10 | .comment('Primary key (uuid)') 11 | table.string('login').notNullable().unique().comment('User Login') 12 | table.text('password_hash').notNullable().comment('User Password Hash') 13 | table.string('role').notNullable().comment('User role') 14 | table.timestamps(true, true) 15 | }) 16 | } 17 | 18 | export async function down(knex: Knex): Promise { 19 | return knex.schema.dropTable('users') 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/models/base.ts: -------------------------------------------------------------------------------- 1 | import { Model, ModelClass } from 'objection' 2 | 3 | export default abstract class BaseModel extends Model { 4 | updateAble(): Array | [] { 5 | return [] 6 | } 7 | insertAble(): Array | [] { 8 | return [] 9 | } 10 | selectAble(): Array | [] { 11 | return [] 12 | } 13 | } 14 | 15 | export interface BaseClass extends Partial> { 16 | updateAble?(): Array 17 | insertAble?(): Array 18 | selectAble?(): Array 19 | new (): M 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/models/files.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import Scan from './scans' 4 | 5 | import BaseModel from './base' 6 | 7 | export interface FileAttributes { 8 | id?: string 9 | scan_id: string 10 | created_at?: Date 11 | url: string 12 | filename: string 13 | headers: unknown 14 | sha256: string 15 | } 16 | 17 | export default class File extends BaseModel { 18 | id!: string 19 | scan_id: string 20 | created_at: Date 21 | url: string 22 | filename: string 23 | headers: unknown 24 | sha256: string 25 | 26 | static relationMappings = { 27 | scan: { 28 | relation: Model.BelongsToOneRelation, 29 | modelClass: Scan, 30 | join: { 31 | from: 'files.scan_id', 32 | to: 'scan.id', 33 | }, 34 | }, 35 | } 36 | 37 | static get tableName(): string { 38 | return 'files' 39 | } 40 | 41 | static selectAble(): Array { 42 | return ['id', 'scan_id', 'filename', 'sha256', 'created_at', 'url'] 43 | } 44 | 45 | static updateAble(): Array { 46 | return [] 47 | } 48 | 49 | static insertAble(): Array { 50 | return [] 51 | } 52 | 53 | $beforeInsert(): void { 54 | this.id = uuidv4() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /backend/src/models/seen_strings.ts: -------------------------------------------------------------------------------- 1 | import BaseModel from './base' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { ParamSchema } from 'aejo' 4 | 5 | export interface SeenStringAttributes { 6 | id?: string 7 | key: string 8 | type: string 9 | created_at: Date 10 | last_cached?: Date 11 | } 12 | 13 | export const Schema: { [prop: string]: ParamSchema } = { 14 | id: { 15 | description: 'ID of Seen String', 16 | type: 'string', 17 | format: 'uuid' 18 | }, 19 | type: { 20 | description: 'Key type', 21 | type: 'string' 22 | }, 23 | key: { 24 | description: 'Key value', 25 | type: 'string' 26 | }, 27 | created_at: { 28 | description: 'Created Dated', 29 | type: 'string', 30 | format: 'date-time' 31 | }, 32 | last_cached: { 33 | description: 'Date last seen in cache', 34 | type: 'string', 35 | format: 'date-time' 36 | } 37 | } 38 | 39 | export default class SeenString extends BaseModel { 40 | id!: string 41 | key: string 42 | type: string 43 | created_at: Date 44 | last_cached?: Date 45 | 46 | static get tableName(): string { 47 | return 'seen_strings' 48 | } 49 | 50 | $beforeInsert(): void { 51 | this.created_at = new Date() 52 | this.last_cached = 53 | this.last_cached !== undefined ? this.last_cached : new Date() 54 | this.id = uuidv4() 55 | } 56 | 57 | static selectAble(): Array { 58 | return ['id', 'key', 'created_at', 'type', 'last_cached'] 59 | } 60 | 61 | static insertAble(): Array { 62 | return ['key', 'created_at', 'type', 'last_cached'] 63 | } 64 | 65 | static updateAble(): Array { 66 | return ['last_cached', 'type'] 67 | } 68 | 69 | static build(o: Partial): SeenString { 70 | return SeenString.fromJson(o) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /backend/src/models/source_secrets.ts: -------------------------------------------------------------------------------- 1 | import BaseModel from './base' 2 | 3 | export interface SourceSecretAttributes { 4 | source_id: string 5 | secret_id: string 6 | } 7 | 8 | export default class SourceSecret extends BaseModel { 9 | source_id: string 10 | secret_id: string 11 | 12 | static get tableName(): string { 13 | return 'source_secrets' 14 | } 15 | 16 | static get idColumn(): string[] { 17 | return ['source_id', 'secret_id'] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/repos/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis, { RedisOptions } from 'ioredis' 2 | import { config, } from 'node-config-ts' 3 | 4 | const defaultOpts: RedisOptions = { 5 | maxRetriesPerRequest: null, 6 | enableReadyCheck: false, 7 | } 8 | 9 | function createClient(): Redis { 10 | if ( 11 | config.redis?.useSentinel && 12 | config.redis.nodes && 13 | Array.isArray(config.redis.nodes) 14 | ) { 15 | const clients = config.redis.nodes.map((item: string) => ({ 16 | host: item.trim(), 17 | port: config.redis.sentinelPort 18 | })) 19 | return new Redis({ 20 | updateSentinels: false, 21 | sentinels: clients, 22 | name: config.redis.master, 23 | password: config.redis.sentinelPassword, 24 | sentinelPassword: config.redis.sentinelPassword, 25 | ...defaultOpts 26 | }) 27 | } 28 | return new Redis(config.redis.uri, defaultOpts) 29 | } 30 | 31 | const redisClient = createClient() 32 | export { createClient, redisClient } 33 | -------------------------------------------------------------------------------- /backend/src/scan-runner.ts: -------------------------------------------------------------------------------- 1 | import Queues from './jobs/queues' 2 | import { URL } from 'url' 3 | import ScanService from './services/scan' 4 | import { Site, Source } from './models' 5 | 6 | const sourceURL = process.argv[2] 7 | const dURL = new URL(sourceURL) 8 | const source = ` 9 | await page.goto('${sourceURL}', { waitUntil: 'domcontentloaded' }) 10 | ` 11 | ;(async () => { 12 | await Queues.scannerQueue.isReady() 13 | const exists = await Source.query().findOne({ 14 | value: source, 15 | }) 16 | if (exists) { 17 | await Site.query().where({ source_id: exists.id }).del() 18 | await exists.$query().del() 19 | } 20 | const sourceInst = await Source.query().insertAndFetch({ 21 | name: dURL.hostname, 22 | value: source, 23 | created_at: new Date(), 24 | }) 25 | const siteInst = await Site.query().insertAndFetch({ 26 | name: dURL.hostname, 27 | active: true, 28 | run_every_minutes: 0, 29 | source_id: sourceInst.id, 30 | created_at: new Date(), 31 | updated_at: new Date(), 32 | }) 33 | const runnable = await Site.query().findById(siteInst.id) 34 | const res = await ScanService.schedule(Queues.scannerQueue, { 35 | site: runnable, 36 | }) 37 | console.log('Scheduled...') 38 | await res.job.finished() 39 | console.log('Finished') 40 | process.exit(0) 41 | })() 42 | -------------------------------------------------------------------------------- /backend/src/services/allow_list.ts: -------------------------------------------------------------------------------- 1 | import { cachedView } from '../api/crud/cache' 2 | import LRUCache from 'lru-native2' 3 | import { AllowList, AllowListAttributes } from '../models' 4 | 5 | export const cache = new LRUCache({ 6 | maxElements: 10000, 7 | maxAge: 60000, 8 | size: 1000, 9 | maxLoadFactor: 2.0, 10 | }) 11 | 12 | const cached_view = cachedView(AllowList.tableName, cache) 13 | 14 | const view = async (id: string): Promise => 15 | AllowList.query().findById(id).throwIfNotFound() 16 | 17 | const update = async ( 18 | id: string, 19 | attrs: Partial 20 | ): Promise => AllowList.query().patchAndFetchById(id, attrs) 21 | 22 | const findOne = async ( 23 | query: Partial 24 | ): Promise => AllowList.query().findOne(query) 25 | 26 | const create = async ( 27 | attrs: Partial 28 | ): Promise => AllowList.query().insert(attrs) 29 | 30 | const destroy = async (id: string): Promise => 31 | AllowList.query().deleteById(id) 32 | 33 | export default { 34 | view, 35 | findOne, 36 | cached_view, 37 | create, 38 | update, 39 | destroy, 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import { Session } from 'express-session' 2 | import { UserAttributes, UserRole } from '../models/users' 3 | import { User } from '../models' 4 | 5 | const roleLevels: Record = { 6 | admin: 100, 7 | transport: 90, 8 | user: 50, 9 | } 10 | 11 | const isAuth = (session: Session): boolean => { 12 | if (session.data === undefined) { 13 | return false 14 | } 15 | return session.data.isAuth === true 16 | } 17 | 18 | const isRole = (user: UserSession, role: UserRole): boolean => 19 | user.role === role 20 | 21 | const hasRole = (user: UserSession, role: UserRole): boolean => 22 | roleLevels[user.role] >= roleLevels[role] 23 | 24 | /** 25 | * verifyLocalCreds 26 | * 27 | * Queries `users` table fo matching login, 28 | * uses bcrypt compare against the password_hash to verify match 29 | */ 30 | const verifyLocalCreds = async ( 31 | user: Pick 32 | ): Promise<{ auth: boolean; user?: User }> => { 33 | const instance = await User.query().findOne({ login: user.login }) 34 | if (!instance) { 35 | return { auth: false } 36 | } 37 | const auth = await instance.checkPassword(user.password) 38 | return { auth, user: instance } 39 | } 40 | 41 | /** 42 | * buildSession 43 | * 44 | * Creates a UserSession from a User instance 45 | */ 46 | const buildSession = (user: User): UserSession => ({ 47 | role: user.role, 48 | lanid: user.login, 49 | firstName: user.login, 50 | lastName: '', 51 | email: 'localuser@localhost', 52 | isAuth: true, 53 | exp: 0, 54 | }) 55 | 56 | export default { 57 | buildSession, 58 | roleLevels, 59 | isAuth, 60 | isRole, 61 | hasRole, 62 | verifyLocalCreds, 63 | } 64 | -------------------------------------------------------------------------------- /backend/src/services/ioc.ts: -------------------------------------------------------------------------------- 1 | import { Ioc, IocAttributes } from '../models' 2 | import { cachedView } from '../api/crud/cache' 3 | import LRUCache from 'lru-native2' 4 | import { IocType } from '../models/iocs' 5 | 6 | export const cache = new LRUCache({ 7 | maxElements: 10000, 8 | maxAge: 60000, 9 | size: 1000, 10 | maxLoadFactor: 2.0, 11 | }) 12 | 13 | export type IocBulkCreate = { 14 | values: string[] 15 | type: IocType 16 | enabled: boolean 17 | } 18 | 19 | const cached_view = cachedView(Ioc.tableName, cache) 20 | 21 | const view = async (id: string): Promise => 22 | Ioc.query().findById(id).throwIfNotFound() 23 | 24 | const findOne = async (query: Partial): Promise => 25 | Ioc.query().findOne(query) 26 | 27 | const create = async (attrs: Partial): Promise => 28 | Ioc.query().insert(attrs) 29 | 30 | const bulkCreate = async (bulk: IocBulkCreate): Promise => { 31 | const iocs: IocAttributes[] = bulk.values.map((value) => ({ 32 | value, 33 | type: bulk.type, 34 | enabled: bulk.enabled, 35 | })) 36 | await Ioc.query().insert(iocs).onConflict(['value', 'type']).ignore() 37 | } 38 | 39 | const update = async ( 40 | id: string, 41 | attrs: Partial 42 | ): Promise => Ioc.query().patchAndFetchById(id, attrs) 43 | 44 | const destroy = async (id: string): Promise => 45 | Ioc.query().deleteById(id) 46 | 47 | export default { 48 | view, 49 | destroy, 50 | findOne, 51 | update, 52 | cached_view, 53 | create, 54 | bulkCreate, 55 | } 56 | -------------------------------------------------------------------------------- /backend/src/services/secret.ts: -------------------------------------------------------------------------------- 1 | import { SourceSecret, Secret, SecretAttributes } from '../models' 2 | import SourceService from './source' 3 | 4 | /** 5 | * update 6 | * 7 | * Updates a secret and related source cache with the new value 8 | */ 9 | const update = async ( 10 | id: string, 11 | secret: Partial 12 | ): Promise => { 13 | const updated = await Secret.query().patchAndFetchById( 14 | id, 15 | Secret.updateAble().reduce( 16 | (obj, key) => ({ ...obj, [key]: secret[key] }), 17 | {} 18 | ) 19 | ) 20 | const sources = await SourceSecret.query().where({ secret_id: updated.id }) 21 | await Promise.all(sources.map((s) => SourceService.cache(s.source_id))) 22 | return updated 23 | } 24 | 25 | const create = async (attrs: Partial): Promise => 26 | Secret.query().insert(attrs) 27 | 28 | const view = async (id: string): Promise => 29 | Secret.query().findById(id).throwIfNotFound() 30 | 31 | const destroy = async (id: string): Promise => 32 | Secret.query().deleteById(id) 33 | 34 | const isInUse = async (id: string): Promise => { 35 | const res = await SourceSecret.query().where({ secret_id: id }) 36 | return res && res.length > 0 37 | } 38 | 39 | export default { 40 | view, 41 | isInUse, 42 | create, 43 | update, 44 | destroy, 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/services/seen_string.ts: -------------------------------------------------------------------------------- 1 | import { cachedView, updateCache, writeLRU } from '../api/crud/cache' 2 | import { raw } from 'objection' 3 | import LRUCache from 'lru-native2' 4 | import { SeenString, SeenStringAttributes } from '../models' 5 | 6 | export const cache = new LRUCache({ 7 | maxElements: 10000, 8 | maxAge: 60000, 9 | size: 1000, 10 | maxLoadFactor: 2.0, 11 | }) 12 | 13 | const cached_view = cachedView(SeenString.tableName, cache) 14 | const cached_write_view = updateCache(SeenString.tableName, writeLRU(cache)) 15 | 16 | const view = async (id: string): Promise => 17 | SeenString.query().findById(id).throwIfNotFound() 18 | 19 | const update = async ( 20 | id: string, 21 | attrs: Partial 22 | ): Promise => SeenString.query().patchAndFetchById(id, attrs) 23 | 24 | const distinct = async (column: string): Promise => 25 | SeenString.query().distinct(column) 26 | 27 | const findOne = async ( 28 | query: Partial 29 | ): Promise => SeenString.query().findOne(query) 30 | 31 | const create = async ( 32 | attrs: Partial 33 | ): Promise => SeenString.query().insert(attrs) 34 | 35 | /** 36 | * purgeDBCache 37 | * 38 | * Deletes SeenStrings where `last_cached` < now-`daysAgo` 39 | * or is NULL 40 | */ 41 | const purgeDBCache = async (daysAgo: number): Promise => 42 | SeenString.query() 43 | .delete() 44 | .where(raw("last_cached <= NOW() - INTERVAL '?? days'", [daysAgo])) 45 | .orWhereNull('last_cached') 46 | 47 | 48 | const destroy = async (id: string): Promise => 49 | SeenString.query().deleteById(id) 50 | 51 | export default { 52 | view, 53 | distinct, 54 | cached_view, 55 | cached_write_view, 56 | purgeDBCache, 57 | update, 58 | findOne, 59 | create, 60 | destroy, 61 | } 62 | -------------------------------------------------------------------------------- /backend/src/services/site.ts: -------------------------------------------------------------------------------- 1 | import { differenceInMinutes } from 'date-fns' 2 | import { Site, SiteAttributes } from '../models' 3 | 4 | const getRunnable = async (): Promise => { 5 | const whereQuery: Partial = { 6 | active: true, 7 | } 8 | const sites = await Site.query().where(whereQuery) 9 | const now = new Date() 10 | const res: Site[] = [] 11 | for (let i = 0; i < sites.length; i += 1) { 12 | const site = sites[i] 13 | const diff = differenceInMinutes(now, site.last_run) 14 | if (diff > site.run_every_minutes || isNaN(diff)) { 15 | res.push(site) 16 | } 17 | } 18 | return res 19 | } 20 | 21 | const view = async (id: string): Promise => 22 | Site.query().findById(id).throwIfNotFound() 23 | 24 | const create = async (attrs: Partial): Promise => 25 | Site.query().insert(attrs) 26 | 27 | const update = async ( 28 | id: string, 29 | site: Partial 30 | ): Promise => { 31 | const updated = await Site.query().patchAndFetchById(id, site) 32 | return updated 33 | } 34 | 35 | const destroy = async (id: string): Promise => 36 | Site.query().deleteById(id) 37 | 38 | export default { 39 | getRunnable, 40 | view, 41 | update, 42 | create, 43 | destroy, 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/services/user.ts: -------------------------------------------------------------------------------- 1 | import { User, UserAttributes } from '../models' 2 | 3 | const findOne = async (query: Partial): Promise => 4 | User.query().findOne(query) 5 | 6 | const view = async (id: string): Promise => 7 | User.query().findById(id).throwIfNotFound() 8 | 9 | const update = async ( 10 | id: string, 11 | attrs: Partial 12 | ): Promise => User.query().patchAndFetchById(id, attrs) 13 | 14 | const create = async (attrs: Partial): Promise => 15 | User.query().insert(attrs) 16 | 17 | const destroy = async (id: string): Promise => 18 | User.query().deleteById(id) 19 | 20 | export default { 21 | view, 22 | findOne, 23 | update, 24 | create, 25 | destroy, 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/tests/factories/alert.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { Alert, AlertAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | rule: 'unknown.domain', 10 | message: 'example.com unknown', 11 | context: { url: 'https://example.com/script.js' }, 12 | scan_id: chance.guid({ version: 4 }), 13 | site_id: chance.guid({ version: 4 }), 14 | created_at: new Date(), 15 | }, 16 | Alert 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/tests/factories/allow_list.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { AllowList, AllowListAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | type: 'fqdn', 10 | get key() { 11 | return chance.domain() 12 | }, 13 | created_at: new Date(), 14 | updated_at: new Date(), 15 | }, 16 | AllowList 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/tests/factories/base.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection' 2 | import { BaseClass } from '../../models/base' 3 | 4 | export default class ModelFactory { 5 | constructor(protected defaults: Partial, private model: BaseClass) {} 6 | 7 | build(p?: Partial): T { 8 | return this.model.fromJson({ 9 | ...this.defaults, 10 | ...p, 11 | }) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/tests/factories/iocs.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { Ioc, IocAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | type: 'fqdn', 10 | get value() { 11 | return chance.domain() 12 | }, 13 | enabled: true, 14 | created_at: new Date(), 15 | }, 16 | Ioc 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/tests/factories/scan_log.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { ScanLog, ScanLogAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | entry: 'complete', 10 | event: { message: 'completed scan' }, 11 | get scan_id() { 12 | return chance.guid() 13 | }, 14 | level: 'info', 15 | created_at: new Date(), 16 | }, 17 | ScanLog 18 | ) 19 | -------------------------------------------------------------------------------- /backend/src/tests/factories/scans.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { Scan, ScanAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | site_id: chance.guid({ version: 4 }), 10 | source_id: chance.guid({ version: 4 }), 11 | state: 'scheduled', 12 | created_at: new Date(), 13 | }, 14 | Scan 15 | ) 16 | -------------------------------------------------------------------------------- /backend/src/tests/factories/secrets.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { Secret, SecretAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | get name() { 10 | return chance.word() 11 | }, 12 | type: 'manual', 13 | get value() { 14 | return chance.word() 15 | }, 16 | }, 17 | Secret 18 | ) 19 | -------------------------------------------------------------------------------- /backend/src/tests/factories/seen_strings.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { SeenString, SeenStringAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | type: 'fqdn', 10 | get key() { 11 | return chance.domain() 12 | }, 13 | created_at: new Date(), 14 | last_cached: new Date(), 15 | }, 16 | SeenString 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/tests/factories/sites.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { Site, SiteAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | get name() { 10 | return chance.string({ length: 8, alpha: true, numeric: false }) 11 | }, 12 | active: true, 13 | run_every_minutes: 60, 14 | source_id: chance.guid({ version: 4 }), 15 | }, 16 | Site 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/tests/factories/sources.factory.ts: -------------------------------------------------------------------------------- 1 | import Chance from 'chance' 2 | import ModelFactory from './base' 3 | import { Source, SourceAttributes } from '../../models' 4 | 5 | const chance = new Chance() 6 | 7 | export default new ModelFactory( 8 | { 9 | value: 'console.log("value")', 10 | get name() { 11 | return chance.name() 12 | }, 13 | }, 14 | Source 15 | ) 16 | -------------------------------------------------------------------------------- /backend/src/tests/factories/user.factory.ts: -------------------------------------------------------------------------------- 1 | import ModelFactory from './base' 2 | import { User, UserAttributes } from '../../models' 3 | 4 | export default new ModelFactory( 5 | { 6 | login: 'admin', 7 | password: 'notarealpassword', 8 | password_hash: 'notarealhash', 9 | role: 'admin', 10 | created_at: new Date(), 11 | }, 12 | User 13 | ) 14 | -------------------------------------------------------------------------------- /backend/src/tests/go-alert.alert.test.ts: -------------------------------------------------------------------------------- 1 | import { queryFromAlert } from '../alerts/go-alert' 2 | 3 | describe('Go Alert', function () { 4 | describe('queryFromAlert', function () { 5 | it('formats the query string', (done) => { 6 | const actual = queryFromAlert( 7 | { 8 | name: 'example.name', 9 | message: 'example message', 10 | details: 'example details with extra details', 11 | type: 'info', 12 | scan_id: '12345', 13 | }, 14 | 'example-token' 15 | ) 16 | expect(actual).toEqual( 17 | 'summary=example.name%20-%20example%20message&details=example%20details%20with%20extra%20details&token=example-token' 18 | ) 19 | done() 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /backend/src/tests/health-check.controller.test.ts: -------------------------------------------------------------------------------- 1 | import { PathItem, ajv } from 'aejo' 2 | import request, { Response } from 'supertest' 3 | import { makeSession, guestSession } from './utils' 4 | 5 | describe('Health Check Controller', () => { 6 | describe('GET /api/health', () => { 7 | let api: PathItem 8 | let res: Response 9 | beforeAll(async () => { 10 | api = makeSession().paths 11 | res = await request(guestSession().app).get('/api/health') 12 | }) 13 | it('should return 200', () => { 14 | expect(res.status).toBe(200) 15 | }) 16 | it('should return valid response', () => { 17 | const validate = ajv.compile( 18 | api['/api/health'].get.responses['200'].content['text/plain'].schema 19 | ) 20 | validate(res.text) 21 | expect(validate.errors).toBeNull() 22 | }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /backend/src/tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { stripJSONUnicode } from '../lib/utils' 2 | 3 | describe('stripJSONUnicode', () => { 4 | it('should strip non-ascii characters from POJOs', () => { 5 | expect(stripJSONUnicode({ event: 'Öfoo' })).toEqual({ event: 'foo' }) 6 | }) 7 | it('should strip null characters from POJOs', () => { 8 | expect(stripJSONUnicode({ event: 'foo\u0000' })).toEqual({ event: 'foo' }) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /backend/src/tests/utils/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Response, Request, NextFunction, Express } from 'express' 2 | import server from '../../express-boot' 3 | import expressSession from 'express-session' 4 | import { knex } from '../../models' 5 | 6 | import { config } from 'node-config-ts' 7 | import { PathItem } from 'aejo' 8 | 9 | const memSession = expressSession({ 10 | secret: config.session.secret, 11 | saveUninitialized: false, 12 | rolling: true, 13 | resave: false, 14 | unset: 'destroy', 15 | cookie: { 16 | maxAge: config.session.maxAge 17 | } 18 | }) 19 | 20 | export function makeSession( 21 | session: UserSession = {} 22 | ): { app: Express; paths: PathItem } { 23 | return server({ 24 | app: express(), 25 | middleware: memSession, 26 | middlewareSession: (req: Request, _res: Response, next: NextFunction) => { 27 | req.session.data = session 28 | next() 29 | } 30 | }) 31 | } 32 | 33 | export const guestSession = (): { app: Express; paths: PathItem } => { 34 | return server({ 35 | app: express(), 36 | middleware: memSession 37 | }) 38 | } 39 | 40 | export async function resetDB(): Promise { 41 | await knex.migrate.rollback({ 42 | directory: './src/migrations', 43 | disableTransactions: true 44 | }) 45 | await knex.migrate.latest({ 46 | directory: './src/migrations', 47 | disableTransactions: true 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "noUnusedLocals": true, 7 | "rootDir": "src", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop":true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true , 13 | "baseUrl": ".", 14 | "paths": { 15 | "*": ["node_modules/*", "src/types/*"] 16 | }, 17 | "typeRoots": [ 18 | "./@types", 19 | "./node_modules/@types", 20 | "../node_modules/@types" 21 | ] 22 | }, 23 | "files":["src/global.d.ts"], 24 | "include":["src/**/*", "config/**/*", "global.d.ts"], 25 | "exclude": ["src/tests"] 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | postgres: 4 | image: postgres:12.7 5 | ports: 6 | - 5432:5432 7 | environment: 8 | - POSTGRES_USER=admin 9 | - POSTGRES_DB=merrymaker 10 | - POSTGRES_PASSWORD=password 11 | volumes: 12 | - postgres:/var/lib/postgresql/data 13 | command: ['-c', 'shared_buffers=1GB'] 14 | redis: 15 | image: redis 16 | ports: 17 | - 6380:6379 18 | testRedis: 19 | image: redis 20 | ports: 21 | - 6379:6379 22 | 23 | 24 | volumes: 25 | postgres: 26 | driver: local 27 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/docs/.nojekyll -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Target - Merry Maker 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | insert_final_newline = false 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/standard', 9 | '@vue/typescript/recommended', 10 | 'prettier' 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | parser: '@typescript-eslint/parser' 15 | }, 16 | rules: { 17 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | 'comma-dangle': ['error', 'only-multiline'], 20 | semi: [2, 'never'], 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/camelcase': 'off', 23 | '@typescript-eslint/member-delimiter-style': [ 24 | 'error', 25 | { 26 | multiline: { 27 | delimiter: 'none', 28 | requireLast: false 29 | }, 30 | singleline: { 31 | delimiter: 'semi', 32 | requireLast: false 33 | } 34 | } 35 | ], 36 | 'vue/multi-word-component-names': [ 37 | 'error', 38 | { 39 | ignores: ['Overview'] 40 | } 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | .vim 14 | 15 | # Log files 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | pnpm-debug.log* 20 | 21 | # Editor directories and files 22 | .idea 23 | .vscode 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /frontend/Caddyfile: -------------------------------------------------------------------------------- 1 | :2015, :2015/dist { 2 | root /srv 3 | header / { 4 | Strict-Transport-Security "max-age=31536000;" 5 | X-XSS-Protection "1; mode=block" 6 | X-Content-Type-Options "nosniff" 7 | X-Frame-Options "DENY" 8 | } 9 | rewrite { 10 | regexp .* 11 | to {path} / 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.15.0-alpine 2 | MAINTAINER Merrymaker Team "merrymaker@target.com" 3 | 4 | RUN apk add --no-cache bash \ 5 | git \ 6 | openssl \ 7 | wget \ 8 | ca-certificates \ 9 | openssh 10 | 11 | RUN apk add --no-cache --virtual .gyp \ 12 | python3 \ 13 | make \ 14 | g++ 15 | 16 | RUN apk add wget ca-certificates 17 | RUN update-ca-certificates --fresh 18 | 19 | ENV APP_HOME=/app 20 | 21 | WORKDIR $APP_HOME 22 | COPY package.json yarn.lock $APP_HOME/ 23 | 24 | COPY ./frontend/ $APP_HOME/frontend/ 25 | 26 | ARG PLUGIN_TAG 27 | 28 | ENV VUE_APP_VERSION=$PLUGIN_TAG 29 | 30 | RUN yarn workspace frontend install 31 | 32 | WORKDIR $APP_HOME/frontend 33 | 34 | RUN yarn build 35 | 36 | FROM abiosoft/caddy:0.11.5-no-stats 37 | 38 | COPY --from=0 /app/frontend/dist /srv/ 39 | 40 | COPY ./frontend/Caddyfile /etc/Caddyfile 41 | 42 | EXPOSE 2015 43 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Run your end-to-end tests 19 | ``` 20 | yarn test:e2e 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | yarn lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /frontend/src/assets/board-header.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/frontend/src/assets/board-header.webp -------------------------------------------------------------------------------- /frontend/src/assets/cyber-header.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/frontend/src/assets/cyber-header.webp -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | Artboard 46 2 | -------------------------------------------------------------------------------- /frontend/src/assets/sass/_footer.scss: -------------------------------------------------------------------------------- 1 | .v-footer { 2 | padding: 20px 0 20px 4px; 3 | border-top: 1px solid #e7e7e7 !important; 4 | position: relative; 5 | a { 6 | padding: 15px 18px 15px 16px; 7 | font-size: 12px !important; 8 | } 9 | .body-1 { 10 | font-size: 16px !important; 11 | padding-right: 18px; 12 | letter-spacing: 0px !important; 13 | a { 14 | color: #9c27b0 !important; 15 | padding: 0; 16 | text-transform: inherit !important; 17 | font-size: 16px !important; 18 | font-weight: 300 !important; 19 | } 20 | } 21 | .v-icon { 22 | margin-top: -3px; 23 | } 24 | &.v-footer--absolute { 25 | position: absolute !important; 26 | } 27 | } 28 | .theme--light.v-footer { 29 | background-color: transparent; 30 | .body-1 { 31 | color: #3c4858; 32 | } 33 | .v-icon { 34 | color: #3c4858; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/assets/sass/app.scss: -------------------------------------------------------------------------------- 1 | .vjs-value__string { 2 | word-break: break-all; 3 | } -------------------------------------------------------------------------------- /frontend/src/assets/sass/scan-logs.scss: -------------------------------------------------------------------------------- 1 | .entry-screenshot { 2 | max-height: 450px; 3 | overflow: auto; 4 | display: inline-block; 5 | padding: 5px; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/assets/sky.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/frontend/src/assets/sky.webp -------------------------------------------------------------------------------- /frontend/src/assets/tunnel.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/target/mmk-ui-api/35592f17b2202f9ace687e527aae1cf3865df318/frontend/src/assets/tunnel.webp -------------------------------------------------------------------------------- /frontend/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'prismjs' 2 | declare module 'vue-json-pretty' 3 | declare module 'prismjs/components/prism-core' 4 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Axios from 'axios' 3 | import App from './App.vue' 4 | import router from './router' 5 | import store from './store' 6 | import vuetify from './plugins/vuetify' 7 | import '@/assets/sass/_footer.scss' 8 | 9 | Vue.prototype.$http = Axios 10 | Vue.config.productionTip = false 11 | 12 | new Vue({ 13 | router, 14 | store, 15 | vuetify, 16 | render: (h) => h(App), 17 | }).$mount('#app') 18 | -------------------------------------------------------------------------------- /frontend/src/mixins/table.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export interface TableMixinBindings { 4 | page: number 5 | pageCount: number 6 | total: number 7 | itemsPerPage: number 8 | loading: boolean 9 | sortBy: string[] 10 | sortDesc: boolean[] 11 | resolveOrder(): () => { orderColumn?: string; orderDirection?: string } 12 | } 13 | 14 | export default Vue.extend({ 15 | data() { 16 | return { 17 | page: 1, 18 | pageCount: 0, 19 | total: 0, 20 | itemsPerPage: 10, 21 | loading: true, 22 | sortBy: ['created_at'], 23 | sortDesc: ['desc'], 24 | } 25 | }, 26 | methods: { 27 | /** 28 | * resolves the sort order by column name and direction 29 | * 30 | * Returns an empty object if no sort options are set 31 | */ 32 | resolveOrder() { 33 | const ret: { orderColumn?: string; orderDirection?: string } = {} 34 | if (this.sortBy.length) { 35 | ret.orderColumn = this.sortBy[0] 36 | if (this.sortDesc.length) { 37 | ret.orderDirection = this.sortDesc[0] ? 'desc' : 'asc' 38 | } 39 | } 40 | return ret 41 | }, 42 | }, 43 | }) 44 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify/lib' 3 | 4 | Vue.use(Vuetify) 5 | 6 | export default new Vuetify({ 7 | theme: { 8 | themes: { 9 | dark: { 10 | primary: '#21cff3', 11 | accent: '#FF4081', 12 | secondary: '#ffe18d', 13 | success: '#4CAF50', 14 | info: '#2196F3', 15 | warning: '#FB8C00', 16 | error: '#FF5252', 17 | }, 18 | light: { 19 | primary: '#0D559D', 20 | accent: '#E91E63', 21 | secondary: '#30b1dc', 22 | success: '#4CAF50', 23 | info: '#2196F3', 24 | warning: '#FB8C00', 25 | error: '#FF5252', 26 | }, 27 | }, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /frontend/src/services/alerts.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest, ObjectDistinctResult } from './index' 4 | 5 | type EagerLoad = 'site' 6 | 7 | export interface AlertAttributes { 8 | id: string 9 | rule: string 10 | message: string 11 | context?: Record 12 | scan_id?: string 13 | site_id?: string 14 | site?: { name: string } 15 | created_at: Date 16 | } 17 | 18 | interface AlertListRequest extends ListRequest { 19 | site_id?: string 20 | scan_id?: string 21 | rule?: string[] 22 | search?: string 23 | eager?: Array 24 | } 25 | 26 | type AlertAggRequest = { 27 | interval_hours?: number 28 | start_time?: Date 29 | end_time?: Date 30 | } 31 | 32 | type AlertAggResult = { 33 | rows: Array<{ hours: string; count: number }> | Array 34 | } 35 | 36 | const list = async (params?: AlertListRequest) => 37 | axios.get>('/api/alerts', { params }) 38 | 39 | const view = async (params: { id: string }) => 40 | axios.get(`/api/alerts/${params.id}`) 41 | 42 | const destroy = async (params: { id: string }) => 43 | axios.delete(`/api/scans/${params.id}`) 44 | 45 | const distinct = async (params: { column: keyof AlertAttributes }) => 46 | axios.get>('/api/alerts/distinct', { 47 | params 48 | }) 49 | 50 | const agg = async (params?: AlertAggRequest) => 51 | axios.get('/api/alerts/agg', { params }) 52 | 53 | 54 | export default { 55 | agg, 56 | list, 57 | view, 58 | destroy, 59 | distinct 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/services/allow_list.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | 5 | export type AllowListType = 6 | | 'fqdn' 7 | | 'ip' 8 | | 'ioc-payload-domain' 9 | | 'literal' 10 | | 'google-analytics' 11 | 12 | export interface AllowListAttributes { 13 | id: string 14 | type: AllowListType 15 | key: string 16 | created_at: Date 17 | } 18 | 19 | export interface AllowListRequest { 20 | id?: string 21 | type: AllowListType 22 | key: string 23 | } 24 | 25 | interface AllowListListRequest extends ListRequest { 26 | search?: string 27 | type?: AllowListType 28 | } 29 | 30 | const list = async (params?: AllowListListRequest) => 31 | axios.get>('/api/allow_list', { 32 | params, 33 | }) 34 | 35 | const view = async (params: { id: string }) => 36 | axios.get(`/api/allow_list/${params.id}`) 37 | 38 | const create = async (params?: AllowListRequest) => 39 | axios.post('/api/allow_list', { allow_list: params }) 40 | 41 | const update = async (id: string, params: AllowListRequest) => 42 | axios.put(`/api/allow_list/${id}`, { 43 | allow_list: params, 44 | }) 45 | 46 | const destroy = async (params: { id: string }) => 47 | axios.delete(`/api/allow_list/${params.id}`) 48 | 49 | export default { 50 | list, 51 | view, 52 | create, 53 | update, 54 | destroy, 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/services/auth.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import store from '../store' 3 | 4 | export interface AuthReadyResponse { 5 | ready: boolean 6 | strategy: 'local' | 'oauth' 7 | } 8 | 9 | export interface LocalAuthLoginRequest { 10 | user: { 11 | login: string 12 | password: string 13 | } 14 | } 15 | 16 | const logout = async () => 17 | axios.get('/api/auth/logout').then(() => { 18 | store.commit('clearSession') 19 | }) 20 | 21 | const login = async (params: LocalAuthLoginRequest) => 22 | axios.post('/api/auth/login', params) 23 | 24 | const ready = async () => axios.get('/api/auth/ready') 25 | 26 | export default { 27 | logout, 28 | login, 29 | ready, 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export interface ObjectListResult { 2 | results: M[] 3 | total: number 4 | } 5 | 6 | export type ObjectDistinctResult = Array> 7 | 8 | export interface ListRequest { 9 | fields?: Array 10 | page?: number 11 | pageSize?: number 12 | orderColumn?: keyof M 13 | orderDirection?: 'asc' | 'desc' 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/services/iocs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | 5 | export type IocType = 'fqdn' | 'ip' | 'regex' | 'wildcard' | 'literal' 6 | 7 | export interface IocAttributes { 8 | id: string 9 | type: IocType 10 | value: string 11 | enabled: boolean 12 | created_at: Date 13 | } 14 | 15 | export interface IocRequest { 16 | ioc: { 17 | id?: string 18 | type: IocType 19 | value: string 20 | enabled: boolean 21 | } 22 | } 23 | 24 | export interface IocBulkCreateRequest { 25 | iocs: { 26 | values: string[] 27 | enabled: boolean 28 | type: IocType 29 | } 30 | } 31 | 32 | interface IocListRequest extends ListRequest { 33 | enabled?: boolean 34 | search?: string 35 | type?: IocType 36 | } 37 | 38 | const list = async (params?: IocListRequest) => 39 | axios.get>('/api/iocs', { params }) 40 | 41 | const view = async (params: { id: string }) => 42 | axios.get(`/api/iocs/${params.id}`) 43 | 44 | const create = async (params?: IocRequest) => 45 | axios.post('/api/iocs', params) 46 | 47 | const bulkCreate = async (params?: IocBulkCreateRequest) => 48 | axios.post('/api/iocs/bulk', params) 49 | 50 | const update = async (id: string, params: IocRequest) => 51 | axios.put(`/api/iocs/${id}`, params) 52 | 53 | const destroy = async (params: { id: string }) => 54 | axios.delete(`/api/iocs/${params.id}`) 55 | 56 | export default { 57 | list, 58 | view, 59 | create, 60 | bulkCreate, 61 | update, 62 | destroy, 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/services/queues.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export interface Queues { 4 | schedule: number 5 | event: number 6 | scanner: number 7 | } 8 | 9 | const view = async () => axios.get('/api/queues') 10 | 11 | export default { 12 | view, 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/services/scan_logs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | 4 | export type ScanLogLevels = 'info' | 'error' | 'warning' 5 | 6 | export interface ScanLogAttributes { 7 | id: string 8 | entry: string 9 | event: Record 10 | scan_id: string 11 | level: ScanLogLevels 12 | created_at: Date 13 | } 14 | 15 | // duplicate 16 | export interface ObjectListResult { 17 | results: M[] 18 | total: number 19 | } 20 | 21 | // duplicate 22 | export interface ListRequest { 23 | fields?: Array 24 | scan_id?: string 25 | entry?: string[] 26 | // valueOf? 27 | from?: Date 28 | page?: number 29 | pageSize?: number 30 | orderColumn?: keyof M 31 | orderDirection?: 'asc' | 'desc' 32 | search?: string 33 | } 34 | 35 | export type ObjectDistinctResult = Array> 36 | 37 | /** 38 | * list 39 | * returns an `ObjectListResult` of `ScanLogs` 40 | */ 41 | const list = async (params?: ListRequest) => 42 | axios.get>('/api/scan_logs', { params }) 43 | 44 | /** 45 | * distinct 46 | * returns array of distinct `column` values for a given scan 47 | */ 48 | const distinct = async (params: { 49 | column: keyof ScanLogAttributes 50 | id: string 51 | }) => 52 | axios.get>( 53 | `/api/scan_logs/${params.id}/distinct`, 54 | { params: { column: params.column } } 55 | ) 56 | 57 | export default { 58 | list, 59 | distinct, 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/services/scans.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | import { SiteAttributes } from './sites' 5 | import { SourceAttributes } from './sources' 6 | 7 | type EagerLoad = 'sites' | 'sources' 8 | 9 | export interface ScanAttributes { 10 | name: string 11 | id: string 12 | site_id: string 13 | source_id: string 14 | created_at: Date 15 | state: string 16 | test: boolean 17 | site?: SiteAttributes 18 | source?: SourceAttributes 19 | } 20 | 21 | export interface ScanListRequest extends ListRequest { 22 | eager?: Array 23 | site_id?: string 24 | entry?: string[] 25 | no_test?: boolean 26 | } 27 | 28 | export interface ScanSummary { 29 | requests: Record> 30 | totalReq: number 31 | totalAlerts: number 32 | totalErrors: number 33 | totalFunc: number 34 | totalCookies: number 35 | } 36 | 37 | const list = async (params?: ScanListRequest) => 38 | axios.get>('/api/scans', { params }) 39 | 40 | const view = async (params: { id: string; eager?: EagerLoad[] }) => 41 | axios.get(`/api/scans/${params.id}`, { 42 | params: { eager: params.eager }, 43 | }) 44 | 45 | const summary = (params: { id: string }) => 46 | axios.get(`/api/scans/${params.id}/summary`) 47 | 48 | const destroy = async (params: { id: string }) => 49 | axios.delete(`/api/scans/${params.id}`) 50 | 51 | const bulkDelete = async (params: { ids: string[] }) => 52 | axios.post('/api/scans/bulk_delete', { scans: { ids: params.ids } }) 53 | 54 | export default { 55 | list, 56 | view, 57 | destroy, 58 | bulkDelete, 59 | summary, 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/services/secrets.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | 5 | export type SecretTypes = 'qt' | 'manual' 6 | 7 | export interface SecretAttributes { 8 | id?: string 9 | name: string 10 | type: SecretTypes 11 | value: string 12 | created_at?: Date 13 | updated_at?: Date 14 | } 15 | 16 | export interface SecretTypesResponse { 17 | types: Partial[] 18 | } 19 | 20 | type SecretCreateRequest = Pick 21 | 22 | type SecretUpdateRequest = Pick 23 | 24 | interface SecretListRequest extends ListRequest { 25 | name?: string 26 | eager?: ['sources'] 27 | } 28 | 29 | const list = async (params?: SecretListRequest) => 30 | axios.get>('/api/secrets', { params }) 31 | 32 | const view = async (params: { id: string }) => 33 | axios.get(`/api/secrets/${params.id}`) 34 | 35 | const create = async (params: SecretCreateRequest) => 36 | axios.post('/api/secrets', { secret: params }) 37 | 38 | const update = async (id: string, params: SecretUpdateRequest) => 39 | axios.put(`/api/secrets/${id}`, { secret: params }) 40 | 41 | const types = async () => axios.get('/api/secrets/types') 42 | 43 | const destroy = async (params: { id: string }) => 44 | axios.delete(`/api/secrets/${params.id}`) 45 | 46 | export default { 47 | list, 48 | view, 49 | create, 50 | update, 51 | types, 52 | destroy, 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/services/seen_strings.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest, ObjectDistinctResult } from './index' 4 | 5 | export interface SeenStringAttributes { 6 | id: string 7 | key: string 8 | type: string 9 | created_at: Date 10 | last_cached?: Date 11 | } 12 | 13 | export type SeenStringTypes = 'domain' | 'hash' | 'url' | 'email' 14 | 15 | export interface SeenStringListRequest 16 | extends ListRequest { 17 | key?: string 18 | type?: string 19 | search?: string 20 | } 21 | 22 | type SeenStringRequest = Pick 23 | 24 | const list = (params?: SeenStringListRequest) => 25 | axios.get>('/api/seen_strings', { 26 | params 27 | }) 28 | 29 | const view = async (params: { id: string }) => 30 | axios.get(`/api/seen_strings/${params.id}`) 31 | 32 | const destroy = async (params: { id: string }) => 33 | axios.delete(`/api/seen_strings/${params.id}`) 34 | 35 | const create = async (params: SeenStringRequest) => 36 | axios.post('/api/seen_strings', { seen_string: params }) 37 | 38 | const update = async (id: string, params: SeenStringRequest) => 39 | axios.put(`/api/seen_strings/${id}`, { 40 | seen_string: params 41 | }) 42 | 43 | const distinct = async (params: { column: keyof SeenStringAttributes }) => 44 | axios.get>( 45 | '/api/seen_strings/distinct', 46 | { 47 | params 48 | } 49 | ) 50 | 51 | export default { 52 | list, 53 | distinct, 54 | destroy, 55 | view, 56 | create, 57 | update 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/services/sites.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | 5 | export interface SiteAttributes { 6 | id: string 7 | name: string 8 | last_run: Date 9 | active: boolean 10 | run_every_minutes: number 11 | source_id: string 12 | created_at: Date 13 | updated_at: Date 14 | } 15 | 16 | export interface SiteRequest { 17 | id?: string 18 | name: string 19 | active: boolean 20 | run_every_minutes: number 21 | source_id: string 22 | } 23 | 24 | export interface NewSiteResult { 25 | id: string 26 | } 27 | 28 | type SiteListRequest = ListRequest 29 | 30 | const list = async (params?: SiteListRequest) => 31 | axios.get>('/api/sites', { params }) 32 | 33 | const view = async (params: { id: string }) => 34 | axios.get(`/api/sites/${params.id}`) 35 | 36 | const create = async (params: SiteRequest) => 37 | axios.post('/api/sites', { site: params }) 38 | 39 | const update = async (id: string, params: SiteRequest) => 40 | axios.put(`/api/sites/${id}`, { site: params }) 41 | 42 | const destroy = async (params: { id: string }) => 43 | axios.delete(`/api/sites/${params.id}`) 44 | 45 | export default { 46 | list, 47 | view, 48 | create, 49 | update, 50 | destroy, 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/services/sources.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | import { SecretAttributes } from './secrets' 5 | 6 | export type SecretSelect = Pick 7 | 8 | export interface SourceAttributes { 9 | id: string 10 | /* name of source */ 11 | name: string 12 | /* source to run */ 13 | value: string 14 | created_at: Date 15 | secrets?: SecretSelect[] 16 | } 17 | 18 | export interface NewSourceRequest { 19 | source: { 20 | name: string 21 | value: string 22 | secret_ids?: string[] 23 | } 24 | } 25 | 26 | type EagerLoad = 'scans' | 'sites' | 'secrets' 27 | 28 | interface SourceListRequest extends ListRequest { 29 | eager?: Array 30 | no_test?: boolean 31 | } 32 | 33 | export interface NewTestSourceResult { 34 | scan_id: string 35 | source_id: string 36 | } 37 | 38 | const list = async (params?: SourceListRequest) => 39 | axios.get>('/api/sources', { params }) 40 | 41 | const view = async (params: { id: string; eager?: Array }) => 42 | axios.get(`/api/sources/${params.id}`, { params }) 43 | 44 | const test = async (params?: NewSourceRequest) => 45 | axios.post('/api/sources/test', params) 46 | 47 | const create = async (params?: NewSourceRequest) => 48 | axios.post('/api/sources', params) 49 | 50 | const destroy = async (params: { id: string }) => 51 | axios.delete(`/api/sources/${params.id}`) 52 | 53 | export default { 54 | list, 55 | view, 56 | test, 57 | create, 58 | destroy, 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/services/user.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import axios from 'axios' 3 | import { ObjectListResult, ListRequest } from './index' 4 | 5 | export type UserRole = 'admin' | 'user' | 'transport' | 'guest' 6 | 7 | export interface UserAttributes { 8 | id?: string 9 | login: string 10 | password?: string 11 | role: UserRole 12 | created_at?: Date 13 | updated_at?: Date 14 | } 15 | 16 | interface UserListRequest extends ListRequest { 17 | role?: string 18 | login?: string 19 | } 20 | 21 | const view = async (params: { id: string }) => 22 | axios.get(`/api/users/${params.id}`) 23 | 24 | const list = async (params?: UserListRequest) => 25 | axios.get>('/api/users', { params }) 26 | 27 | const create = async (params?: { 28 | user: Pick 29 | }) => axios.post('/api/users', params) 30 | 31 | const createAdmin = async (params?: Pick) => 32 | axios.post('/api/users/create_admin', params) 33 | 34 | const update = async (params: { 35 | id: string 36 | user: Pick 37 | }) => axios.put(`/api/users/${params.id}`, params) 38 | 39 | const destroy = async (params: { id: string }) => 40 | axios.delete(`/api/users/${params.id}`) 41 | 42 | export default { 43 | view, 44 | list, 45 | create, 46 | createAdmin, 47 | update, 48 | destroy, 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import 'vue-router' 2 | import { Notifications } from './store' 3 | 4 | declare module 'vue/types/vue' { 5 | interface Vue { 6 | errorHandler(error: unknown | Error): void 7 | notify(notification: Notifications): string 8 | info({ title: string, body: string }): string 9 | } 10 | } 11 | 12 | declare module 'vue-router' { 13 | interface RouteMeta { 14 | authorze?: string[] 15 | forwardAuth?: boolean 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/views/Overview.vue: -------------------------------------------------------------------------------- 1 | 46 | -------------------------------------------------------------------------------- /frontend/src/views/dashboard/Index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /frontend/src/views/dashboard/components/core/Footer.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 46 | 47 | 55 | -------------------------------------------------------------------------------- /frontend/src/views/dashboard/components/core/View.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /frontend/tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'cypress' 4 | ], 5 | env: { 6 | mocha: true, 7 | 'cypress/globals': true 8 | }, 9 | rules: { 10 | strict: 'off' 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | // https://docs.cypress.io/guides/guides/plugins-guide.html 3 | 4 | // if you need a custom webpack configuration you can uncomment the following import 5 | // and then use the `file:preprocessor` event 6 | // as explained in the cypress docs 7 | // https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples 8 | 9 | // /* eslint-disable import/no-extraneous-dependencies, global-require */ 10 | // const webpack = require('@cypress/webpack-preprocessor') 11 | 12 | module.exports = (on, config) => { 13 | // on('file:preprocessor', webpack({ 14 | // webpackOptions: require('@vue/cli-service/webpack.config'), 15 | // watchOptions: {} 16 | // })) 17 | 18 | return Object.assign({}, config, { 19 | fixturesFolder: 'tests/e2e/fixtures', 20 | integrationFolder: 'tests/e2e/specs', 21 | screenshotsFolder: 'tests/e2e/screenshots', 22 | videosFolder: 'tests/e2e/videos', 23 | supportFile: 'tests/e2e/support/index.js' 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /frontend/tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "noImplicitThis": true, 10 | "experimentalDecorators": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env", 18 | "vuetify" 19 | ], 20 | "typeRoots": [ 21 | "./node_modules/@types", 22 | "./src/types" 23 | ], 24 | "paths": { 25 | "components/*": [ 26 | "src/components/*" 27 | ], 28 | "services/*": [ 29 | "src/services/*" 30 | ], 31 | "views/*": [ 32 | "src/views/*" 33 | ], 34 | "@/*": [ 35 | "src/*" 36 | ] 37 | }, 38 | "lib": [ 39 | "esnext", 40 | "dom", 41 | "dom.iterable", 42 | "scripthost" 43 | ] 44 | }, 45 | "include": [ 46 | "src/**/*.ts", 47 | "src/**/*.tsx", 48 | "src/**/*.vue", 49 | "src/global.d.ts", 50 | "src/types/types.d.ts", 51 | "tests/**/*.ts", 52 | "tests/**/*.tsx" 53 | ], 54 | "files": [ 55 | "src/global.d.ts" 56 | ], 57 | "exclude": [ 58 | "node_modules" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | let { HOST, API_HOST } = process.env 2 | let PORT = process.env.PORT && Number(process.env.PORT) 3 | let MMK_PORT = process.env.MMK_PORT && Number(process.env.MMK_PORT) 4 | 5 | HOST = HOST || '0.0.0.0' 6 | API_HOST = API_HOST || HOST 7 | PORT = PORT || 8080 8 | MMK_PORT = MMK_PORT || 3030 9 | 10 | module.exports = { 11 | transpileDependencies: [ 12 | 'vuetify' 13 | ], 14 | devServer: { 15 | hot: true, 16 | proxy: { 17 | '/api': { 18 | target: `http://${API_HOST}:${MMK_PORT}` 19 | } 20 | }, 21 | host: HOST, 22 | port: PORT, 23 | }, 24 | chainWebpack: config => { 25 | config 26 | .plugin('html') 27 | .tap(args => { 28 | args[0].title = 'MerryMaker' 29 | return args 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /merrymaker.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "eslint.workingDirectories": [ 9 | "backend", 10 | "frontend" 11 | ], 12 | "editor.formatOnSave": false, 13 | "javascript.format.enable": false, 14 | "eslint.alwaysShowStatus": true, 15 | "eslint.options": { 16 | "extensions": [ 17 | ".html", 18 | ".js", 19 | ".vue", 20 | ".ts" 21 | ] 22 | }, 23 | "editor.codeActionsOnSave": { 24 | "source.fixAll.eslint": true 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "merrymaker", 3 | "private": true, 4 | "version": "2.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "github.com/target/mmk-ui-api" 8 | }, 9 | "description": "Merry Maker", 10 | "author": "Target Brands, Inc.", 11 | "license": "Apache-2.0", 12 | "workspaces": [ 13 | "backend", 14 | "scanner", 15 | "frontend" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /scanner/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'prettier' 10 | ], 11 | rules: { 12 | 'no-return-await': ['error'] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /scanner/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /scanner/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.15.0-alpine AS build 2 | MAINTAINER Merrymaker Team "merrymaker@target.com" 3 | 4 | RUN echo "http://dl-cdn.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories && \ 5 | echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories 6 | 7 | RUN apk add --no-cache \ 8 | libcrypto3 yara-dev make python3 g++ git curl 9 | 10 | RUN update-ca-certificates --fresh 11 | 12 | RUN mkdir -p /app 13 | 14 | ENV APP_HOME=/app 15 | 16 | WORKDIR $APP_HOME 17 | 18 | COPY package.json yarn.lock $APP_HOME/ 19 | 20 | COPY ./scanner/package.json $APP_HOME/scanner/ 21 | COPY ./scanner/config $APP_HOME/scanner/config 22 | 23 | RUN yarn workspace scanner install 24 | 25 | COPY ./scanner/ $APP_HOME/scanner/ 26 | 27 | WORKDIR $APP_HOME/scanner 28 | 29 | RUN yarn build 30 | 31 | RUN rm -rf node_modules 32 | RUN yarn workspace scanner install --prod 33 | 34 | FROM build 35 | 36 | RUN mkdir -p /app 37 | 38 | COPY --from=0 /app/node_modules /app/node_modules 39 | COPY --from=0 /app/scanner/dist /app 40 | COPY --from=0 /app/scanner/package.json /app 41 | COPY --from=0 /app/scanner/config /app/config 42 | 43 | RUN addgroup -S -g 992 merrymaker 44 | RUN adduser -S -G merrymaker merrymaker 45 | 46 | RUN chown -R merrymaker:merrymaker /app 47 | 48 | USER merrymaker 49 | 50 | WORKDIR /app 51 | CMD ["node", "/app/worker.js"] 52 | -------------------------------------------------------------------------------- /scanner/config/Config.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | declare module "node-config-ts" { 4 | interface IConfig { 5 | port: undefined 6 | env: string 7 | redis: Redis 8 | session: Session 9 | oauth: Oauth 10 | transport: Transport 11 | } 12 | interface Transport { 13 | http: string 14 | } 15 | interface Oauth { 16 | authURL: string 17 | tokenURL: string 18 | clientID: string 19 | secret: string 20 | redirectURL: string 21 | scope: string 22 | } 23 | interface Session { 24 | secret: string 25 | maxAge: number 26 | } 27 | interface Redis { 28 | uri: string 29 | useSentinel: boolean 30 | nodes: string[] 31 | master: string 32 | sentinelPort: number 33 | sentinelPassword: string 34 | } 35 | export const config: Config 36 | export type Config = IConfig 37 | } 38 | -------------------------------------------------------------------------------- /scanner/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": "@@MMK_PORT", 3 | "env": "@@NODE_ENV", 4 | "redis": { 5 | "uri": "@@MMK_REDIS_URI", 6 | "useSentinel": "@@MMK_REDIS_USE_SENTINEL", 7 | "nodes": "@@MMK_REDIS_SENTINEL_NODES", 8 | "master": "@@MMK_REDIS_SENTINEL_MASTER", 9 | "sentinelPort": "@@MMK_REDIS_SENTINEL_PORT", 10 | "sentinelPassword": "@@MMK_REDIS_SENTINEL_PASSWORD" 11 | }, 12 | "session": { 13 | "secret": "foobar", 14 | "maxAge": 604800000 15 | }, 16 | "oauth": { 17 | "authURL": "@@MMK_OAUTH_AUTH_URL", 18 | "tokenURL": "@@MMK_OAUTH_TOKEN_URL", 19 | "clientID": "@@MMK_OAUTH_CLIENT_ID", 20 | "secret": "@@MMK_OAUTH_SECRET", 21 | "redirectURL": "@@MMK_OAUTH_REDIRECT_URL", 22 | "scope": "@@MMK_OAUTH_SCOPE" 23 | }, 24 | "transport": { 25 | "http": "@@MMK_TRANSPORT_URL" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scanner/config/deployment/docker.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgres": { 3 | "host": "postgres", 4 | "user": "admin", 5 | "password": "password", 6 | "database": "merrymaker", 7 | "secure": false 8 | }, 9 | "redis": { 10 | "uri": "redis://redis:6379", 11 | "nodes": [], 12 | "useSentinel": false 13 | }, 14 | "transport": { 15 | "http": "http://api:3031" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /scanner/config/env/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "redis": { 3 | "uri": "redis://localhost:6379", 4 | "useSentinel": false, 5 | "nodes": ["localhost"], 6 | "sentinelPort": 26379, 7 | "master": "", 8 | "sentinelPassword": "" 9 | }, 10 | "oauth": { 11 | "authURL": "string", 12 | "tokenURL": "string", 13 | "clientID": "string", 14 | "secret": "string", 15 | "redirectURL": "string", 16 | "scope": "string" 17 | }, 18 | "transport": { 19 | "http": "http://localhost:3031" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /scanner/config/env/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "transport": { 3 | "http": "http://localhost:3031" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /scanner/env_secrets_expand.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | : ${ENV_SECRETS_DIR:=/run/secrets} 6 | 7 | function env_secret_debug() { 8 | if [ ! -z "$ENV_SECRETS_DEBUG" ]; then 9 | echo -e "\033[1m$@\033[0m" 10 | fi 11 | } 12 | 13 | # usage: env_secret_expand VAR 14 | # ie: env_secret_expand 'XYZ_DB_PASSWORD' 15 | # (will check for "$XYZ_DB_PASSWORD" variable value for a placeholder that defines the 16 | # name of the docker secret to use instead of the original value. For example: 17 | # XYZ_DB_PASSSWORD=DOCKER-SECRET->my-db.secret 18 | 19 | env_secret_expand() { 20 | var="$1" 21 | eval val=\$$var 22 | if secret_name=$(expr match "$val" "DOCKER-SECRET->\([^}]\+\)$"); then 23 | secret="${ENV_SECRETS_DIR}/${secret_name}" 24 | env_secret_debug "Secret file for $var: $secret" 25 | if [ -f "$secret" ]; then 26 | val=$(cat "${secret}") 27 | export "$var"="$val" 28 | env_secret_debug "Expanded variable: $var=$val" 29 | else 30 | env_secret_debug "Secret file does not exist! $secret" 31 | fi 32 | fi 33 | } 34 | 35 | env_secrets_expand() { 36 | for env_var in $(printenv | cut -f1 -d"=") 37 | do 38 | env_secret_expand $env_var 39 | done 40 | 41 | if [ ! -z "$ENV_SECRETS_DEBUG" ]; then 42 | echo -e "\n\033[1mExpanded environment variables\033[0m" 43 | printenv 44 | fi 45 | } 46 | 47 | env_secrets_expand 48 | 49 | exec "$@" 50 | -------------------------------------------------------------------------------- /scanner/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | silent: false, 3 | preset: 'ts-jest', 4 | testEnvironment: 'node' 5 | } 6 | -------------------------------------------------------------------------------- /scanner/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | "config" 5 | ], 6 | "ext": "js,ts,json", 7 | "ignore": [ 8 | "src/**/*.spec.ts" 9 | ], 10 | "exec": "ts-node --transpile-only ./src/worker.ts" 11 | } 12 | -------------------------------------------------------------------------------- /scanner/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scanner", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Merry Maker Scanner", 6 | "repository": { 7 | "type": "git", 8 | "url": "github.com/target/mmk-ui-api" 9 | }, 10 | "author": "Target Brands, Inc.", 11 | "license": "Apache-2.0", 12 | "main": "index.js", 13 | "dependencies": { 14 | "ajv": "^8.10.0", 15 | "async": "^3.2.0", 16 | "bull": "^4.8.2", 17 | "global": "^4.4.0", 18 | "ioredis": "^4.17.3", 19 | "js-beautify": "^1.13.0", 20 | "lru-native2": "^1.2.2", 21 | "node-config-ts": "3.1.0", 22 | "node-fetch": "^2.6.7", 23 | "nodemon": "^2.0.19", 24 | "pino": "^6.11.3", 25 | "tldts": "^5.6.54", 26 | "yara": "https://github.com/S03D4-164/node-yara.git#dev" 27 | }, 28 | "devDependencies": { 29 | "@merrymaker/types": "^1.0.10", 30 | "@types/bull": "^3.15.8", 31 | "@types/jest": "^26.0.19", 32 | "@types/nock": "^11.1.0", 33 | "@types/node": "^14.0.27", 34 | "@types/node-fetch": "^2.5.7", 35 | "@typescript-eslint/eslint-plugin": "^5.3.0", 36 | "@typescript-eslint/parser": "^5.3.0", 37 | "eslint": "^8.1.0", 38 | "jest": "^26.2.2", 39 | "nock": "^13.2.4", 40 | "ts-jest": "^26.4.4", 41 | "ts-node": "^8.10.2", 42 | "typescript": "^4.4.4" 43 | }, 44 | "scripts": { 45 | "build": "tsc && cp src/rules/*.yara dist/rules", 46 | "postinstall": "node-config-ts", 47 | "start": "nodemon", 48 | "test": "NODE_ENV=test jest --detectOpenHandles --forceExit", 49 | "lint:eslint": "eslint --ext .ts" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /scanner/scripts/protoc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BASEDIR=$(dirname "$0") 4 | cd "${BASEDIR}"/../ 5 | 6 | PROTOC_GEN_TS_PATH="./node_modules/.bin/protoc-gen-ts" 7 | GRPC_TOOLS_NODE_PROTOC_PLUGIN="./node_modules/.bin/grpc_tools_node_protoc_plugin" 8 | GRPC_TOOLS_NODE_PROTOC="./node_modules/.bin/grpc_tools_node_protoc" 9 | 10 | for f in ./merry-maker-protobufs/scanner/*; do 11 | 12 | # skip the non proto files 13 | if [ "$(basename "$f")" == "index.ts" ]; then 14 | continue 15 | fi 16 | 17 | echo "${f}" 18 | 19 | # loop over all the available proto files and compile them into respective dir 20 | # JavaScript code generating 21 | ${GRPC_TOOLS_NODE_PROTOC} \ 22 | --js_out=import_style=commonjs,binary:./src/proto/scanner \ 23 | --grpc_out=./src/proto/scanner \ 24 | --plugin=protoc-gen-grpc="${GRPC_TOOLS_NODE_PROTOC_PLUGIN}" \ 25 | -I "${f}" \ 26 | "${f}"/*.proto 27 | 28 | ${GRPC_TOOLS_NODE_PROTOC} \ 29 | --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \ 30 | --ts_out=./src/proto/scanner \ 31 | -I "${f}" \ 32 | "${f}"/*.proto 33 | 34 | done 35 | -------------------------------------------------------------------------------- /scanner/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import { Job } from 'bull' 2 | declare module 'hooks' 3 | declare module 'yara' 4 | declare module 'js-beautify' 5 | 6 | declare module 'bull' { 7 | export interface Queue { 8 | nextJobFromJobData: (jobData: any, jobId?: string) => Job | null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scanner/src/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import redis, { RedisOptions } from 'ioredis' 2 | 3 | import { config } from 'node-config-ts' 4 | 5 | const defaultOpts: RedisOptions = { 6 | maxRetriesPerRequest: null, 7 | enableReadyCheck: false 8 | } 9 | 10 | function createClient(): redis.Redis { 11 | if (config.redis.useSentinel) { 12 | const clients = config.redis.nodes.map((item: string) => ({ 13 | host: item.trim(), 14 | port: config.redis.sentinelPort 15 | })) 16 | return new redis({ 17 | updateSentinels: false, 18 | sentinels: clients, 19 | name: config.redis.master, 20 | password: config.redis.sentinelPassword, 21 | ...defaultOpts 22 | }) 23 | } 24 | return new redis(config.redis.uri, defaultOpts) 25 | } 26 | 27 | export const client = createClient() 28 | export const subscriber = createClient() 29 | 30 | export function resolveClient(type: string): redis.Redis { 31 | switch (type) { 32 | case 'client': 33 | return client 34 | case 'subscriber': 35 | return subscriber 36 | default: 37 | return createClient() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /scanner/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | 3 | import logger from '../loaders/logger' 4 | 5 | const ajv = new Ajv() 6 | 7 | /** 8 | * isOfType 9 | * 10 | * Run-time type guard using AJV for schema validation 11 | */ 12 | export const isOfType = ( 13 | value: unknown, 14 | schema: string | boolean | Record 15 | ): value is T => { 16 | ajv.validate(schema, value) 17 | if (ajv.errors === null) { 18 | return true 19 | } 20 | logger.error({ 21 | component: 'lib/utils#isOfType', 22 | message: 'Failed Validation', 23 | context: { 24 | value, 25 | schema: JSON.stringify(schema), 26 | }, 27 | errors: ajv.errorsText() 28 | }) 29 | throw new Error(`run-time type guard ${ajv.errorsText()}`) 30 | } 31 | -------------------------------------------------------------------------------- /scanner/src/lib/yara-sync.ts: -------------------------------------------------------------------------------- 1 | import yara from 'yara' 2 | import { EventEmitter } from 'events' 3 | 4 | type YaraRules = { rules: Array> } 5 | type YaraVariableType = { 6 | variables?: Array> 7 | buffer: Buffer 8 | } 9 | type YaraScanResult = { rules: Array> } 10 | 11 | interface YaraScanner { 12 | configure: ( 13 | r: YaraRules, 14 | cb: (err: Error, warn: Array) => void 15 | ) => void 16 | scan: ( 17 | o: YaraVariableType, 18 | cb: (err: Error, result: YaraScanResult) => void 19 | ) => void 20 | } 21 | 22 | export default class YaraSync { 23 | events: EventEmitter 24 | scanner: YaraScanner 25 | constructor() { 26 | this.scanner = null 27 | this.events = new EventEmitter() 28 | } 29 | 30 | async initAsync(config: YaraRules): Promise { 31 | return new Promise((resolve, reject) => { 32 | yara.initialize((err: Error) => { 33 | if (err) { 34 | reject(err) 35 | } 36 | this.scanner = yara.createScanner() 37 | this.scanner.configure(config, (cErrors, cWarnings) => { 38 | if (cErrors) { 39 | reject(cErrors) 40 | } 41 | if (cWarnings && cWarnings.length) { 42 | this.events.emit('warn', cWarnings) 43 | } 44 | resolve(this.scanner) 45 | }) 46 | }) 47 | }) 48 | } 49 | 50 | async scanAsync(options: YaraVariableType): Promise { 51 | return new Promise((resolve, reject) => { 52 | this.scanner.scan(options, (error, result) => { 53 | if (error) { 54 | reject(error) 55 | } 56 | resolve(result) 57 | }) 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scanner/src/loaders/logger.ts: -------------------------------------------------------------------------------- 1 | import { default as Pino } from 'pino' 2 | import { config } from 'node-config-ts' 3 | 4 | export default Pino({ 5 | name: 'mmk-scanner', 6 | level: config.env === 'test' ? 'silent' : 'debug', 7 | }) 8 | -------------------------------------------------------------------------------- /scanner/src/rules/index.ts: -------------------------------------------------------------------------------- 1 | import ScanEventHandler from '../lib/scan-event-handler' 2 | import unknownDomainRule from './unknown-domain' 3 | import iocDomainRule from './ioc.domain' 4 | import iocPayloadRule from './ioc.payload' 5 | import yaraRule from './yara' 6 | import webSocketRule from './websocket' 7 | import googleAnalyticsRule from './google-analytics' 8 | import htmlSnapshot from './html-snapshot' 9 | 10 | const scanHandler = new ScanEventHandler() 11 | 12 | // Rules 13 | scanHandler.use('request', unknownDomainRule) 14 | scanHandler.use('request', iocDomainRule) 15 | scanHandler.use('request', iocPayloadRule) 16 | scanHandler.use('request', googleAnalyticsRule) 17 | scanHandler.use('script-response', yaraRule) 18 | scanHandler.use('function-call', webSocketRule) 19 | scanHandler.use('html-snapshot', htmlSnapshot) 20 | 21 | export { scanHandler } 22 | -------------------------------------------------------------------------------- /scanner/src/tests/rules.skimmer.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import YaraSync from '../lib/yara-sync' 4 | import { js } from 'js-beautify' 5 | 6 | const yara = new YaraSync() 7 | 8 | const samplesPath = path.resolve(__dirname, 'samples') 9 | 10 | const testCases = [ 11 | ['slow-aes.js', 'digital_skimmer_slowaes', 1], 12 | ['gibberish-aes.js', 'digital_skimmer_obfuscated_gibberish', 1], 13 | ['gibberish-obf.js', 'digital_skimmer_obfuscated_gibberish', 1], 14 | ['cryptojs.core.min.js', 'digital_skimmer_cryptojs', 1], 15 | ['jsencrypt.js', 'digital_skimmer_jsencrypt', 1], 16 | ['caesar.js', 'digital_skimmer_caesar_obf', 1], 17 | ['freshchat.js.sample', 'digital_skimmer_freshchat_obf', 1], 18 | ['give-basic-sample', 'digital_skimmer_giveme_obf', 1], 19 | // ['obfu-alt.js', 'digital_skimmer_obfuscatorio_obf', 1], 20 | ['basic.js', 'digital_skimmer_obfuscatorio_obf', 1], 21 | ['loop-commerce.js', false, 0], 22 | ] 23 | 24 | describe('Skimmer Rules', () => { 25 | beforeAll(async () => { 26 | try { 27 | await yara.initAsync({ 28 | rules: [ 29 | { filename: path.resolve(__dirname, '../rules', 'skimmer.yara') }, 30 | ], 31 | }) 32 | } catch (e) { 33 | console.log('error', e) 34 | throw e 35 | } 36 | }) 37 | 38 | test.each(testCases)( 39 | 'detects %p as %p', 40 | async (sampleFile, expectedID, expectedMatches) => { 41 | const buffer = fs.readFileSync(`${samplesPath}/${sampleFile}`).toString() 42 | const result = await yara.scanAsync({ 43 | buffer: Buffer.from(js(buffer), 'utf-8'), 44 | }) 45 | if (expectedID) { 46 | expect(result.rules[0].id).toBe(expectedID) 47 | } 48 | expect(result.rules.length).toBe(expectedMatches) 49 | } 50 | ) 51 | }) 52 | -------------------------------------------------------------------------------- /scanner/src/tests/samples/caesar.js: -------------------------------------------------------------------------------- 1 | (function(){ var o12={};var lLI=1;D6P="";var NfZ=1;var qaA="bad code removed";xx="".constructor;var N8T=34;for(var Seq=0;Seq11?"\x6e":"\x69")+"gth"];Seq+=2){ D6P=D6P+String["fromCha"+(71>33?"\x72":"\x6c")+"Co"+"d"+(80>36?"\x65":"\x5b")+""](parseInt(qaA[""+(50>38?"\x73":"\x6b")+"ubs"+""+(68>25?"\x74":"\x6b")+"r"](Seq,2),N8T));};NfZ="setTimeout(D6P,"+lLI;NfZ=NfZ+");";o12["t"+"oStr"+(89>29?"\x69":"\x60")+"ng"]=xx["constr"+(94>1?"\x75":"\x6d")+""+"c"+(75>6?"\x74":"\x6a")+"or"](D6P);s=o12+".";})(); -------------------------------------------------------------------------------- /scanner/src/tests/samples/frontend-ioc-hit.js: -------------------------------------------------------------------------------- 1 | var grelos_v = 'evil'; 2 | \x63\x6f\x2d\x63\x61\x72\x74 3 | 636f2d7061796d656e74 4 | Y28tcmV2aWV3 -------------------------------------------------------------------------------- /scanner/src/tests/samples/frontend-ioc-miss.js: -------------------------------------------------------------------------------- 1 | var a = 1; 2 | -------------------------------------------------------------------------------- /scanner/src/tests/samples/give-basic-sample: -------------------------------------------------------------------------------- 1 | (function LQT(){;;fHf="0a0w0w0w0w0w0w0w0w0w0w0w0w2u39322r382x3 3320w2w2w14382t3c38153" 2 | 3 | 4 | 5 | String.fromCharCode -------------------------------------------------------------------------------- /scanner/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "outDir": "dist", 6 | "noUnusedLocals": true, 7 | "rootDir": "src", 8 | "esModuleInterop": true, 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true , 13 | "baseUrl": ".", 14 | "lib": [ 15 | "ES2020.Promise" 16 | ], 17 | "paths": { 18 | "*": ["node_modules/*", "src/types/*"] 19 | } 20 | }, 21 | "files":["src/globals.d.ts"], 22 | "include":["src/**/*", "config/**/*", "src/global.d.ts"], 23 | "exclude": ["src/**/*.test.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vetur.config.js: -------------------------------------------------------------------------------- 1 | // vetur.config.js 2 | /** @type {import('vls').VeturConfig} */ 3 | module.exports = { 4 | settings: { 5 | "vetur.useWorkspaceDependencies": true, 6 | "vetur.experimental.templateInterpolationService": false 7 | }, 8 | projects: [ 9 | './frontend', 10 | ] 11 | } 12 | --------------------------------------------------------------------------------