├── .eslintignore ├── testResources ├── blessedFake.js └── jobs │ ├── job_4.0.0-alpha.3_cloud_single_appScanner │ ├── job_4.0.0-alpha.3_local_missing_type_of_appScanner │ ├── job_4.0.0-alpha.3_local │ ├── job_4.0.0-alpha.3_local_missing_comma │ └── job_4.0.0-alpha.3_cloud ├── .husky └── pre-commit ├── assets └── images │ └── purpleteam-banner.png ├── licenses ├── third_party.md ├── pcl_header.txt ├── bsl_header.txt └── bsl.md ├── bin └── purpleteam.js ├── config ├── config.localtest.json ├── config.example.local.json ├── config.example.cloud.json └── config.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── config.yml │ ├── questions--discussions---do-not-create-issues-for-questions.md │ ├── ask-a-question---do-not-create-issues-for-questions.md │ └── bug_report.md └── workflows │ └── node.js.yml ├── .gitignore ├── test ├── .eslintrc.cjs ├── models │ └── model.js └── presenter │ ├── apiDecoratingAdapter_sSe_falsyMessageAndIncorrectOrigin.js │ ├── apiDecoratingAdapter.js │ └── apiDecoratingAdapter_sSeAndLp.js ├── src ├── view │ ├── index.js │ ├── blessedTypes │ │ ├── app.js │ │ ├── tls.js │ │ ├── server.js │ │ └── index.js │ ├── noUi.js │ └── cUi.js ├── cmds │ ├── status.js │ ├── testplan.js │ ├── test.js │ └── about.js ├── index.js ├── strings │ └── index.js ├── cli.js ├── schemas │ ├── job.js │ ├── job.browserApp.js │ └── job.aPi.js ├── models │ └── model.js └── presenter │ └── apiDecoratingAdapter.js ├── LICENSE.md ├── LEGALNOTICE.md ├── .eslintrc.cjs ├── package.json └── CONTRIBUTING.md /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .git/ 3 | -------------------------------------------------------------------------------- /testResources/blessedFake.js: -------------------------------------------------------------------------------- 1 | export default { screen: () => {} }; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | 6 | -------------------------------------------------------------------------------- /assets/images/purpleteam-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purpleteam-labs/purpleteam/HEAD/assets/images/purpleteam-banner.png -------------------------------------------------------------------------------- /licenses/third_party.md: -------------------------------------------------------------------------------- 1 | The licenses used by the _PurpleTeam_ _Tester_ _Emissaries_ can be found [here](https://purpleteam-labs.com/doc/third-party-sources/). 2 | 3 | The license of PurpleTeam dependencies can usually be found within the source code of the specific dependency. 4 | -------------------------------------------------------------------------------- /licenses/pcl_header.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | Licensed as a PurpleTeam cloud file under the PurpleTeam Cloud 4 | License (the "License"); you may not use this file except in compliance with 5 | the License. You may obtain a copy of the License at 6 | 7 | https://purpleteam-labs.com/publication/purpleteam-cloud-license/ 8 | -------------------------------------------------------------------------------- /bin/purpleteam.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { createRequire } from 'module'; 4 | 5 | const require = createRequire(import.meta.url); 6 | const { exports } = require('../package'); 7 | 8 | const { default: start } = await import(`.${exports}`); 9 | 10 | async function main() { 11 | await start({ argv: process.argv }); 12 | } 13 | 14 | await main(); 15 | -------------------------------------------------------------------------------- /licenses/bsl_header.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | Use of this software is governed by the Business Source License 4 | included in the file /licenses/bsl.md 5 | 6 | As of the Change Date specified in that file, in accordance with 7 | the Business Source License, use of this software will be governed 8 | by the Apache License, Version 2.0 9 | -------------------------------------------------------------------------------- /config/config.localtest.json: -------------------------------------------------------------------------------- 1 | { 2 | "loggers": { 3 | "cUi": { 4 | "level": "debug" 5 | } 6 | }, 7 | "purpleteamApi": { 8 | "protocol": "http", 9 | "host": "127.0.0.1" 10 | }, 11 | "job": { 12 | "fileUri": "./testResources/jobs/job_4.0.0-alpha.3_local" 13 | }, 14 | "modulePaths": { 15 | "blessed": "../../testResources/blessedFake.js" 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | The enhancement may already be reported! Please search for the enhancement before creating one. 11 | 12 | ### Current Behaviour: 13 | 14 | ... 15 | 16 | ### Proposed Behaviour: 17 | 18 | ... 19 | 20 | ### How It Would Benefit You: 21 | 22 | ... 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | config.*.json 3 | !config.example.local.json 4 | !config.example.cloud.json 5 | !config.localtest.json 6 | 7 | .vscode 8 | *.code-workspace 9 | logs 10 | coverage/ 11 | 12 | testResources/jobs/* 13 | 14 | !testResources/jobs/job_4.0.0-alpha.3_cloud 15 | !testResources/jobs/job_4.0.0-alpha.3_cloud_single_appScanner 16 | !testResources/jobs/job_4.0.0-alpha.3_local 17 | !testResources/jobs/job_4.0.0-alpha.3_local_missing_comma 18 | !testResources/jobs/job_4.0.0-alpha.3_local_missing_type_of_appScanner 19 | 20 | -------------------------------------------------------------------------------- /test/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | module.exports = { 11 | // https://github.com/avajs/eslint-plugin-ava 12 | extends: 'plugin:ava/recommended', 13 | rules: {} 14 | }; 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | # Use of this software is governed by the Business Source License 4 | # included in the file /licenses/bsl.md 5 | 6 | # As of the Change Date specified in that file, in accordance with 7 | # the Business Source License, use of this software will be governed 8 | # by the Apache License, Version 2.0 9 | 10 | blank_issues_enabled: false 11 | contact_links: 12 | - name: BinaryMist 13 | url: https://binarymist.io/#contact 14 | about: If you need to contact us directly. 15 | -------------------------------------------------------------------------------- /config/config.example.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "loggers": { 3 | "cUi": { 4 | "level": "debug" 5 | }, 6 | "testerProgress": { 7 | "dirname": "" 8 | }, 9 | "testPlan": { 10 | "dirname": "" 11 | } 12 | }, 13 | "purpleteamApi": { 14 | "protocol": "http", 15 | "host": "172.25.0.110" 16 | }, 17 | "job": { 18 | "fileUri": "./testResources/jobs/" 19 | }, 20 | "outcomes": { 21 | "dir": "" 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/view/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import config from '../../config/config.js'; 11 | 12 | const viewFileName = config.get(`uI.path.${config.get('uI.type')}`); 13 | 14 | const { default: view } = await import(viewFileName); 15 | 16 | export default view; 17 | -------------------------------------------------------------------------------- /src/cmds/status.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import api from '../presenter/apiDecoratingAdapter.js'; 11 | 12 | const flags = 'status'; 13 | const desc = 'Check the status of the PurpleTeam back-end.'; 14 | const run = async () => { 15 | api.inject({}); 16 | await api.status(); 17 | }; 18 | 19 | export { flags, desc, run }; 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/questions--discussions---do-not-create-issues-for-questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Questions, Discussions - Do not create issues for questions 3 | about: Have a question about purpleteam? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | All questions should be directed to the GitHub discussions or purpleteam-labs Slack. 11 | Questions submitted through GitHub issues will be closed. 12 | 13 | GitHub discussions 14 | - https://github.com/purpleteam-labs/purpleteam/discussions 15 | 16 | Slack 17 | - https://purpleteam-labs.slack.com/ 18 | 19 | Slack invite 20 | - DM us at https://twitter.com/purpleteamlabs 21 | 22 | Providing as much detail as possible, what is your question? 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ask-a-question---do-not-create-issues-for-questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ask A Question - Do not create issues for questions 3 | about: Have a question or want to discuss something about purpleteam? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | All questions should be directed to the GitHub discussions or purpleteam-labs Slack. 11 | Questions submitted through GitHub issues will be closed. 12 | 13 | GitHub discussions 14 | - https://github.com/purpleteam-labs/purpleteam/discussions 15 | 16 | Slack 17 | - https://purpleteam-labs.slack.com/ 18 | 19 | Slack invite 20 | - DM us at https://twitter.com/purpleteamlabs 21 | 22 | Providing as much detail as possible, what is your question? 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { init as initLogger } from 'purpleteam-logger'; 11 | import config from '../config/config.js'; 12 | import processCommands from './cli.js'; 13 | 14 | const cUiLogger = initLogger(config.get('loggers.cUi')); 15 | 16 | const start = async (options) => { 17 | cUiLogger.debug('Starting the CLI', { tags: ['index'] }); 18 | await processCommands({ argv: options.argv }); 19 | }; 20 | 21 | export default start; 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | There are 2 licenses for PurpleTeam. BSL covers our `local` environment and PCL (PurpleTeam Cloud License) 4 | which covers the `cloud` environment (also known as BinaryMist PurpleTeam). 5 | 6 | 1. [BSL](./licenses/bsl.md): The intent as we mentioned in our [blog post](https://binarymist.io/blog/2021/12/20/purpleteam-license-change/) 7 | is to allow any use of the `local` PurpleTeam environment other than 8 | offering PurpleTeam as a service. The license will at some point in the future (change date) become Apache 2. 9 | 10 | 2. [PCL](https://purpleteam-labs.com/publication/purpleteam-cloud-license/): PurpleTeam Cloud License - is intended to allow you to use the `cloud` environment that you pay for. 11 | 12 | We thank Redpanda, MariaDB and CockroachDB for pioneering the use of BSL for storage systems. 13 | It gave us a path to build a SaaS product in the age of the hyperclouds. 14 | -------------------------------------------------------------------------------- /config/config.example.cloud.json: -------------------------------------------------------------------------------- 1 | { 2 | "loggers": { 3 | "cUi": { 4 | "level": "debug" 5 | }, 6 | "testerProgress": { 7 | "dirname": "" 8 | }, 9 | "testPlan": { 10 | "dirname": "" 11 | } 12 | }, 13 | "purpleteamApi": { 14 | "protocol": "https", 15 | "host": "api[release-environment].purpleteam-labs.com", 16 | "port": 443, 17 | "stage": "alpha", 18 | "customerId": "", 19 | "apiKey": "" 20 | }, 21 | "purpleteamAuth": { 22 | "protocol": "https", 23 | "host": "auth[release-environment].purpleteam-labs.com", 24 | "appClientId": "", 25 | "appClientSecret": "", 26 | "custnSubdomain": "" 27 | }, 28 | "job": { 29 | "fileUri": "./testResources/jobs/" 30 | }, 31 | "outcomes": { 32 | "dir": "" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | The defect may already be reported! Please search for the defect before creating one. 11 | 12 | 13 | ### Actual Behavior: 14 | 15 | ... 16 | 17 | ### Expected Behavior: 18 | 19 | ... 20 | 21 | ### Steps to Reproduce: 22 | 23 | ... 24 | 25 | ### Environment: 26 | 27 | * Specific purpleteam project and version? 28 | * Running locally or in the cloud? 29 | * Details of your specific System Under Test (SUT)? 30 | * Which purpleteam components are running on which Operating System and version? 31 | * Anything else that will set the stage for us understanding your context? 32 | 33 | ### Additional Details: 34 | 35 | (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, Github Discussions, links for us to have context, eg. code from your repository, a discussion in stackoverflow, gitter, etc) 36 | 37 | ### Checklist 38 | 39 | - [ ] I have read the documentation. 40 | -------------------------------------------------------------------------------- /LEGALNOTICE.md: -------------------------------------------------------------------------------- 1 | COPYRIGHT 2 | --------- 3 | 4 | PurpleTeam 5 | 6 | The software components under PurpleTeam-Labs are: 7 | 8 | > Copyright (C) 2017-2022 BinaryMist Limited 9 | 10 | Any new or updated code, tests or information sent to PurpleTeam-Labs is assumed free of copyrights. By sending new or updated code, tests or information to purpleteam-labs you relinquish all claims of copyright on the material, and agree that this code can be claimed under the same copyright and license as PurpleTeam. 11 | 12 | SOFTWARE LICENSE 13 | ---------------- 14 | 15 | The software licenses of the PurpleTeam components are included in their respective repositories or linked to from the LICENSE.md file in the root directory. 16 | Source code in a given file is licensed under either the BSL or PCL as defined by the copyright notice at the beginning of the file. 17 | For other licenses [contact us](https://purpleteam-labs.com/contact/). 18 | 19 | THIRD-PARTY SOFTWARE LICENSES 20 | ----------------------------- 21 | 22 | A list of third party sources can be found [here](https://purpleteam-labs.com/doc/third-party-sources/). 23 | 24 | -------------------------------------------------------------------------------- /src/strings/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | const TesterUnavailable = (tester) => `No ${tester} testing available currently. The ${tester} Tester is currently in-active.`; // Should match orchestrator. 11 | const TestPlanUnavailable = (tester) => `No test plan available for the ${tester} Tester. The ${tester} Tester is currently in-active.`; // Should match orchestrator. 12 | 13 | const TesterFeedbackRoutePrefix = (m) => ({ sse: 'tester-feedback', lp: 'poll-tester-feedback' }[m]); 14 | 15 | // Also used in the app tester. 16 | const NowAsFileName = () => { 17 | const date = new Date(); 18 | const padLeft = (num) => (num < 10 ? `0${num}` : `${num}`); 19 | return `${date.getFullYear()}-${padLeft(date.getMonth() + 1)}-${padLeft(date.getDate())}T${padLeft(date.getHours())}:${padLeft(date.getMinutes())}:${padLeft(date.getSeconds())}`; 20 | }; 21 | 22 | export { 23 | TesterUnavailable, 24 | TestPlanUnavailable, 25 | TesterFeedbackRoutePrefix, 26 | NowAsFileName 27 | }; 28 | -------------------------------------------------------------------------------- /src/view/blessedTypes/app.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import blessed from 'blessed'; 11 | import contrib from 'blessed-contrib'; 12 | 13 | const testOpts = { 14 | gridCoords: { 15 | row: 0, 16 | col: 0, 17 | rowSpan: 10.5, 18 | colSpan: 12 19 | }, 20 | type: contrib.log, 21 | args: { 22 | label: 'App Tester', 23 | bufferLength: 1000, 24 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } }, 25 | tags: 'true', 26 | name: 'app' 27 | } 28 | }; 29 | 30 | const testPlanOpts = { 31 | gridCoords: { 32 | row: 0, 33 | col: 0, 34 | rowSpan: 10.5, 35 | colSpan: 12 36 | }, 37 | type: blessed.log, 38 | args: { 39 | label: 'App Test Plan', 40 | mouse: true, 41 | scrollable: true, 42 | tags: true, 43 | keys: true, 44 | vi: true, 45 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } }, 46 | border: { type: 'line', fg: '#00ff00' }, 47 | hover: { bg: 'blue' }, 48 | scrollbar: { ch: ' ', track: { bg: 'magenta' }, style: { inverse: true } }, 49 | name: 'app' 50 | } 51 | }; 52 | 53 | export default { testOpts, testPlanOpts }; 54 | 55 | -------------------------------------------------------------------------------- /src/view/blessedTypes/tls.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import blessed from 'blessed'; 11 | import contrib from 'blessed-contrib'; 12 | 13 | const testOpts = { 14 | gridCoords: { 15 | row: 0, 16 | col: 0, 17 | rowSpan: 10.5, 18 | colSpan: 12 19 | }, 20 | type: contrib.log, 21 | args: { 22 | label: 'TLS Tester', 23 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } }, 24 | bufferLength: 1000, 25 | tags: 'true', 26 | name: 'tls' 27 | } 28 | }; 29 | 30 | const testPlanOpts = { 31 | gridCoords: { 32 | row: 0, 33 | col: 0, 34 | rowSpan: 10.5, 35 | colSpan: 12 36 | }, 37 | type: blessed.log, 38 | args: { 39 | label: 'TLS Test Plan', 40 | mouse: true, 41 | scrollable: true, 42 | tags: true, 43 | keys: true, 44 | vi: true, 45 | style: { fg: 'default', gb: 'default', border: { fg: 'magenta', bg: 'default' } }, 46 | border: { type: 'line', fg: '#00ff00' }, 47 | hover: { bg: 'blue' }, 48 | scrollbar: { ch: ' ', track: { bg: 'magenta' }, style: { inverse: true } }, 49 | name: 'tls' 50 | } 51 | }; 52 | 53 | export default { testOpts, testPlanOpts }; 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | # Use of this software is governed by the Business Source License 4 | # included in the file /licenses/bsl.md 5 | 6 | # As of the Change Date specified in that file, in accordance with 7 | # the Business Source License, use of this software will be governed 8 | # by the Apache License, Version 2.0 9 | 10 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 11 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 12 | 13 | name: Node.js CI 14 | 15 | on: 16 | push: 17 | branches: [ main ] 18 | pull_request: 19 | branches: [ main ] 20 | 21 | jobs: 22 | build: 23 | 24 | runs-on: ubuntu-latest 25 | 26 | strategy: 27 | matrix: 28 | node-version: [14.x, 16.x] 29 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | 38 | - run: npm ci 39 | - run: npm run build --if-present 40 | - run: npm run lint 41 | - run: npm run test:nolint 42 | 43 | - name: Coveralls 44 | uses: coverallsapp/github-action@master 45 | with: 46 | github-token: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | -------------------------------------------------------------------------------- /src/view/blessedTypes/server.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import blessed from 'blessed'; 11 | import contrib from 'blessed-contrib'; 12 | 13 | const testOpts = { 14 | gridCoords: { 15 | row: 0, 16 | col: 0, 17 | rowSpan: 10.5, 18 | colSpan: 12 19 | }, 20 | type: contrib.log, 21 | args: { 22 | label: 'Server Tester', 23 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } }, 24 | bufferLength: 1000, 25 | tags: 'true', 26 | name: 'server' 27 | } 28 | }; 29 | 30 | const testPlanOpts = { 31 | gridCoords: { 32 | row: 0, 33 | col: 0, 34 | rowSpan: 10.5, 35 | colSpan: 12 36 | }, 37 | type: blessed.log, 38 | args: { 39 | label: 'Server Test Plan', 40 | mouse: true, 41 | scrollable: true, 42 | tags: true, 43 | keys: true, 44 | vi: true, 45 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } }, 46 | border: { type: 'line', fg: '#00ff00' }, 47 | hover: { bg: 'blue' }, 48 | scrollbar: { ch: ' ', track: { bg: 'magenta' }, style: { inverse: true } }, 49 | name: 'server' 50 | } 51 | }; 52 | 53 | export default { testOpts, testPlanOpts }; 54 | 55 | -------------------------------------------------------------------------------- /src/view/noUi.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import config from '../../config/config.js'; 11 | import { NowAsFileName } from '../strings/index.js'; 12 | 13 | const handleTesterProgress = ({ testerType, sessionId, message, ptLogger }) => { 14 | ptLogger.get(`${testerType}-${sessionId}`).notice(message); 15 | }; 16 | 17 | const handleTesterPctComplete = () => { 18 | // No UI so nothing to do. 19 | }; 20 | 21 | const handleTesterBugCount = () => { 22 | // No UI so nothing to do. 23 | }; 24 | 25 | const testPlan = ({ testPlans, ptLogger }) => { 26 | const { transports, dirname } = config.get('loggers.testPlan'); 27 | testPlans.forEach((tP) => { 28 | // loggerThype is app or server or tls. 29 | const loggerType = `${tP.name}-testPlan`; 30 | ptLogger.add(loggerType, { transports, filename: `${dirname}${loggerType}_${NowAsFileName()}` }).notice(tP.message); 31 | }); 32 | }; 33 | 34 | const test = () => { 35 | // No UI so nothing to do. 36 | }; 37 | 38 | const status = (cUiLogger, statusOfPurpleteamApi) => { 39 | cUiLogger.notice(statusOfPurpleteamApi, { tags: ['noUi'] }); 40 | }; 41 | 42 | export default { 43 | testPlan, 44 | test, 45 | status, 46 | handleTesterProgress, 47 | handleTesterPctComplete, 48 | handleTesterBugCount 49 | }; 50 | 51 | -------------------------------------------------------------------------------- /src/cmds/testplan.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { statSync } from 'fs'; 11 | import config from '../../config/config.js'; 12 | import api from '../presenter/apiDecoratingAdapter.js'; 13 | 14 | const flags = 'testplan'; 15 | const desc = 'Retrieve the test plan that will be executed when you run test.'; 16 | const setup = (sywac) => { 17 | // To override the help: 18 | // sywac.usage({ optionsPlaceholder: '' }); 19 | sywac 20 | .option('-j, --job-file ', { 21 | type: 'file', 22 | desc: 'Build user supplied Job file. Must be a file conforming to the Job schema.', 23 | mustExist: true, 24 | defaultValue: (() => { 25 | const jobFileUri = config.get('job.fileUri'); 26 | const isFile = statSync(jobFileUri, { throwIfNoEntry: false })?.isFile(); 27 | return isFile ? jobFileUri : ''; // Only an empty string will cause proper error handling. 28 | })() 29 | }) 30 | .check((argv, context) => { 31 | if (argv._.length) context.cliMessage(`Unknown argument${argv._.length > 1 ? 's' : ''}: ${argv._.join(', ')}`); 32 | }); 33 | }; 34 | const run = async (parsedArgv, context) => { 35 | if (parsedArgv.j) { 36 | api.inject({}); 37 | const jobFileContents = await api.getJobFile(parsedArgv.j); 38 | await api.testPlans(jobFileContents); 39 | } else { 40 | context.cliMessage('You must provide a valid Job file that exists on the local file system.'); 41 | } 42 | }; 43 | 44 | export { flags, desc, setup, run }; 45 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | module.exports = { 11 | extends: 'airbnb-base', 12 | rules: { 13 | 'comma-dangle': ['error', 'never'], 14 | 15 | // specify the maximum length of a line in your program 16 | // http://eslint.org/docs/rules/max-len 17 | 'max-len': ['error', 200, 2, { 18 | ignoreUrls: true, 19 | ignoreComments: false, 20 | ignoreRegExpLiterals: true, 21 | ignoreStrings: true, 22 | ignoreTemplateLiterals: true 23 | }], 24 | // enforce consistent line breaks inside function parentheses 25 | // https://eslint.org/docs/rules/function-paren-newline 26 | 'function-paren-newline': ['error', 'multiline'], 27 | // Eslint can't deal with ESM modules currently. 28 | 'import/no-unresolved': ['error', { ignore: ['ava', 'chalk', 'got', 'purpleteam-logger'] }], 29 | // Used in order to supress the errors in the use of appending file extensions to the import statement for local modules 30 | // Which is required in order to upgrade from CJS to ESM. At time of upgrade file extensions have to be provided in import statements. 31 | 'import/extensions': ['error', { 'js': 'ignorePackages' }], 32 | 'no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true }], 33 | 'object-curly-newline': ['error', { multiline: true }], 34 | 'no-multiple-empty-lines': ['error', { max: 2, maxBOF: 0, maxEOF: 1 }] 35 | }, 36 | env: { node: true, 'es2021': true }, 37 | parserOptions: { sourceType: 'module', ecmaVersion: 'latest' } 38 | }; 39 | -------------------------------------------------------------------------------- /src/cmds/test.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { statSync } from 'fs'; 11 | import config from '../../config/config.js'; 12 | import api from '../presenter/apiDecoratingAdapter.js'; 13 | 14 | const flags = 'test'; 15 | const description = 'Launch purpleteam to attack your specified target.'; 16 | const setup = (sywac) => { 17 | sywac 18 | .option('-j, --job-file ', { 19 | type: 'file', 20 | desc: 'Build user supplied Job file. Must be a file conforming to the Job schema.', 21 | mustExist: true, 22 | defaultValue: (() => { 23 | const jobFileUri = config.get('job.fileUri'); 24 | const isFile = statSync(jobFileUri, { throwIfNoEntry: false })?.isFile(); 25 | return isFile ? jobFileUri : ''; // Only an empty string will cause proper error handling. 26 | })() 27 | }) 28 | .check((argv, context) => { 29 | if (argv._.length) context.cliMessage(`Unknown argument${argv._.length > 1 ? 's' : ''}: ${argv._.join(', ')}`); 30 | }); 31 | }; 32 | const run = async (parsedArgv, context) => { 33 | if (parsedArgv.j) { 34 | api.inject({}); 35 | const jobFileContents = await api.getJobFile(parsedArgv.j); 36 | // Todo: KC: In the future we could deserialise configFileContents, and possibly validate before sending to the Orchestrator. 37 | // https://github.com/danivek/json-api-serializer looks to be well maintained. 38 | // https://github.com/SeyZ/jsonapi-serializer looks to be a little neglected. 39 | 40 | await api.test(jobFileContents); 41 | 42 | // stream tester log Print each tester to a table row, and to log file 43 | // stream emissary log To artifacts dir 44 | } else { 45 | context.cliMessage('You must provide a valid Job file that exists on the local file system.'); 46 | } 47 | }; 48 | 49 | export { flags, description, setup, run }; 50 | -------------------------------------------------------------------------------- /src/cmds/about.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { get as getLogger } from 'purpleteam-logger'; 11 | 12 | import { createRequire } from 'module'; 13 | 14 | const require = createRequire(import.meta.url); 15 | const { name: projectName, version, description, homepage, author: { name, email } } = require('../../package'); 16 | 17 | const cUiLogger = getLogger(); 18 | 19 | const flags = 'about'; 20 | const desc = 'About purpleteam'; 21 | const setup = {}; 22 | const run = (/* parsedArgv, context */) => { 23 | cUiLogger.notice(`${projectName} ${version}`, { tags: ['screen'] }); 24 | cUiLogger.notice(description, { tags: ['screen'] }); 25 | cUiLogger.notice(`Homepage: ${homepage}`, { tags: ['screen'] }); 26 | cUiLogger.notice(`Created by ${name}<${email}>\n`, { tags: ['screen'] }); 27 | 28 | cUiLogger.emerg('This is what an emergency looks like.', { tags: ['emerg-tag'] }); 29 | cUiLogger.alert('This is what an alert looks like.', { tags: ['alert-tag'] }); 30 | cUiLogger.crit('This is what a critical event looks like.', { tags: ['crit-tag'] }); 31 | cUiLogger.error('This is what an error looks like.', { tags: ['error-tag'] }); 32 | cUiLogger.warning('This is what a warning looks like.', { tags: ['warning-tag'] }); 33 | cUiLogger.notice('This is what a notice looks like.', { tags: ['notice-tag'] }); 34 | cUiLogger.info('This is what an info event looks like.', { tags: ['info-tag'] }); 35 | cUiLogger.debug('This is what a debug event looks like.\n', { tags: ['debug-tag'] }); 36 | 37 | const manPage = `Usage details for the CLI can be found on the README: 38 | (https://github.com/purpleteam-labs/purpleteam#usage) 39 | 40 | Installation, configuration and running details for the CLI can also be 41 | found on the README: 42 | (https://github.com/purpleteam-labs/purpleteam#contents). 43 | 44 | Full documentation for the PurpleTeam SaaS can be found at: 45 | (https://purpleteam-labs.com/doc/)`; 46 | 47 | console.log(`${manPage}\n`); // eslint-disable-line no-console 48 | 49 | process.exit(0); 50 | }; 51 | 52 | export { flags, desc, setup, run }; 53 | -------------------------------------------------------------------------------- /src/view/blessedTypes/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import contrib from 'blessed-contrib'; 11 | 12 | import app from './app.js'; 13 | import server from './server.js'; 14 | import tls from './tls.js'; 15 | 16 | const testerViewTypes = [app, server, tls]; 17 | 18 | const testerPctCompleteType = { 19 | gridCoords: { 20 | row: 10.5, 21 | col: 0, 22 | rowSpan: 1.6, 23 | colSpan: 3 24 | }, 25 | type: contrib.donut, 26 | args: { 27 | label: 'Tester Complete (%)', 28 | radius: 8, 29 | arcWidth: 3, 30 | remainColor: 'black', 31 | yPadding: 4, 32 | data: testerViewTypes.map((tv) => ({ label: tv.testOpts.args.name, percent: 0, color: 'red' })), 33 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } }, 34 | border: { type: 'line', fg: '#00ff00' } 35 | } 36 | }; 37 | 38 | const statTableType = { 39 | gridCoords: { 40 | row: 10.5, 41 | col: 3, 42 | rowSpan: 1.6, 43 | colSpan: 4.0 44 | }, 45 | type: contrib.table, 46 | args: { 47 | label: 'Running Statistics', 48 | keys: true, 49 | vi: true, 50 | interactive: true, 51 | selectedFg: 'white', 52 | selectedBg: 'magenta', 53 | columnSpacing: 1, 54 | columnWidth: [10, 12, 12, 6, 12], 55 | fg: 'magenta', 56 | style: { fg: 'blue', bg: 'default', border: { fg: 'magenta', bg: 'default' } } 57 | }, 58 | headers: ['Testers', 'SessionId', 'Threshold', 'Bugs', 'Complete (%)'], 59 | seedData: testerViewTypes.map((tv) => [tv.testOpts.args.name, '-', 0, 0, 0]) 60 | }; 61 | 62 | const newBugsType = { 63 | gridCoords: { 64 | row: 10.5, 65 | col: 7.0, 66 | rowSpan: 1.6, 67 | colSpan: 1.3 68 | }, 69 | type: contrib.lcd, 70 | args: { 71 | label: 'New Alerts', 72 | segmentWidth: 0.06, 73 | segmentInterval: 0.1, 74 | strokeWidth: 0.9, 75 | elements: 3, 76 | display: '000', 77 | elementSpacing: 3, 78 | elementPadding: 3, 79 | color: 'blue', 80 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } } 81 | } 82 | }; 83 | 84 | const totalProgressType = { 85 | gridCoords: { 86 | row: 10.5, 87 | col: 8.3, 88 | rowSpan: 1.6, 89 | colSpan: 3.7 90 | }, 91 | type: contrib.gauge, 92 | args: { 93 | label: 'Total Tester Progress', 94 | percent: 0, 95 | stroke: 'blue', 96 | style: { fg: 'default', bg: 'default', border: { fg: 'magenta', bg: 'default' } } 97 | } 98 | }; 99 | 100 | 101 | export { 102 | testerViewTypes, // [app, server, tls], 103 | testerPctCompleteType, 104 | statTableType, 105 | newBugsType, 106 | totalProgressType 107 | }; 108 | -------------------------------------------------------------------------------- /testResources/jobs/job_4.0.0-alpha.3_cloud_single_appScanner: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "BrowserApp", 4 | "attributes": { 5 | "version": "4.0.0-alpha.3", 6 | "sutAuthentication": { 7 | "sitesTreeSutAuthenticationPopulationStrategy": "FormStandard", 8 | "emissaryAuthenticationStrategy": "FormStandard", 9 | "route": "/login", 10 | "usernameFieldLocater": "userName", 11 | "passwordFieldLocater": "password", 12 | "submit": "btn btn-danger", 13 | "expectedPageSourceSuccess": "Log Out" 14 | }, 15 | "sutIp": "nodegoat.sut.purpleteam-labs.com", 16 | "sutPort": 443, 17 | "sutProtocol": "https", 18 | "browser": "firefox", 19 | "loggedInIndicator": "

Found. Redirecting to \/dashboard<\/a><\/p>" 20 | }, 21 | "relationships": { 22 | "data": [{ 23 | "type": "tlsScanner", 24 | "id": "NA" 25 | }, 26 | { 27 | "type": "appScanner", 28 | "id": "adminUser" 29 | }] 30 | } 31 | }, 32 | "included": [ 33 | { 34 | "type": "tlsScanner", 35 | "id": "NA", 36 | "attributes": { 37 | "tlsScannerSeverity": "LOW", 38 | "alertThreshold": 3 39 | } 40 | }, 41 | { 42 | "type": "appScanner", 43 | "id": "adminUser", 44 | "attributes": { 45 | "sitesTreePopulationStrategy": "WebDriverStandard", 46 | "spiderStrategy": "Standard", 47 | "scannersStrategy": "BrowserAppStandard", 48 | "scanningStrategy": "BrowserAppStandard", 49 | "postScanningStrategy": "BrowserAppStandard", 50 | "reportingStrategy": "Standard", 51 | "username": "admin", 52 | "password": "Admin_123" 53 | }, 54 | "relationships": { 55 | "data": [{ 56 | "type": "route", 57 | "id": "/memos" 58 | }, 59 | { 60 | "type": "route", 61 | "id": "/profile" 62 | }] 63 | } 64 | }, 65 | { 66 | "type": "route", 67 | "id": "/profile", 68 | "attributes": { 69 | "attackFields": [ 70 | {"name": "firstName", "value": "PurpleJohn", "visible": true}, 71 | {"name": "lastName", "value": "PurpleDoe", "visible": true}, 72 | {"name": "ssn", "value": "PurpleSSN", "visible": true}, 73 | {"name": "dob", "value": "12235678", "visible": true}, 74 | {"name": "bankAcc", "value": "PurpleBankAcc", "visible": true}, 75 | {"name": "bankRouting", "value": "0198212#", "visible": true}, 76 | {"name": "address", "value": "PurpleAddress", "visible": true}, 77 | {"name": "website", "value": "https://purpleteam-labs.com", "visible": true}, 78 | {"name": "_csrf", "value": ""}, 79 | {"name": "submit", "value": ""} 80 | ], 81 | "method": "POST", 82 | "submit": "submit" 83 | } 84 | }, 85 | { 86 | "type": "route", 87 | "id": "/memos", 88 | "attributes": { 89 | "attackFields": [ 90 | {"name": "memo", "value": "PurpleMemo", "visible": true} 91 | ], 92 | "method": "POST", 93 | "submit": "btn btn-primary" 94 | } 95 | } 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import sywac from 'sywac'; 11 | import chalk from 'chalk'; 12 | import figlet from 'figlet'; 13 | import { get as getLogger } from 'purpleteam-logger'; 14 | import { fileURLToPath } from 'url'; 15 | import path, { dirname } from 'path'; 16 | 17 | import { createRequire } from 'module'; 18 | 19 | const require = createRequire(import.meta.url); 20 | const { name: pkgName, description: pkgDescription } = require('../package'); 21 | 22 | const epilogue = `For more information, find the manual at https://doc.purpleteam-labs.com 23 | Copyright (C) 2017-2021 BinaryMist Limited. All rights reserved. 24 | Use of this source code is governed by a license that can be found in the LICENSE.md file.`; 25 | 26 | const processCommands = async (options) => { // eslint-disable-line no-unused-vars 27 | const cUiLogger = getLogger(); 28 | cUiLogger.debug('Configuring sywac\n', { tags: ['cli'] }); 29 | const api = sywac; // eslint-disable-line no-unused-vars 30 | api.usage('Usage: $0 [command] [option(s)]'); 31 | await api.commandDirectory(path.join(dirname(fileURLToPath(import.meta.url)), 'cmds')); 32 | // This overrides the --help and --version and adds their aliases 33 | api.showHelpByDefault() 34 | .version('-v, --version', { desc: 'Show version number' }) 35 | .help('-h, --help') 36 | .preface(figlet.textSync(pkgName, 'Chunky'), chalk.bgHex('#9961ed')(pkgDescription)) 37 | .epilogue(epilogue) 38 | .style({ 39 | // usagePrefix: str => chalk.hex('#9961ed').bold(str), 40 | flags: (str) => chalk.bold(str), 41 | group: (str) => chalk.hex('#9961ed').bold(str), 42 | messages: (str) => chalk.hex('#FFA500').bold(str) 43 | }); 44 | 45 | // Introduced this function due to https://github.com/sywac/sywac/issues/25 46 | const shouldParseAndexit = (argv) => { 47 | const command = argv[2]; 48 | const arg = argv[3]; 49 | return argv.length < 3 50 | || command === 'about' 51 | || command === '-v' || command === '--version' 52 | || command === '-h' || command === '--help' 53 | || (command !== 'test' && command !== 'testplan' && command !== 'status') 54 | || (command === 'test' && !!arg) || (command === 'testplan' && !!arg) || (command === 'status' && !!arg); 55 | }; 56 | 57 | const cliArgs = shouldParseAndexit(options.argv) ? await api.parseAndExit() : await api.parse(); 58 | 59 | // api.parse needs a short-circut for errors. 60 | // Unexpected errors are included in cliArgs.errors 61 | typeof cliArgs.errors !== 'undefined' && cliArgs.errors.length && cUiLogger.error(cliArgs.errors) && process.exit(cliArgs.code); 62 | // a non-zero cliArgs.code value means at least one validation or unexpected error occurred 63 | // Doc: https://sywac.io/docs/async-parsing.html 64 | cliArgs.code > 0 && cUiLogger.warning(cliArgs.output) && process.exit(cliArgs.code); 65 | }; 66 | 67 | export default processCommands; 68 | -------------------------------------------------------------------------------- /src/schemas/job.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { diffJson } from 'diff'; 11 | import ajvErrors from 'ajv-errors'; 12 | import addFormats from 'ajv-formats'; 13 | import Ajv from 'ajv'; 14 | import Bourne from '@hapi/bourne'; 15 | import { init as initPtLogger } from 'purpleteam-logger'; 16 | import { init as initApiSchema, schema as aPiSchema } from './job.aPi.js'; 17 | import { init as initBrowserAppSchema, schema as browserAppSchema } from './job.browserApp.js'; 18 | 19 | const ajv = new Ajv({ allErrors: true, useDefaults: true, removeAdditional: true }); 20 | addFormats(ajv); 21 | ajvErrors(ajv); 22 | 23 | const internals = { 24 | recognisedJobTypes: ['Api', 'BrowserApp'], 25 | config: { 26 | sut: null, 27 | job: null 28 | }, 29 | log: null, 30 | validateApi: null, 31 | validateBrowserApp: null 32 | }; 33 | 34 | const convertJsonToObj = (value) => ((typeof value === 'string' || value instanceof String) ? Bourne.parse(value) : value); 35 | const deltaLogs = (initialConfig, possiblyMutatedConfig) => { 36 | const deltas = diffJson(convertJsonToObj(initialConfig), convertJsonToObj(possiblyMutatedConfig)); 37 | const additionLogs = deltas.filter((d) => d.added).map((cV) => `Added -> ${cV.value}`); 38 | const subtractionsLogs = deltas.filter((d) => d.removed).map((cV) => `Removed -> ${cV.value}`); 39 | return [...additionLogs, ...subtractionsLogs]; 40 | }; 41 | 42 | const logDeltaLogs = (logItems) => { 43 | const { log } = internals; 44 | logItems.length && log.notice(`During Job validation, the following changes were made to the job:\n${logItems}`, { tags: ['job'] }); 45 | }; 46 | 47 | // hapi route.options.validate.payload expects no return value if all good, but a value if mutation occurred. 48 | // eslint-disable-next-line consistent-return 49 | const validateJob = (jobString) => { 50 | const job = convertJsonToObj(jobString); 51 | const validate = internals[`validate${job.data.type}`]; 52 | if (!validate) throw new Error(`The Job type supplied is incorrect, please choose one of ${JSON.stringify(internals.recognisedJobTypes, null, 2)}.`); 53 | 54 | if (!validate(job)) { 55 | const validationError = new Error(JSON.stringify(validate.errors, null, 2)); 56 | validationError.name = 'ValidationError'; 57 | throw validationError; 58 | } 59 | 60 | const possiblyMutatedJobString = JSON.stringify(job, null, 2); 61 | const logItems = deltaLogs(jobString, possiblyMutatedJobString); 62 | logDeltaLogs(logItems); 63 | return logItems.length ? possiblyMutatedJobString : jobString; 64 | }; 65 | 66 | const init = ({ loggerConfig, sutConfig, jobConfig }) => { 67 | internals.config.sut = sutConfig; 68 | internals.config.job = jobConfig; 69 | internals.log = initPtLogger(loggerConfig); 70 | 71 | initApiSchema(internals.config); 72 | initBrowserAppSchema(internals.config); 73 | 74 | internals.validateApi = ajv.compile(aPiSchema); 75 | internals.validateBrowserApp = ajv.compile(browserAppSchema); 76 | 77 | return { validateJob }; 78 | }; 79 | 80 | export default init; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "ava": { 3 | "files": [ 4 | "test/**/*" 5 | ], 6 | "environmentVariables": { 7 | "NODE_ENV": "localtest" 8 | } 9 | }, 10 | "name": "purpleteam", 11 | "np": { 12 | "branch": "main" 13 | }, 14 | "version": "4.0.0-alpha.3", 15 | "description": "CLI for driving purpleteam -- security regression testing SaaS", 16 | "exports": "./src/index.js", 17 | "scripts": { 18 | "lint": "eslint .", 19 | "deps": "npm-check", 20 | "update:deps": "npm-check -u", 21 | "test": "c8 --reporter=lcov --reporter=text-summary ava", 22 | "test:nolint": "c8 --reporter=lcov --reporter=text-summary ava", 23 | "test:coverage": "c8 ava", 24 | "test:debug": "ava debug --break ./test/presenter/apiDecoratingAdapter_sSeAndLp.js", 25 | "test:nockDebug": "DEBUG=nock.* ava debug --break ./test/presenter/apiDecoratingAdapter.js", 26 | "pretest": "npm run lint", 27 | "debug": "node --inspect-brk=localhost:9230 ./bin/purpleteam.js", 28 | "start": "node ./bin/purpleteam.js", 29 | "prepare": "husky install" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/purpleteam-labs/purpleteam.git" 34 | }, 35 | "keywords": [ 36 | "agile", 37 | "application security", 38 | "bdd", 39 | "behaviour driven development", 40 | "blue team", 41 | "build pipeline", 42 | "build tool", 43 | "ci", 44 | "cli", 45 | "cloud", 46 | "cloud security", 47 | "continuous deployment", 48 | "continuous integration", 49 | "cybersecurity", 50 | "devops", 51 | "devsecops", 52 | "information security", 53 | "infosec", 54 | "owasp", 55 | "owasp zap", 56 | "penetration testing", 57 | "purpleteam", 58 | "red team", 59 | "security", 60 | "security regression testing", 61 | "security testing", 62 | "software security", 63 | "tool", 64 | "web api", 65 | "web application security", 66 | "web security", 67 | "zap" 68 | ], 69 | "author": { 70 | "name": "Kim Carter", 71 | "email": "services@binarymist.net" 72 | }, 73 | "license": "BSL", 74 | "homepage": "https://purpleteam-labs.com", 75 | "bugs": { 76 | "url": "https://github.com/purpleteam-labs/purpleteam/issues" 77 | }, 78 | "dependencies": { 79 | "@hapi/bourne": "^2.0.0", 80 | "ajv": "^8.10.0", 81 | "ajv-errors": "^3.0.0", 82 | "ajv-formats": "^2.1.1", 83 | "blessed": "^0.1.81", 84 | "blessed-contrib": "^4.10.1", 85 | "chalk": "^5.0.0", 86 | "convict": "^6.2.1", 87 | "convict-format-with-validator": "^6.2.0", 88 | "diff": "^5.0.0", 89 | "eventsource": "^1.1.0", 90 | "figlet": "^1.5.2", 91 | "got": "^12.0.1", 92 | "purpleteam-logger": "^2.0.0", 93 | "sywac": "git+https://git@github.com:binarymist/sywac.git#binarymist/esm" 94 | }, 95 | "devDependencies": { 96 | "ava": "^4.0.1", 97 | "c8": "^7.11.0", 98 | "eslint": "^8.9.0", 99 | "eslint-config-airbnb-base": "^15.0.0", 100 | "eslint-plugin-ava": "^13.2.0", 101 | "eslint-plugin-import": "^2.25.4", 102 | "husky": "^7.0.4", 103 | "mocksse": "^1.0.4", 104 | "nock": "^13.2.4", 105 | "npm-check": "^5.9.2", 106 | "sinon": "^13.0.1" 107 | }, 108 | "bin": { 109 | "purpleteam": "./bin/purpleteam.js" 110 | }, 111 | "type": "module", 112 | "files": [ 113 | "bin", 114 | "config/config.js", 115 | "config/config.example.cloud.json", 116 | "config/config.example.local.json", 117 | "src", 118 | "LEGALNOTICE.md", 119 | "licenses" 120 | ] 121 | } 122 | -------------------------------------------------------------------------------- /licenses/bsl.md: -------------------------------------------------------------------------------- 1 | PurpleTeam Business Source License 1.1 2 | 3 | License: BSL 1.1 4 | 5 | Licensor: BinaryMist Limited. 6 | 7 | Licensed Work: PurpleTeam The Licensed Work is © 2017-2022 BinaryMist Limited. 8 | 9 | Additional Use Grant: You may make use of the Licensed Work, 10 | provided that you may not use the Licensed Work for a Security Testing 11 | Service. A "Security Testing Service" is a commercial offering that 12 | allows third parties (other than your employees and 13 | individual contractors) to access the functionality of the Licensed Work 14 | by performing an action directly or indirectly that causes the Licensed Work to perform security scans of applications and/or APIs. 15 | For clarity, a Security Testing Service would include providers of: 16 | dynamic, static or interactive application security testing services, 17 | source control services, infrastructure services, such as cloud services, hosting services, and similarly 18 | situated third parties (including affiliates of such entities) that 19 | would offer the Licensed Work as is, or in connection with a broader service 20 | offering to customers or subscribers of such third party’s core 21 | services. 22 | 23 | Change Date: Change date is four years from release date. Please see [GitHub releases](https://github.com/purpleteam-labs/purpleteam/releases/) for exact dates. 24 | 25 | Change License: [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0), as published by the Apache Foundation. 26 | 27 | Business Source License 1.1 28 | 29 | Terms 30 | 31 | The Licensor hereby grants you the right to copy, modify, create 32 | derivative works, redistribute, and make non-production use of the 33 | Licensed Work. The Licensor may make an Additional Use Grant, above, 34 | permitting limited production use. 35 | 36 | Effective on the Change Date, or the fifth anniversary of the first 37 | publicly available distribution of a specific version of the Licensed 38 | Work under this License, whichever comes first, the Licensor hereby 39 | grants you rights under the terms of the Change License, and the rights 40 | granted in the paragraph above terminate. 41 | 42 | If your use of the Licensed Work does not comply with the requirements 43 | currently in effect as described in this License, you must purchase a 44 | commercial license from the Licensor, its affiliated entities, or 45 | authorized resellers, or you must refrain from using the Licensed Work. 46 | 47 | All copies of the original and modified Licensed Work, and derivative 48 | works of the Licensed Work, are subject to this License. This License 49 | applies separately for each version of the Licensed Work and the Change 50 | Date may vary for each version of the Licensed Work released by 51 | Licensor. 52 | 53 | You must conspicuously display this License on each original or modified 54 | copy of the Licensed Work. If you receive the Licensed Work in original 55 | or modified form from a third party, the terms and conditions set forth 56 | in this License apply to your use of that work. 57 | 58 | Any use of the Licensed Work in violation of this License will 59 | automatically terminate your rights under this License for the current 60 | and all other versions of the Licensed Work. 61 | 62 | This License does not grant you any right in any trademark or logo of 63 | Licensor or its affiliates (provided that you may use a trademark or 64 | logo of Licensor as expressly required by this License).TO THE EXTENT 65 | PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN “AS IS” 66 | BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS 67 | OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF 68 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND 69 | TITLE. -------------------------------------------------------------------------------- /testResources/jobs/job_4.0.0-alpha.3_local_missing_type_of_appScanner: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "BrowserApp", 4 | "attributes": { 5 | "version": "4.0.0-alpha.3", 6 | "sutAuthentication": { 7 | "sitesTreeSutAuthenticationPopulationStrategy": "FormStandard", 8 | "emissaryAuthenticationStrategy": "FormStandard", 9 | "route": "/login", 10 | "usernameFieldLocater": "userName", 11 | "passwordFieldLocater": "password", 12 | "submit": "btn btn-danger", 13 | "expectedPageSourceSuccess": "Log Out" 14 | }, 15 | "sutIp": "pt-sut-cont", 16 | "sutPort": 4000, 17 | "sutProtocol": "http", 18 | "browser": "chrome", 19 | "loggedInIndicator": "

Found. Redirecting to \/dashboard<\/a><\/p>" 20 | }, 21 | "relationships": { 22 | "data": [{ 23 | "type": "tlsScanner", 24 | "id": "NA" 25 | }, 26 | { 27 | "type": "appScanner", 28 | "id": "lowPrivUser" 29 | }, 30 | { 31 | "type": "appScanner", 32 | "id": "adminUser" 33 | }] 34 | } 35 | }, 36 | "included": [ 37 | { 38 | "type": "tlsScanner", 39 | "id": "NA", 40 | "attributes": { 41 | "tlsScannerSeverity": "LOW", 42 | "alertThreshold": 3 43 | } 44 | }, 45 | { 46 | "id": "lowPrivUser", 47 | "attributes": { 48 | "sitesTreePopulationStrategy": "WebDriverStandard", 49 | "spiderStrategy": "Standard", 50 | "scannersStrategy": "BrowserAppStandard", 51 | "scanningStrategy": "BrowserAppStandard", 52 | "postScanningStrategy": "BrowserAppStandard", 53 | "reportingStrategy": "Standard", 54 | "reports": { 55 | "templateThemes": [{ 56 | "name": "traditionalHtml" 57 | }, { 58 | "name": "traditionalHtmlPlusLight" 59 | }] 60 | }, 61 | "username": "user1", 62 | "password": "User1_123", 63 | "aScannerAttackStrength": "HIGH", 64 | "aScannerAlertThreshold": "LOW", 65 | "alertThreshold": 12 66 | }, 67 | "relationships": { 68 | "data": [{ 69 | "type": "route", 70 | "id": "/profile" 71 | }] 72 | } 73 | }, 74 | { 75 | "type": "appScanner", 76 | "id": "adminUser", 77 | "attributes": { 78 | "sitesTreePopulationStrategy": "WebDriverStandard", 79 | "spiderStrategy": "Standard", 80 | "scannersStrategy": "BrowserAppStandard", 81 | "scanningStrategy": "BrowserAppStandard", 82 | "postScanningStrategy": "BrowserAppStandard", 83 | "reportingStrategy": "Standard", 84 | "username": "admin", 85 | "password": "Admin_123" 86 | }, 87 | "relationships": { 88 | "data": [{ 89 | "type": "route", 90 | "id": "/memos" 91 | }, 92 | { 93 | "type": "route", 94 | "id": "/profile" 95 | }] 96 | } 97 | }, 98 | { 99 | "type": "route", 100 | "id": "/profile", 101 | "attributes": { 102 | "attackFields": [ 103 | {"name": "firstName", "value": "PurpleJohn", "visible": true}, 104 | {"name": "lastName", "value": "PurpleDoe", "visible": true}, 105 | {"name": "ssn", "value": "PurpleSSN", "visible": true}, 106 | {"name": "dob", "value": "12235678", "visible": true}, 107 | {"name": "bankAcc", "value": "PurpleBankAcc", "visible": true}, 108 | {"name": "bankRouting", "value": "0198212#", "visible": true}, 109 | {"name": "address", "value": "PurpleAddress", "visible": true}, 110 | {"name": "website", "value": "https://purpleteam-labs.com", "visible": true}, 111 | {"name": "_csrf", "value": ""}, 112 | {"name": "submit", "value": ""} 113 | ], 114 | "method": "POST", 115 | "submit": "submit" 116 | } 117 | }, 118 | { 119 | "type": "route", 120 | "id": "/memos", 121 | "attributes": { 122 | "attackFields": [ 123 | {"name": "memo", "value": "PurpleMemo", "visible": true} 124 | ], 125 | "method": "POST", 126 | "submit": "btn btn-primary" 127 | } 128 | } 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /testResources/jobs/job_4.0.0-alpha.3_local: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "BrowserApp", 4 | "attributes": { 5 | "version": "4.0.0-alpha.3", 6 | "sutAuthentication": { 7 | "sitesTreeSutAuthenticationPopulationStrategy": "FormStandard", 8 | "emissaryAuthenticationStrategy": "FormStandard", 9 | "route": "/login", 10 | "usernameFieldLocater": "userName", 11 | "passwordFieldLocater": "password", 12 | "submit": "btn btn-danger", 13 | "expectedPageSourceSuccess": "Log Out" 14 | }, 15 | "sutIp": "pt-sut-cont", 16 | "sutPort": 4000, 17 | "sutProtocol": "http", 18 | "browser": "chrome", 19 | "loggedInIndicator": "

Found. Redirecting to \/dashboard<\/a><\/p>" 20 | }, 21 | "relationships": { 22 | "data": [{ 23 | "type": "tlsScanner", 24 | "id": "NA" 25 | }, 26 | { 27 | "type": "appScanner", 28 | "id": "lowPrivUser" 29 | }, 30 | { 31 | "type": "appScanner", 32 | "id": "adminUser" 33 | }] 34 | } 35 | }, 36 | "included": [ 37 | { 38 | "type": "tlsScanner", 39 | "id": "NA", 40 | "attributes": { 41 | "tlsScannerSeverity": "LOW", 42 | "alertThreshold": 3 43 | } 44 | }, 45 | { 46 | "type": "appScanner", 47 | "id": "lowPrivUser", 48 | "attributes": { 49 | "sitesTreePopulationStrategy": "WebDriverStandard", 50 | "spiderStrategy": "Standard", 51 | "scannersStrategy": "BrowserAppStandard", 52 | "scanningStrategy": "BrowserAppStandard", 53 | "postScanningStrategy": "BrowserAppStandard", 54 | "reportingStrategy": "Standard", 55 | "reports": { 56 | "templateThemes": [{ 57 | "name": "traditionalHtml" 58 | }, { 59 | "name": "traditionalHtmlPlusLight" 60 | }] 61 | }, 62 | "username": "user1", 63 | "password": "User1_123", 64 | "aScannerAttackStrength": "HIGH", 65 | "aScannerAlertThreshold": "LOW", 66 | "alertThreshold": 12 67 | }, 68 | "relationships": { 69 | "data": [{ 70 | "type": "route", 71 | "id": "/profile" 72 | }] 73 | } 74 | }, 75 | { 76 | "type": "appScanner", 77 | "id": "adminUser", 78 | "attributes": { 79 | "sitesTreePopulationStrategy": "WebDriverStandard", 80 | "spiderStrategy": "Standard", 81 | "scannersStrategy": "BrowserAppStandard", 82 | "scanningStrategy": "BrowserAppStandard", 83 | "postScanningStrategy": "BrowserAppStandard", 84 | "reportingStrategy": "Standard", 85 | "username": "admin", 86 | "password": "Admin_123" 87 | }, 88 | "relationships": { 89 | "data": [{ 90 | "type": "route", 91 | "id": "/memos" 92 | }, 93 | { 94 | "type": "route", 95 | "id": "/profile" 96 | }] 97 | } 98 | }, 99 | { 100 | "type": "route", 101 | "id": "/profile", 102 | "attributes": { 103 | "attackFields": [ 104 | {"name": "firstName", "value": "PurpleJohn", "visible": true}, 105 | {"name": "lastName", "value": "PurpleDoe", "visible": true}, 106 | {"name": "ssn", "value": "PurpleSSN", "visible": true}, 107 | {"name": "dob", "value": "12235678", "visible": true}, 108 | {"name": "bankAcc", "value": "PurpleBankAcc", "visible": true}, 109 | {"name": "bankRouting", "value": "0198212#", "visible": true}, 110 | {"name": "address", "value": "PurpleAddress", "visible": true}, 111 | {"name": "website", "value": "https://purpleteam-labs.com", "visible": true}, 112 | {"name": "_csrf", "value": ""}, 113 | {"name": "submit", "value": ""} 114 | ], 115 | "method": "POST", 116 | "submit": "submit" 117 | } 118 | }, 119 | { 120 | "type": "route", 121 | "id": "/memos", 122 | "attributes": { 123 | "attackFields": [ 124 | {"name": "memo", "value": "PurpleMemo", "visible": true} 125 | ], 126 | "method": "POST", 127 | "submit": "btn btn-primary" 128 | } 129 | } 130 | ] 131 | } 132 | -------------------------------------------------------------------------------- /testResources/jobs/job_4.0.0-alpha.3_local_missing_comma: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "BrowserApp", 4 | "attributes": { 5 | "version": "4.0.0-alpha.3", 6 | "sutAuthentication": { 7 | "sitesTreeSutAuthenticationPopulationStrategy": "FormStandard", 8 | "emissaryAuthenticationStrategy": "FormStandard", 9 | "route": "/login", 10 | "usernameFieldLocater": "userName", 11 | "passwordFieldLocater": "password", 12 | "submit": "btn btn-danger", 13 | "expectedPageSourceSuccess": "Log Out" 14 | }, 15 | "sutIp": "pt-sut-cont", 16 | "sutPort": 4000, 17 | "sutProtocol": "http", 18 | "browser": "chrome", 19 | "loggedInIndicator": "

Found. Redirecting to \/dashboard<\/a><\/p>" 20 | }, 21 | "relationships": { 22 | "data": [{ 23 | "type": "tlsScanner", 24 | "id": "NA" 25 | }, 26 | { 27 | "type": "appScanner", 28 | "id": "lowPrivUser" 29 | }, 30 | { 31 | "type": "appScanner", 32 | "id": "adminUser" 33 | }] 34 | } 35 | }, 36 | "included": [ 37 | { 38 | "type": "tlsScanner", 39 | "id": "NA", 40 | "attributes": { 41 | "tlsScannerSeverity": "LOW", 42 | "alertThreshold": 3 43 | } 44 | }, 45 | { 46 | "type": "appScanner" 47 | "id": "lowPrivUser", 48 | "attributes": { 49 | "sitesTreePopulationStrategy": "WebDriverStandard", 50 | "spiderStrategy": "Standard", 51 | "scannersStrategy": "BrowserAppStandard", 52 | "scanningStrategy": "BrowserAppStandard", 53 | "postScanningStrategy": "BrowserAppStandard", 54 | "reportingStrategy": "Standard", 55 | "reports": { 56 | "templateThemes": [{ 57 | "name": "traditionalHtml" 58 | }, { 59 | "name": "traditionalHtmlPlusLight" 60 | }] 61 | }, 62 | "username": "user1", 63 | "password": "User1_123", 64 | "aScannerAttackStrength": "HIGH", 65 | "aScannerAlertThreshold": "LOW", 66 | "alertThreshold": 12 67 | }, 68 | "relationships": { 69 | "data": [{ 70 | "type": "route", 71 | "id": "/profile" 72 | }] 73 | } 74 | }, 75 | { 76 | "type": "appScanner", 77 | "id": "adminUser", 78 | "attributes": { 79 | "sitesTreePopulationStrategy": "WebDriverStandard", 80 | "spiderStrategy": "Standard", 81 | "scannersStrategy": "BrowserAppStandard", 82 | "scanningStrategy": "BrowserAppStandard", 83 | "postScanningStrategy": "BrowserAppStandard", 84 | "reportingStrategy": "Standard", 85 | "username": "admin", 86 | "password": "Admin_123" 87 | }, 88 | "relationships": { 89 | "data": [{ 90 | "type": "route", 91 | "id": "/memos" 92 | }, 93 | { 94 | "type": "route", 95 | "id": "/profile" 96 | }] 97 | } 98 | }, 99 | { 100 | "type": "route", 101 | "id": "/profile", 102 | "attributes": { 103 | "attackFields": [ 104 | {"name": "firstName", "value": "PurpleJohn", "visible": true}, 105 | {"name": "lastName", "value": "PurpleDoe", "visible": true}, 106 | {"name": "ssn", "value": "PurpleSSN", "visible": true}, 107 | {"name": "dob", "value": "12235678", "visible": true}, 108 | {"name": "bankAcc", "value": "PurpleBankAcc", "visible": true}, 109 | {"name": "bankRouting", "value": "0198212#", "visible": true}, 110 | {"name": "address", "value": "PurpleAddress", "visible": true}, 111 | {"name": "website", "value": "https://purpleteam-labs.com", "visible": true}, 112 | {"name": "_csrf", "value": ""}, 113 | {"name": "submit", "value": ""} 114 | ], 115 | "method": "POST", 116 | "submit": "submit" 117 | } 118 | }, 119 | { 120 | "type": "route", 121 | "id": "/memos", 122 | "attributes": { 123 | "attackFields": [ 124 | {"name": "memo", "value": "PurpleMemo", "visible": true} 125 | ], 126 | "method": "POST", 127 | "submit": "btn btn-primary" 128 | } 129 | } 130 | ] 131 | } 132 | -------------------------------------------------------------------------------- /testResources/jobs/job_4.0.0-alpha.3_cloud: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "BrowserApp", 4 | "attributes": { 5 | "version": "4.0.0-alpha.3", 6 | "sutAuthentication": { 7 | "sitesTreeSutAuthenticationPopulationStrategy": "FormStandard", 8 | "emissaryAuthenticationStrategy": "FormStandard", 9 | "route": "/login", 10 | "usernameFieldLocater": "userName", 11 | "passwordFieldLocater": "password", 12 | "submit": "btn btn-danger", 13 | "expectedPageSourceSuccess": "Log Out" 14 | }, 15 | "sutIp": "nodegoat.sut.purpleteam-labs.com", 16 | "sutPort": 443, 17 | "sutProtocol": "https", 18 | "browser": "firefox", 19 | "loggedInIndicator": "

Found. Redirecting to \/dashboard<\/a><\/p>" 20 | }, 21 | "relationships": { 22 | "data": [{ 23 | "type": "tlsScanner", 24 | "id": "NA" 25 | }, 26 | { 27 | "type": "appScanner", 28 | "id": "lowPrivUser" 29 | }, 30 | { 31 | "type": "appScanner", 32 | "id": "adminUser" 33 | }] 34 | } 35 | }, 36 | "included": [ 37 | { 38 | "type": "tlsScanner", 39 | "id": "NA", 40 | "attributes": { 41 | "tlsScannerSeverity": "LOW", 42 | "alertThreshold": 3 43 | } 44 | }, 45 | { 46 | "type": "appScanner", 47 | "id": "lowPrivUser", 48 | "attributes": { 49 | "sitesTreePopulationStrategy": "WebDriverStandard", 50 | "spiderStrategy": "Standard", 51 | "scannersStrategy": "BrowserAppStandard", 52 | "scanningStrategy": "BrowserAppStandard", 53 | "postScanningStrategy": "BrowserAppStandard", 54 | "reportingStrategy": "Standard", 55 | "reports": { 56 | "templateThemes": [{ 57 | "name": "traditionalHtml" 58 | }, { 59 | "name": "traditionalHtmlPlusLight" 60 | }] 61 | }, 62 | "username": "user1", 63 | "password": "User1_123", 64 | "aScannerAttackStrength": "HIGH", 65 | "aScannerAlertThreshold": "LOW", 66 | "alertThreshold": 12 67 | }, 68 | "relationships": { 69 | "data": [{ 70 | "type": "route", 71 | "id": "/profile" 72 | }] 73 | } 74 | }, 75 | { 76 | "type": "appScanner", 77 | "id": "adminUser", 78 | "attributes": { 79 | "sitesTreePopulationStrategy": "WebDriverStandard", 80 | "spiderStrategy": "Standard", 81 | "scannersStrategy": "BrowserAppStandard", 82 | "scanningStrategy": "BrowserAppStandard", 83 | "postScanningStrategy": "BrowserAppStandard", 84 | "reportingStrategy": "Standard", 85 | "username": "admin", 86 | "password": "Admin_123" 87 | }, 88 | "relationships": { 89 | "data": [{ 90 | "type": "route", 91 | "id": "/memos" 92 | }, 93 | { 94 | "type": "route", 95 | "id": "/profile" 96 | }] 97 | } 98 | }, 99 | { 100 | "type": "route", 101 | "id": "/profile", 102 | "attributes": { 103 | "attackFields": [ 104 | {"name": "firstName", "value": "PurpleJohn", "visible": true}, 105 | {"name": "lastName", "value": "PurpleDoe", "visible": true}, 106 | {"name": "ssn", "value": "PurpleSSN", "visible": true}, 107 | {"name": "dob", "value": "12235678", "visible": true}, 108 | {"name": "bankAcc", "value": "PurpleBankAcc", "visible": true}, 109 | {"name": "bankRouting", "value": "0198212#", "visible": true}, 110 | {"name": "address", "value": "PurpleAddress", "visible": true}, 111 | {"name": "website", "value": "https://purpleteam-labs.com", "visible": true}, 112 | {"name": "_csrf", "value": ""}, 113 | {"name": "submit", "value": ""} 114 | ], 115 | "method": "POST", 116 | "submit": "submit" 117 | } 118 | }, 119 | { 120 | "type": "route", 121 | "id": "/memos", 122 | "attributes": { 123 | "attackFields": [ 124 | {"name": "memo", "value": "PurpleMemo", "visible": true} 125 | ], 126 | "method": "POST", 127 | "submit": "btn btn-primary" 128 | } 129 | } 130 | ] 131 | } 132 | -------------------------------------------------------------------------------- /src/models/model.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import EventEmitter from 'events'; 11 | import Bourne from '@hapi/bourne'; 12 | import config from '../../config/config.js'; 13 | import initJobSchema from '../schemas/job.js'; 14 | 15 | const sutConfig = config.getSchema()._cvtProperties.sut; // eslint-disable-line no-underscore-dangle 16 | const jobSchemaOpts = { 17 | loggerConfig: config.get('loggers.cUi'), 18 | sutConfig: { 19 | browserOptions: sutConfig._cvtProperties.browser.format, // eslint-disable-line no-underscore-dangle 20 | defaultBrowser: sutConfig._cvtProperties.browser.default // eslint-disable-line no-underscore-dangle 21 | }, 22 | jobConfig: config.get('job') 23 | }; 24 | const { validateJob } = initJobSchema(jobSchemaOpts); 25 | 26 | const events = { testerProgress: [], testerPctComplete: [], testerBugCount: [] }; 27 | 28 | 29 | class Model extends EventEmitter { 30 | constructor(jobFileContents) { 31 | super(); 32 | const validatedJobFileContent = validateJob(jobFileContents); 33 | this.job = Bourne.parse(validatedJobFileContent); 34 | this.eventNames.forEach((e) => this.initTesterMessages(e)); 35 | } 36 | 37 | // eslint-disable-next-line class-methods-use-this 38 | get eventNames() { 39 | return Object.keys(events); 40 | } 41 | 42 | // eslint-disable-next-line class-methods-use-this 43 | initTesterMessages(eventName) { 44 | const appScannerResourceObjectsFromJob = this.job.included.filter((resourceObj) => resourceObj.type === 'appScanner'); 45 | const appScannerResourceObjects = appScannerResourceObjectsFromJob.length >= config.get('testers.server.minNum') && appScannerResourceObjectsFromJob.length <= config.get('testers.app.maxNum') 46 | ? appScannerResourceObjectsFromJob 47 | : [{ id: 'NA' }]; // If Build User supplied an incorrect number of appScanner resource objects. 48 | events[eventName] = appScannerResourceObjects.map((aSRO) => ({ testerType: 'app', sessionId: aSRO.id, messages: [] })); 49 | events[eventName].push({ testerType: 'server', sessionId: 'NA', messages: [] }); 50 | events[eventName].push({ testerType: 'tls', sessionId: 'NA', messages: [] }); 51 | } 52 | 53 | // eslint-disable-next-line class-methods-use-this 54 | get testerNamesAndSessions() { 55 | return events.testerProgress.map((tNAS) => ({ testerType: tNAS.testerType, sessionId: tNAS.sessionId })); 56 | } 57 | 58 | 59 | propagateTesterMessage(msgOpts) { 60 | const defaultEvent = 'testerProgress'; 61 | const eventType = msgOpts.event || defaultEvent; 62 | if (this.eventNames.includes(eventType)) { 63 | const msgEvents = events[eventType].find((record) => record.testerType === msgOpts.testerType && record.sessionId === msgOpts.sessionId); 64 | msgEvents.messages.push(msgOpts.message); 65 | // (push/shift) Setup as placeholder for proper queue if needed. 66 | this.emit(msgOpts.event || defaultEvent, msgEvents.testerType, msgEvents.sessionId, msgEvents.messages.shift()); 67 | } else { 68 | throw new Error(`Invalid event of type "${eventType}" was received. The known events are [${this.eventNames}]`); 69 | } 70 | } 71 | 72 | // eslint-disable-next-line class-methods-use-this 73 | testerSessions() { 74 | const testerSessions = []; 75 | 76 | const appScannerResourceObjects = this.job.included.filter((resourceObj) => resourceObj.type === 'appScanner'); 77 | const serverScannerResourceObjects = this.job.included.filter((resourceObj) => resourceObj.type === 'serverScanner'); 78 | const tlsScannerResourceObjects = this.job.included.filter((resourceObj) => resourceObj.type === 'tlsScanner'); 79 | 80 | testerSessions.push(...(appScannerResourceObjects.length >= config.get('testers.app.minNum') && appScannerResourceObjects.length <= config.get('testers.app.maxNum') 81 | ? appScannerResourceObjects.map((aSRO) => ( 82 | { testerType: 'app', sessionId: aSRO.id, threshold: aSRO.attributes.alertThreshold || 0 } 83 | )) 84 | : [{ testerType: 'app', sessionId: 'NA', threshold: 0 }] 85 | )); 86 | testerSessions.push(...(serverScannerResourceObjects.length >= config.get('testers.server.minNum') && serverScannerResourceObjects.length <= config.get('testers.server.maxNum') 87 | ? serverScannerResourceObjects.map((sSRO) => ( 88 | { testerType: 'server', sessionId: sSRO.id, threshold: sSRO.attributes.alertThreshold || 0 } 89 | )) 90 | : [{ testerType: 'server', sessionId: 'NA', threshold: 0 }] 91 | )); 92 | testerSessions.push(...(tlsScannerResourceObjects.length >= config.get('testers.tls.minNum') && tlsScannerResourceObjects.length <= config.get('testers.tls.maxNum') 93 | ? tlsScannerResourceObjects.map((tSRO) => ( 94 | { testerType: 'tls', sessionId: tSRO.id, threshold: tSRO.attributes.alertThreshold || 0 } 95 | )) 96 | : [{ testerType: 'tls', sessionId: 'NA', threshold: 0 }] 97 | )); 98 | 99 | return testerSessions; 100 | } 101 | } 102 | 103 | 104 | export default Model; 105 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions from community are key to making purpleteam a high quality comprehensive resource. Lets make purpleteam awesome together! 4 | 5 | ## Ways to Contribute 6 | 7 | Depending on your preference, you can contribute in various ways. 8 | 9 | Often the best approach is to discuss what you're thinking of or planning first. You can do this by: 10 | 11 | * Starting a [Github Discussion](https://github.com/purpleteam-labs/purpleteam/discussions) in the purpleteam repository 12 | * Having a chat about it on one of the purpleteam-labs [Slack](https://purpleteam-labs.com/community/) channels. If you need an invite DM us on Twitter [@purpleteamlabs](https://twitter.com/purpleteamlabs), or reach out via the PurpleTeam-Lambs [contact page](https://purpleteam-labs.com/contact/) or BinaryMist website [contact page](https://binarymist.io/#contact) 13 | 14 | The purpleteam-labs [Backlog/Project board](https://github.com/purpleteam-labs/purpleteam/projects/2) lists work items for all of the purpleteam-labs projects waiting for someone to start work on. These can be a good place to start. 15 | 16 | You can also [open an issue](https://github.com/purpleteam-labs/purpleteam/issues/new/choose), or send a Pull Request (PR). 17 | 18 | If you are OK with creating a PR that may never be accepted, then go ahead and just create the PR and use the description to provide context and motivation, as you would for an issue. 19 | 20 | ## Coding 21 | 22 | ### Production Code 23 | 24 | * If you modify existing code that has tests for it, please modify the tests first to help you drive your development, include the tests with the PR and make sure they are passing before submitting 25 | * If you modify existing code that does not yet have tests, please create tests for the modified code, include them with the PR and make sure they are passing before submitting 26 | * If you are creating new code, please drive that creation with tests and include the tests in your PR 27 | * If the code you create requires modification or additional documentation to be added to the official docs, please include the modified or new documentation with your PR 28 | 29 | ### Experimental Code 30 | 31 | If you are writing experimental or example code that you don't expect to be included in the purpleteam production code base the [Production Code](#production-code) guidelines can be relaxed. 32 | 33 | ### Guidelines for Pull Request (PR) submission and processing: 34 | 35 | Daniel Stenberg's post on "[This Is How I Git](https://daniel.haxx.se/blog/2020/11/09/this-is-how-i-git/)" provides a good example of what we expect as a git work-flow. 36 | 37 | #### Style/Linting JavaScript 38 | 39 | The output of eslint needs to be error free before submitting a PR. This should take place automatically as part of the git `pre-commit` on most purpleteam-lab projects. 40 | For example: `git commit` will run the `pre-commit` hook, which runs the `test` script before allowing commit. The `test` script has a `pretest` script which runs `npm run lint` 41 | 42 | We use npm scripts for most build tasks. 43 | 44 | #### What should you, the author of a Pull Request (PR) expect from us (purpleteam-labs Team)? 45 | 46 | * How much time (maximum) until the first feedback? 1 week 47 | * And following iterations? 1 week 48 | * This is a deadline we should normally be able to hit. If it's been more than a week and you haven't heard then please feel free to add a comment to your PR and @ mention the team (@purpleteam-labs/team-purpleteam-labs) 49 | 50 | #### What do we (purpleteam-labs Team) expect from you? 51 | 52 | * Choose the granularity of your commits consciously and squash commits that represent multiple edits or corrections of the same logical change. "Atomic commits" (logical changes to be in a single commit). Please don't group disjointed changes into a single commit/PR 53 | * Descriptive commits (subject and message). Please follow "[The seven rules of a great Git commit message](https://chris.beams.io/posts/git-commit/#seven-rules)" 54 | * Discussion about the changes: 55 | * If there is a prior issue, reference the GitHub issue number in the description of the PR 56 | * Should be done in/on the PR or via the [Github Discussions](https://github.com/purpleteam-labs/purpleteam/discussions) and a link to that Discussion thread added to the PR comments. (i.e.: Shared information is important, if something happens via any of our [communication channels](https://purpleteam-labs.com/community/) please ensure a summary makes it to the PR) 57 | * Discussion will be kept in the PR unless off topic 58 | * If accepted, your contribution may be heavily modified as needed prior to merging. You will likely retain author attribution for your git commits granted that the bulk of your changes remain intact. You may also be asked to rework the submission 59 | * If asked to make corrections, simply push the changes against the same branch, and your pull request will be updated. In other words, you do not need to create a new pull request when asked to make changes 60 | * No merge commits. Please, rebase 61 | * Rebase if the branch has conflicts 62 | * Please, leave a comment after force pushing changes. It allows everyone to know that new changes are available 63 | * How much time will a PR be left open? 64 | * This isn't static, one or more members of the purpleteam-labs Team will reach out (using @ mentions in PR comments) once or twice in order to get things back on track. If no input is received after a month or two then the PR will be closed. Total stale time will likely be 2 to 3 months 65 | * Close with a message such as: "The PR was closed because of lack of activity (as per CONTRIBUTING guidelines)". Labelled as "Stale" 66 | * If the contribution is deemed important or still valuable the code may be: 67 | * Manually merged (if possible) 68 | * Retrieved by another member of the team, fixed up and resubmitted. In which case the commit message (PR message) should contain a reference to the original submission 69 | 70 | #### Approval process: 71 | 72 | * All PRs must be approved by a minimum of two members (if they exist) of the purpleteam-labs Core Team (other than the author) and anyone who is flagged as a reviewer on the PR 73 | * The PR author can optionally specify any reviewer they would like to review their PR and any member of the Core Team can add themselves as a reviewer. This will effectively prevent the PR from being merged until they approve it 74 | * Any member of the Core Team can merge a PR as long as the above conditions are met 75 | * When merging a PR make sure to close any related issues that have no outstanding PRs referencing them. This [can be done](https://github.blog/2013-03-18-closing-issues-across-repositories/) from the PR within the commit message 76 | * Reviews by people outside of the Core Team are still appreciated :) 77 | Helping to review PR is another great way to contribute. Your feedback can help to shape the implementation of new features. When reviewing PRs, however, please refrain from approving or rejecting a PR unless you are a purpleteam-labs Core Team member 78 | 79 | ## Reference Docs 80 | 81 | The reference documentation is on the [purpleteam-labs.com](https://purpleteam-labs.com/doc/) website. 82 | 83 | -------------------------------------------------------------------------------- /test/models/model.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { readFile } from 'fs/promises'; 11 | import test from 'ava'; 12 | 13 | import config from '../../config/config.js'; 14 | import Model from '../../src/models/model.js'; 15 | 16 | const jobFilePath = config.get('job.fileUri'); 17 | const jobFileContents = await readFile(jobFilePath, { encoding: 'utf8' }); 18 | 19 | test.beforeEach((t) => { 20 | t.context.model = new Model(jobFileContents); // eslint-disable-line no-param-reassign 21 | }); 22 | 23 | test('eventNames - should return valid event names', (t) => { 24 | const { model } = t.context; 25 | const { eventNames } = model; 26 | 27 | t.deepEqual(eventNames, ['testerProgress', 'testerPctComplete', 'testerBugCount']); 28 | }); 29 | 30 | test('testerSessions - should return valid testerSessions', (t) => { 31 | const { model } = t.context; 32 | // expectedTesterSessions also used in the presenter tests 33 | const expectedTesterSessions = [ 34 | { testerType: 'app', sessionId: 'lowPrivUser', threshold: 12 }, 35 | { testerType: 'app', sessionId: 'adminUser', threshold: 0 }, 36 | { testerType: 'server', sessionId: 'NA', threshold: 0 }, 37 | { testerType: 'tls', sessionId: 'NA', threshold: 3 } 38 | ]; 39 | 40 | const testerSessions = model.testerSessions(); 41 | t.deepEqual(testerSessions, expectedTesterSessions); 42 | }); 43 | 44 | test('testerNamesAndSessions - should return valid testerNamesAndSessions', (t) => { 45 | const { model: { testerNamesAndSessions } } = t.context; 46 | 47 | const expectedTesterNamesAndSessions = [ 48 | { testerType: 'app', sessionId: 'lowPrivUser' }, 49 | { testerType: 'app', sessionId: 'adminUser' }, 50 | { testerType: 'server', sessionId: 'NA' }, 51 | { testerType: 'tls', sessionId: 'NA' } 52 | ]; 53 | 54 | t.deepEqual(testerNamesAndSessions, expectedTesterNamesAndSessions); 55 | }); 56 | 57 | test('propagateTesterMessage - with testerType app, sessionId lowPrivUser - should fire testerProgress event, if event not specified, once only', (t) => { 58 | const { model } = t.context; 59 | let eventHandlerInvocationCount = 0; 60 | const appTesterLowPrivUserSessionIdMessage = { 61 | testerType: 'app', 62 | sessionId: 'lowPrivUser', 63 | message: 'App tests are now running.' 64 | }; 65 | 66 | model.on('testerProgress', (testerType, sessionId, message) => { 67 | t.deepEqual(testerType, appTesterLowPrivUserSessionIdMessage.testerType); 68 | t.deepEqual(sessionId, appTesterLowPrivUserSessionIdMessage.sessionId); 69 | t.deepEqual(message, appTesterLowPrivUserSessionIdMessage.message); 70 | eventHandlerInvocationCount += 1; 71 | }); 72 | model.on('testerPctComplete', () => { 73 | t.fail('testerPctComplete handler should not be invoked'); 74 | }); 75 | model.on('testerBugCount', () => { 76 | t.fail('testerBugCount handler should not be invoked'); 77 | }); 78 | 79 | model.propagateTesterMessage(appTesterLowPrivUserSessionIdMessage); 80 | 81 | t.is(eventHandlerInvocationCount, 1); 82 | }); 83 | 84 | test('propagateTesterMessage - with testerType app, sessionId lowPrivUser - should fire testerProgress event, if event specified, once only', (t) => { 85 | const { model } = t.context; 86 | let eventHandlerInvocationCount = 0; 87 | const appTesterLowPrivUserSessionIdMessage = { 88 | testerType: 'app', 89 | sessionId: 'lowPrivUser', 90 | message: 'App tests are now running.', 91 | event: 'testerProgress' 92 | }; 93 | 94 | model.on('testerProgress', (testerType, sessionId, message) => { 95 | t.deepEqual(testerType, appTesterLowPrivUserSessionIdMessage.testerType); 96 | t.deepEqual(sessionId, appTesterLowPrivUserSessionIdMessage.sessionId); 97 | t.deepEqual(message, appTesterLowPrivUserSessionIdMessage.message); 98 | eventHandlerInvocationCount += 1; 99 | }); 100 | model.on('testerPctComplete', () => { 101 | t.fail('testerPctComplete handler should not be invoked'); 102 | }); 103 | model.on('testerBugCount', () => { 104 | t.fail('testerBugCount handler should not be invoked'); 105 | }); 106 | 107 | model.propagateTesterMessage(appTesterLowPrivUserSessionIdMessage); 108 | 109 | t.is(eventHandlerInvocationCount, 1); 110 | }); 111 | 112 | test('propagateTesterMessage - with testerType app, sessionId lowPrivUser - should fire testerPctComplete event, once only', (t) => { 113 | const { model } = t.context; 114 | let eventHandlerInvocationCount = 0; 115 | const appTesterLowPrivUserSessionIdMessage = { 116 | testerType: 'app', 117 | sessionId: 'lowPrivUser', 118 | message: 20, 119 | event: 'testerPctComplete' 120 | }; 121 | 122 | model.on('testerProgress', () => { 123 | t.fail('testerProgress handler should not be invoked'); 124 | }); 125 | model.on('testerPctComplete', (testerType, sessionId, message) => { 126 | t.deepEqual(testerType, appTesterLowPrivUserSessionIdMessage.testerType); 127 | t.deepEqual(sessionId, appTesterLowPrivUserSessionIdMessage.sessionId); 128 | t.deepEqual(message, appTesterLowPrivUserSessionIdMessage.message); 129 | eventHandlerInvocationCount += 1; 130 | }); 131 | model.on('testerBugCount', () => { 132 | t.fail('testerBugCount handler should not be invoked'); 133 | }); 134 | 135 | model.propagateTesterMessage(appTesterLowPrivUserSessionIdMessage); 136 | 137 | t.is(eventHandlerInvocationCount, 1); 138 | }); 139 | 140 | test('propagateTesterMessage - with testerType app, sessionId lowPrivUser - should fire testerBugCount event, once only', (t) => { 141 | const { model } = t.context; 142 | let eventHandlerInvocationCount = 0; 143 | const appTesterLowPrivUserSessionIdMessage = { 144 | testerType: 'app', 145 | sessionId: 'lowPrivUser', 146 | message: 4, 147 | event: 'testerBugCount' 148 | }; 149 | 150 | model.on('testerProgress', () => { 151 | t.fail('testerProgress handler should not be invoked'); 152 | }); 153 | model.on('testerPctComplete', () => { 154 | t.fail('testerPctComplete handler should not be invoked'); 155 | }); 156 | model.on('testerBugCount', (testerType, sessionId, message) => { 157 | t.deepEqual(testerType, appTesterLowPrivUserSessionIdMessage.testerType); 158 | t.deepEqual(sessionId, appTesterLowPrivUserSessionIdMessage.sessionId); 159 | t.deepEqual(message, appTesterLowPrivUserSessionIdMessage.message); 160 | eventHandlerInvocationCount += 1; 161 | }); 162 | 163 | model.propagateTesterMessage(appTesterLowPrivUserSessionIdMessage); 164 | 165 | t.is(eventHandlerInvocationCount, 1); 166 | }); 167 | 168 | test('propagateTesterMessage - with testerType app, sessionId lowPrivUser - with unknown event - should not fire any event', (t) => { 169 | const { model } = t.context; 170 | const expectedError = `Invalid event of type "unknownEvent" was received. The known events are [${model.eventNames}]`; 171 | const appTesterLowPrivUserSessionIdMessage = { 172 | testerType: 'app', 173 | sessionId: 'lowPrivUser', 174 | message: 4, 175 | event: 'unknownEvent' 176 | }; 177 | 178 | model.on('testerProgress', () => { t.fail('testerProgress handler should not be invoked'); }); 179 | model.on('testerPctComplete', () => { t.fail('testerPctComplete handler should not be invoked'); }); 180 | model.on('testerBugCount', () => { t.fail('testerBugCount handler should not be invoked'); }); 181 | 182 | t.throws(() => { 183 | model.propagateTesterMessage(appTesterLowPrivUserSessionIdMessage); 184 | }, { instanceOf: Error, message: expectedError }); 185 | }); 186 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import convict from 'convict'; 11 | import convictFormatWithValidator from 'convict-format-with-validator'; 12 | import { fileURLToPath } from 'url'; 13 | import path, { dirname } from 'path'; 14 | 15 | convict.addFormats(convictFormatWithValidator); 16 | 17 | const schema = { 18 | env: { 19 | doc: 'The application environment.', 20 | format: ['cloud', 'local', 'cloudtest', 'localtest'], 21 | default: 'cloud', 22 | env: 'NODE_ENV' 23 | }, 24 | loggers: { 25 | cUi: { 26 | level: { 27 | doc: 'Default logger to write all log events with this level and below. Syslog levels used: https://github.com/winstonjs/winston#logging-levels', 28 | format: ['emerg', 'alert', 'crit', 'error', 'warning', 'notice', 'info', 'debug'], 29 | default: 'notice' 30 | }, 31 | transports: { 32 | doc: 'Transports to send generic logging events to.', 33 | format: Array, 34 | default: ['SignaleTransport'] 35 | } 36 | }, 37 | testerProgress: { 38 | transports: { 39 | doc: 'Transports to send testerProgress events to.', 40 | format: Array, 41 | default: ['File'] 42 | }, 43 | dirname: { 44 | doc: 'Location of testerProgress logs generated by the purpleteam CLI.', 45 | format: String, 46 | default: `${process.cwd()}/logs/` 47 | } 48 | }, 49 | testPlan: { 50 | transports: { 51 | doc: 'Transports to send testPlans to.', 52 | format: Array, 53 | default: ['File'] 54 | }, 55 | dirname: { 56 | doc: 'Location of testPlan logs generated by the purpleteam CLI based on the test plans (for each tester) to be executed in the purpleteam API', 57 | format: String, 58 | default: `${process.cwd()}/logs/` 59 | } 60 | } 61 | }, 62 | sut: { 63 | browser: { 64 | doc: 'The type of browser to run tests through.', 65 | format: ['chrome', 'firefox'], 66 | default: 'chrome' 67 | } 68 | }, 69 | testers: { 70 | app: { 71 | minNum: { 72 | doc: 'The minimum number of supported App Testers.', 73 | format: Number, 74 | default: 1 75 | }, 76 | maxNum: { 77 | doc: 'The maximum number of supported App Testers.', 78 | format: Number, 79 | default: 12 80 | } 81 | }, 82 | server: { 83 | minNum: { 84 | doc: 'The minimum number of supported Server Testers.', 85 | format: Number, 86 | default: 1 87 | }, 88 | maxNum: { 89 | doc: 'The maximum number of supported Server Testers.', 90 | format: Number, 91 | default: 1 92 | } 93 | }, 94 | tls: { 95 | minNum: { 96 | doc: 'The minimum number of supported Tls Testers.', 97 | format: Number, 98 | default: 1 99 | }, 100 | maxNum: { 101 | doc: 'The maximum number of supported Tls Testers.', 102 | format: Number, 103 | default: 1 104 | } 105 | } 106 | }, 107 | purpleteamAuth: { 108 | protocol: { 109 | doc: 'The protocol of the purpleteam authorisation server.', 110 | format: ['https', 'http'], 111 | default: 'https' 112 | }, 113 | host: { 114 | doc: 'The IP address or hostname of the purpleteam authorisation server.', 115 | format: String, 116 | default: 'purpleteam' 117 | }, 118 | appClientId: { 119 | doc: 'The App Client Id used to authenticate to the purpleteam authorisation server.', 120 | format: String, 121 | default: 'customer to set if using purpleteam in the cloud', 122 | env: 'PURPLETEAM_APP_CLIENT_ID', 123 | sensitive: true 124 | }, 125 | appClientSecret: { 126 | doc: 'The App Client Secret used to authenticate to the purpleteam authorisation server.', 127 | format: String, 128 | default: 'customer to set if using purpleteam in the cloud', 129 | env: 'PURPLETEAM_APP_CLIENT_SECRET', 130 | sensitive: true 131 | }, 132 | custnSubdomain: { 133 | doc: 'The customer specific subdomain.', 134 | format: String, 135 | default: 'custn' 136 | }, 137 | url: { 138 | doc: 'The URL of the purpleteam authorisation server.', 139 | format: 'url', 140 | url: 'https://set-below' 141 | } 142 | }, 143 | purpleteamApi: { 144 | protocol: { 145 | doc: 'The protocol of the purpleteam Cloud API. If using local env please use the existing value in the example config, if using cloud env, you will be given this when you sign-up for a purpleteam account.', 146 | format: ['https', 'http'], 147 | default: 'https' 148 | }, 149 | host: { 150 | doc: 'The IP address of the hostname of the purpleteam cloud API or local orchestrator. If using local env please use the existing value in the example config, if using cloud env, you will be given this when you sign-up for a purpleteam account.', 151 | format: String, 152 | default: '240.0.0.0' 153 | }, 154 | port: { 155 | doc: 'The port of the purpleteam cloud API or local orchestrator. If using local env please use the existing value, if using cloud env, you will be given this when you sign-up for a purpleteam account.', 156 | format: 'port', 157 | default: 2000, 158 | env: 'PORT' 159 | }, 160 | url: { 161 | doc: 'The URL of the purpleteam API.', 162 | format: 'url', 163 | default: 'https://set-below' 164 | }, 165 | stage: { 166 | doc: 'The API stage of the purpleteam cloud API. If using local env this is not required, if using cloud env, you will be given this when you sign-up for a purpleteam account.', 167 | format: String, 168 | default: 'customer to set if using purpleteam in the cloud' 169 | }, 170 | customerId: { 171 | doc: 'Your customer id if using purpleteam with the cloud service. If using local env this is not required, if using cloud env, you will be given this when you sign-up for a purpleteam account.', 172 | format: String, 173 | default: 'customer to set if using purpleteam in the cloud' 174 | }, 175 | apiKey: { 176 | doc: 'Your API key to interact with the purpleteam cloud service. If using local env this is not required, if using cloud env, you will be given this when you sign-up for a purpleteam account.', 177 | format: String, 178 | default: 'customer to set if using purpleteam in the cloud', 179 | env: 'PURPLETEAM_API_KEY', 180 | sensitive: true 181 | } 182 | }, 183 | testerFeedbackComms: { 184 | longPoll: { 185 | nullProgressMaxRetries: { 186 | doc: 'The number of times (sequentially receiving an event with a data object containing a property with a null value) to poll the backend when the orchestrator is not receiving feedback from the testers.', 187 | format: 'int', 188 | default: 5 189 | } 190 | } 191 | }, 192 | outcomes: { 193 | dir: { 194 | doc: 'The location of the results.', 195 | format: String, 196 | default: path.join(process.cwd(), '/outcomes/') 197 | }, 198 | fileName: { 199 | doc: 'The name of the archive file containing all of the Tester outcomes (results, reports).', 200 | format: String, 201 | default: 'outcomes_time.zip' 202 | }, 203 | filePath: { 204 | doc: 'The full file path of the archive file containing all of the Tester outcomes (results, reports).', 205 | format: String, 206 | default: 'not yet set' 207 | } 208 | }, 209 | job: { 210 | fileUri: { 211 | doc: 'The location of the Job file.', 212 | format: String, 213 | default: './testResources/jobs/Job' 214 | }, 215 | version: { 216 | doc: 'The version of the Job accepted by the PurpleTeam API.', 217 | format: ['0.1.0-alpha.1', '1.0.0-alpha.3', '2.0.0-alpha.3', '3.0.0-alpha.3', '3.1.0-alpha.3', '4.0.0-alpha.3'], 218 | default: '4.0.0-alpha.3' 219 | } 220 | }, 221 | modulePaths: { 222 | blessed: { 223 | doc: 'The path to blessed module.', 224 | format: String, 225 | default: 'blessed' 226 | } 227 | }, 228 | uI: { 229 | type: { 230 | doc: 'The user interface used. cUi is usually the best option for running the purpleteam CLI standalone, noUi is usually the best option for running the purpleteam CLI from within another process', 231 | format: ['cUi', 'noUi'], 232 | default: 'cUi', 233 | env: 'PURPLETEAM_UI' 234 | }, 235 | path: { 236 | cUi: path.join(process.cwd(), '/src/view/cUi.js'), 237 | noUi: path.join(process.cwd(), '/src/view/noUi.js') 238 | } 239 | } 240 | }; 241 | 242 | const config = convict(schema); 243 | // If you would like to put your sensitive values in a different location and lock down access, 244 | // simply provide the isolated file path as an array element to config.loadFile. 245 | // Doc: https://github.com/mozilla/node-convict/tree/master/packages/convict#configloadfilefile-or-filearray 246 | // config.loadFile([path.join(__dirname, `config.${process.env.NODE_ENV}.json`), '/my/locked/down/purpleteam_secrets.json']); 247 | const filename = fileURLToPath(import.meta.url); 248 | const currentDirName = dirname(filename); 249 | config.loadFile(path.join(currentDirName, `config.${process.env.NODE_ENV}.json`)); 250 | config.validate(); 251 | 252 | config.set('purpleteamApi.url', `${config.get('purpleteamApi.protocol')}://${config.get('purpleteamApi.host')}:${config.get('purpleteamApi.port')}`); 253 | config.set('outcomes.filePath', `${config.get('outcomes.dir')}${config.get('outcomes.fileName')}`); 254 | config.set('purpleteamAuth.url', `${config.get('purpleteamAuth.protocol')}://${config.get('purpleteamAuth.custnSubdomain')}.${config.get('purpleteamAuth.host')}/oauth2/token`); 255 | 256 | export default config; 257 | -------------------------------------------------------------------------------- /test/presenter/apiDecoratingAdapter_sSe_falsyMessageAndIncorrectOrigin.js: -------------------------------------------------------------------------------- 1 | // Use of this software is governed by the Business Source License 2 | // included in the file /licenses/bsl.md 3 | 4 | // As of the Change Date specified in that file, in accordance with 5 | // the Business Source License, use of this software will be governed 6 | // by the Apache License, Version 2.0 7 | 8 | import { readFile } from 'fs/promises'; 9 | import test from 'ava'; 10 | import sinon from 'sinon'; 11 | import nock from 'nock'; 12 | import { MockEvent, EventSource } from 'mocksse'; 13 | 14 | import config from '../../config/config.js'; 15 | import { TesterFeedbackRoutePrefix } from '../../src/strings/index.js'; 16 | 17 | const apiUrl = config.get('purpleteamApi.url'); 18 | const jobFilePath = config.get('job.fileUri'); 19 | const apiDecoratingAdapterPath = '../../src/presenter/apiDecoratingAdapter.js'; 20 | const viewPath = '../../src/view/index.js'; 21 | 22 | // As stored in the `request` object body from file: /testResources/jobs/job_4.0.0-alpha.3 23 | const expectedJob = '{\"data\":{\"type\":\"BrowserApp\",\"attributes\":{\"version\":\"4.0.0-alpha.3\",\"sutAuthentication\":{\"sitesTreeSutAuthenticationPopulationStrategy\":\"FormStandard\",\"emissaryAuthenticationStrategy\":\"FormStandard\",\"route\":\"/login\",\"usernameFieldLocater\":\"userName\",\"passwordFieldLocater\":\"password\",\"submit\":\"btn btn-danger\",\"expectedPageSourceSuccess\":\"Log Out\"},\"sutIp\":\"pt-sut-cont\",\"sutPort\":4000,\"sutProtocol\":\"http\",\"browser\":\"chrome\",\"loggedInIndicator\":\"

Found. Redirecting to /dashboard

\"},\"relationships\":{\"data\":[{\"type\":\"tlsScanner\",\"id\":\"NA\"},{\"type\":\"appScanner\",\"id\":\"lowPrivUser\"},{\"type\":\"appScanner\",\"id\":\"adminUser\"}]}},\"included\":[{\"type\":\"tlsScanner\",\"id\":\"NA\",\"attributes\":{\"tlsScannerSeverity\":\"LOW\",\"alertThreshold\":3}},{\"type\":\"appScanner\",\"id\":\"lowPrivUser\",\"attributes\":{\"sitesTreePopulationStrategy\":\"WebDriverStandard\",\"spiderStrategy\":\"Standard\",\"scannersStrategy\":\"BrowserAppStandard\",\"scanningStrategy\":\"BrowserAppStandard\",\"postScanningStrategy\":\"BrowserAppStandard\",\"reportingStrategy\":\"Standard\",\"reports\":{\"templateThemes\":[{\"name\":\"traditionalHtml\"},{\"name\":\"traditionalHtmlPlusLight\"}]},\"username\":\"user1\",\"password\":\"User1_123\",\"aScannerAttackStrength\":\"HIGH\",\"aScannerAlertThreshold\":\"LOW\",\"alertThreshold\":12},\"relationships\":{\"data\":[{\"type\":\"route\",\"id\":\"/profile\"}]}},{\"type\":\"appScanner\",\"id\":\"adminUser\",\"attributes\":{\"sitesTreePopulationStrategy\":\"WebDriverStandard\",\"spiderStrategy\":\"Standard\",\"scannersStrategy\":\"BrowserAppStandard\",\"scanningStrategy\":\"BrowserAppStandard\",\"postScanningStrategy\":\"BrowserAppStandard\",\"reportingStrategy\":\"Standard\",\"username\":\"admin\",\"password\":\"Admin_123\"},\"relationships\":{\"data\":[{\"type\":\"route\",\"id\":\"/memos\"},{\"type\":\"route\",\"id\":\"/profile\"}]}},{\"type\":\"route\",\"id\":\"/profile\",\"attributes\":{\"attackFields\":[{\"name\":\"firstName\",\"value\":\"PurpleJohn\",\"visible\":true},{\"name\":\"lastName\",\"value\":\"PurpleDoe\",\"visible\":true},{\"name\":\"ssn\",\"value\":\"PurpleSSN\",\"visible\":true},{\"name\":\"dob\",\"value\":\"12235678\",\"visible\":true},{\"name\":\"bankAcc\",\"value\":\"PurpleBankAcc\",\"visible\":true},{\"name\":\"bankRouting\",\"value\":\"0198212#\",\"visible\":true},{\"name\":\"address\",\"value\":\"PurpleAddress\",\"visible\":true},{\"name\":\"website\",\"value\":\"https://purpleteam-labs.com\",\"visible\":true},{\"name\":\"_csrf\",\"value\":\"\"},{\"name\":\"submit\",\"value\":\"\"}],\"method\":\"POST\",\"submit\":\"submit\"}},{\"type\":\"route\",\"id\":\"/memos\",\"attributes\":{\"attackFields\":[{\"name\":\"memo\",\"value\":\"PurpleMemo\",\"visible\":true}],\"method\":\"POST\",\"submit\":\"btn btn-primary\"}}]}'; // eslint-disable-line no-useless-escape 24 | 25 | test.before(async (t) => { 26 | t.context.jobFileContent = await readFile(jobFilePath, { encoding: 'utf8' }); // eslint-disable-line no-param-reassign 27 | }); 28 | 29 | // falsy message and incorrect origin 30 | // falsy message is logged to cUiLogger 31 | // incorrect origin messages are dropped by eventSource 32 | 33 | test.serial('subscribeToTesterFeedback SSE and handlers - given a mock event with falsy message or incorrect origin for each of the available testers sessions - given invocation of all the tester events - relevant handler instances should be run with correct error messages', async (t) => { 34 | const { context: { jobFileContent } } = t; 35 | nock.cleanAll(); 36 | const apiResponse = { 37 | testerStatuses: [ 38 | { 39 | name: 'app', 40 | message: 'Tester initialised.' 41 | }, 42 | { 43 | name: 'server', 44 | message: 'No server testing available currently. The server tester is currently in-active.' 45 | }, 46 | { 47 | name: 'tls', 48 | message: 'Tester initialised.' 49 | } 50 | ], 51 | testerFeedbackCommsMedium: 'sse' 52 | }; 53 | 54 | nock(apiUrl).post('/test', expectedJob).reply(200, apiResponse); 55 | 56 | const { default: view } = await import(viewPath); 57 | const { default: ptLogger, init: initPtLogger } = await import('purpleteam-logger'); 58 | const { default: aPi } = await import(apiDecoratingAdapterPath); 59 | 60 | const cUiLogger = initPtLogger(config.get('loggers.cUi')); 61 | const testStub = sinon.stub(view, 'test'); 62 | const warningStub = sinon.stub(cUiLogger, 'warning'); 63 | view.test = testStub; 64 | cUiLogger.warning = warningStub; 65 | 66 | const handleTesterProgressStub = sinon.stub(view, 'handleTesterProgress'); 67 | view.handleTesterProgress = handleTesterProgressStub; 68 | 69 | new MockEvent({ // eslint-disable-line no-new 70 | url: `${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/app/lowPrivUser`, 71 | setInterval: 1, 72 | responses: [ 73 | { lastEventId: 'one_', type: 'testerProgress', data: '{ "progress": null }' }, 74 | { lastEventId: 'two_', type: 'testerPctComplete', data: '{ "pctComplete": null }' }, 75 | { lastEventId: 'three_', type: 'testerBugCount', data: '{ "bugCount": null }' } 76 | ] 77 | }); 78 | new MockEvent({ // eslint-disable-line no-new 79 | url: `${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/app/adminUser`, 80 | setInterval: 1, 81 | responses: [ 82 | { lastEventId: 'four_', type: 'testerProgress', data: '{ "progress": null }' }, 83 | { lastEventId: 'five_', type: 'testerPctComplete', data: '{ "pctComplete": null }' }, 84 | { lastEventId: 'six_', type: 'testerBugCount', data: '{ "bugCount": null }' } 85 | ] 86 | }); 87 | new MockEvent({ // eslint-disable-line no-new 88 | url: `http://devious-origin.com/${TesterFeedbackRoutePrefix('sse')}/server/NA`, 89 | setInterval: 1, 90 | responses: [ 91 | { lastEventId: 'seven_', type: 'testerProgress', data: '{ "progress": "Initialising SSE subscription to \\"server-NA\\" channel for the event \\"testerProgress\\"" }' }, 92 | { lastEventId: 'eight_', type: 'testerPctComplete', data: '{ "pctComplete": 1 }' }, 93 | { lastEventId: 'nine_', type: 'testerBugCount', data: '{ "bugCount": 1 }' } 94 | ] 95 | }); 96 | new MockEvent({ // eslint-disable-line no-new 97 | url: `http://devious-origin.com/${TesterFeedbackRoutePrefix('sse')}/tls/NA`, 98 | setInterval: 1, 99 | responses: [ 100 | { lastEventId: 'ten_', type: 'testerProgress', data: '{ "progress": "Initialising SSE subscription to \\"tls-NA\\" channel for the event \\"testerProgress\\"" }' }, 101 | { lastEventId: 'eleven_', type: 'testerPctComplete', data: '{ "pctComplete": 0 }' }, 102 | { lastEventId: 'twelve_', type: 'testerBugCount', data: '{ "bugCount": 900 }' } 103 | ] 104 | }); 105 | 106 | t.teardown(() => { 107 | nock.cleanAll(); 108 | testStub.restore(); 109 | cUiLogger.warning.restore(); 110 | handleTesterProgressStub.restore(); 111 | }); 112 | 113 | await new Promise((resolve) => { 114 | const resolveIfAllWarningStubExpectationsAreComplete = () => { 115 | t.deepEqual(warningStub.getCall(0).args, ['A falsy testerProgress event message was received from the orchestrator', { tags: ['apiDecoratingAdapter'] }]) 116 | && t.deepEqual(warningStub.getCall(1).args, ['A falsy testerProgress event message was received from the orchestrator', { tags: ['apiDecoratingAdapter'] }]) 117 | && t.deepEqual(warningStub.getCall(2).args, ['A falsy testerPctComplete event message was received from the orchestrator', { tags: ['apiDecoratingAdapter'] }]) 118 | && t.deepEqual(warningStub.getCall(3).args, ['A falsy testerPctComplete event message was received from the orchestrator', { tags: ['apiDecoratingAdapter'] }]) 119 | && t.deepEqual(warningStub.getCall(4).args, ['A falsy testerBugCount event message was received from the orchestrator', { tags: ['apiDecoratingAdapter'] }]) 120 | && t.deepEqual(warningStub.getCall(5).args, ['A falsy testerBugCount event message was received from the orchestrator', { tags: ['apiDecoratingAdapter'] }]) 121 | && resolve(); 122 | }; 123 | 124 | const handler = { 125 | get(target, property, receiver) { 126 | if (property === 'warning') { 127 | return (message, tagObj) => { 128 | // Hit the cUiLogger warning stub (warningStub) 129 | target[property].call(receiver, message, tagObj); 130 | warningStub.callCount === 6 && resolveIfAllWarningStubExpectationsAreComplete(); 131 | }; 132 | } 133 | return (...args) => { 134 | target[property].call(receiver, ...args); 135 | }; 136 | } 137 | }; 138 | 139 | const proxyCuiLogger = new Proxy(cUiLogger, handler); 140 | 141 | aPi.inject({ /* Model, */ view /* , ptLogger */, cUiLogger: proxyCuiLogger, EventSource }); 142 | aPi.test(jobFileContent); 143 | }); 144 | 145 | const expectedTesterSessions = [ // Taken from the model test 146 | { testerType: 'app', sessionId: 'lowPrivUser', threshold: 12 }, 147 | { testerType: 'app', sessionId: 'adminUser', threshold: 0 }, 148 | { testerType: 'server', sessionId: 'NA', threshold: 0 }, 149 | { testerType: 'tls', sessionId: 'NA', threshold: 3 } 150 | ]; 151 | 152 | t.deepEqual(testStub.getCall(0).args[0], expectedTesterSessions); 153 | t.is(testStub.callCount, 1); 154 | 155 | t.is(handleTesterProgressStub.callCount, 4); // If incorrect origin messages were not dropped by eventSource, then there would be 2 more testerProgress messages. 156 | t.deepEqual(handleTesterProgressStub.getCall(0).args, [{ testerType: 'app', sessionId: 'lowPrivUser', message: 'Tester initialised.', ptLogger }]); 157 | t.deepEqual(handleTesterProgressStub.getCall(1).args, [{ testerType: 'app', sessionId: 'adminUser', message: 'Tester initialised.', ptLogger }]); 158 | t.deepEqual(handleTesterProgressStub.getCall(2).args, [{ testerType: 'server', sessionId: 'NA', message: 'No server testing available currently. The server tester is currently in-active.', ptLogger }]); 159 | t.deepEqual(handleTesterProgressStub.getCall(3).args, [{ testerType: 'tls', sessionId: 'NA', message: 'Tester initialised.', ptLogger }]); 160 | }); 161 | -------------------------------------------------------------------------------- /src/schemas/job.browserApp.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | const internals = { config: null }; 11 | 12 | const init = (config) => { 13 | internals.config = config; 14 | }; 15 | 16 | const schema = { 17 | $schema: 'http://json-schema.org/draft-07/schema#', 18 | $ref: '#/definitions/Job', 19 | definitions: { 20 | Job: { 21 | type: 'object', 22 | additionalProperties: false, 23 | properties: { 24 | data: { $ref: '#/definitions/Data' }, 25 | included: { 26 | type: 'array', 27 | items: { $ref: '#/definitions/TopLevelResourceObject' } 28 | } 29 | }, 30 | required: [ 31 | 'data', 32 | 'included' 33 | ], 34 | title: 'Job' 35 | }, 36 | Data: { 37 | type: 'object', 38 | additionalProperties: false, 39 | properties: { 40 | type: { type: 'string', enum: ['BrowserApp'] }, 41 | attributes: { $ref: '#/definitions/DataAttributes' }, 42 | relationships: { $ref: '#/definitions/Relationships' } 43 | }, 44 | required: [ 45 | 'attributes', 46 | 'relationships', 47 | 'type' 48 | ], 49 | title: 'Data' 50 | }, 51 | DataAttributes: { 52 | type: 'object', 53 | additionalProperties: false, 54 | properties: { 55 | version: { type: 'string', get const() { return internals.config.job.version; } }, 56 | sutAuthentication: { $ref: '#/definitions/SutAuthentication' }, 57 | sutIp: { type: 'string', oneOf: [{ format: 'ipv6' }, { format: 'hostname' }] }, 58 | sutPort: { type: 'integer', minimum: 1, maximum: 65535 }, 59 | sutProtocol: { type: 'string', enum: ['https', 'http'], default: 'https' }, 60 | browser: { type: 'string', get enum() { return internals.config.sut.browserOptions; }, get default() { return internals.config.sut.defaultBrowser; } }, 61 | loggedInIndicator: { type: 'string', minLength: 1 }, 62 | loggedOutIndicator: { type: 'string', minLength: 1 } 63 | }, 64 | oneOf: [ 65 | { required: ['loggedInIndicator'] }, 66 | { required: ['loggedOutIndicator'] } 67 | ], 68 | required: [ 69 | 'browser', 70 | 'sutAuthentication', 71 | 'sutIp', 72 | 'sutPort', 73 | 'sutProtocol', 74 | 'version' 75 | ], 76 | title: 'DataAttributes' 77 | }, 78 | SutAuthentication: { 79 | type: 'object', 80 | additionalProperties: false, 81 | properties: { 82 | sitesTreeSutAuthenticationPopulationStrategy: { type: 'string', enum: ['FormStandard', 'Link'], default: 'FormStandard' }, 83 | emissaryAuthenticationStrategy: { type: 'string', enum: ['FormStandard', 'ScriptLink'], default: 'FormStandard' }, 84 | route: { type: 'string', pattern: '^/[-?&=\\w/]{1,1000}$' }, 85 | usernameFieldLocater: { type: 'string', pattern: '^[a-zA-Z0-9_. -]{1,100}$' }, // Possibly allow spaces for css selectors. 86 | passwordFieldLocater: { type: 'string', pattern: '^[a-zA-Z0-9_. -]{1,100}$' }, // Possibly allow spaces for css selectors. 87 | submit: { type: 'string', pattern: '^[a-zA-Z0-9_\\-\\s]{1,100}$' }, 88 | expectedPageSourceSuccess: { type: 'string', minLength: 2, maxLength: 200 } 89 | }, 90 | required: [ 91 | 'route', 92 | 'expectedPageSourceSuccess' 93 | ], 94 | title: 'SutAuthentication' 95 | }, 96 | Relationships: { 97 | type: 'object', 98 | additionalProperties: false, 99 | properties: { 100 | data: { 101 | type: 'array', 102 | items: { $ref: '#/definitions/ResourceLinkage' } 103 | } 104 | }, 105 | required: [ 106 | 'data' 107 | ], 108 | title: 'Relationships' 109 | }, 110 | ResourceLinkage: { 111 | type: 'object', 112 | additionalProperties: false, 113 | properties: { 114 | type: { type: 'string', enum: ['tlsScanner', 'appScanner', 'route'] }, 115 | id: { type: 'string' } 116 | }, 117 | required: ['id', 'type'], 118 | if: { properties: { type: { enum: ['tlsScanner'] } } }, 119 | then: { properties: { id: { type: 'string', pattern: 'NA' } } }, 120 | else: { 121 | if: { properties: { type: { enum: ['appScanner'] } } }, 122 | then: { properties: { id: { type: 'string', pattern: '^\\w[-\\w]{1,200}$' } } }, 123 | else: { 124 | if: { properties: { type: { enum: ['route'] } } }, 125 | then: { properties: { id: { type: 'string', pattern: '^/[-\\w/]{1,200}$' } } } 126 | } 127 | }, 128 | title: 'ResourceLinkage' 129 | }, 130 | TopLevelResourceObject: { 131 | type: 'object', 132 | additionalProperties: false, 133 | properties: { 134 | type: { type: 'string', enum: ['tlsScanner', 'appScanner', 'route'] }, 135 | id: { type: 'string' }, 136 | attributes: {}, 137 | relationships: {} 138 | }, 139 | required: [ 140 | 'attributes', 141 | 'id', 142 | 'type' 143 | ], 144 | if: { properties: { type: { enum: ['tlsScanner'] } } }, 145 | then: { 146 | properties: { 147 | id: { type: 'string', pattern: 'NA' }, 148 | attributes: { $ref: '#/definitions/AttributesObjOfTopLevelResourceObjectOfTypeTlsScanner' } 149 | } 150 | }, 151 | // If we want to use flags for regex, etc, then need to use ajv-keywords: https://github.com/epoberezkin/ajv-keywords#regexp 152 | else: { 153 | if: { properties: { type: { enum: ['appScanner'] } } }, 154 | then: { 155 | properties: { 156 | id: { type: 'string', pattern: '^\\w[-\\w]{1,200}$' }, 157 | attributes: { $ref: '#/definitions/AttributesObjOfTopLevelResourceObjectOfTypeAppScanner' }, 158 | relationships: { $ref: '#/definitions/Relationships' } 159 | }, 160 | required: ['relationships'] 161 | }, 162 | else: { 163 | if: { properties: { type: { enum: ['route'] } } }, 164 | then: { 165 | properties: { 166 | id: { type: 'string', pattern: '^/[-\\w/]{1,200}$' }, 167 | attributes: { $ref: '#/definitions/AttributesObjOfTopLevelResourceObjectOfTypeRoute' } 168 | } 169 | } 170 | } 171 | }, 172 | title: 'TopLevelResourceObject', 173 | errorMessage: { 174 | properties: { 175 | type: 'should be one of either tlsScanner, appScanner, or route', 176 | id: 'If type is tlsScanner, the id should be NA. If type is appScanner, the id should be a valid appScanner. If type is route, the id should be a valid route.' 177 | } 178 | } 179 | }, 180 | 181 | AttributesObjOfTopLevelResourceObjectOfTypeTlsScanner: { 182 | type: 'object', 183 | additionalProperties: false, 184 | properties: { 185 | tlsScannerSeverity: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, 186 | alertThreshold: { type: 'integer', minimum: 0, maximum: 9999 } 187 | }, 188 | required: [], 189 | title: 'AttributesObjOfTopLevelResourceObjectOfTypeTlsScanner' 190 | }, 191 | 192 | AttributesObjOfTopLevelResourceObjectOfTypeAppScanner: { 193 | type: 'object', 194 | additionalProperties: false, 195 | properties: { 196 | username: { type: 'string', pattern: '^([a-zA-Z0-9_-]{1,100}|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z0-9]{2,})$' }, // https://www.py4u.net/discuss/1646374 197 | password: { type: 'string' }, 198 | aScannerAttackStrength: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'INSANE'] }, 199 | aScannerAlertThreshold: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] }, 200 | alertThreshold: { type: 'integer', minimum: 0, maximum: 9999 }, 201 | sitesTreePopulationStrategy: { type: 'string', enum: ['WebDriverStandard'], default: 'WebDriverStandard' }, 202 | spiderStrategy: { type: 'string', enum: ['Standard'], default: 'Standard' }, 203 | scannersStrategy: { type: 'string', enum: ['BrowserAppStandard'], default: 'BrowserAppStandard' }, 204 | scanningStrategy: { type: 'string', enum: ['BrowserAppStandard'], default: 'BrowserAppStandard' }, 205 | postScanningStrategy: { type: 'string', enum: ['BrowserAppStandard'], default: 'BrowserAppStandard' }, 206 | reportingStrategy: { type: 'string', enum: ['Standard'], default: 'Standard' }, 207 | reports: { 208 | type: 'object', 209 | additionalProperties: false, 210 | properties: { 211 | templateThemes: { 212 | type: 'array', 213 | items: { 214 | type: 'object', 215 | additionalProperties: false, 216 | properties: { 217 | name: { 218 | type: 'string', 219 | enum: [ 220 | 'traditionalHtml', 221 | 'traditionalHtmlPlusLight', 222 | 'traditionalHtmlPlusDark', 223 | 'traditionalJson', 224 | 'traditionalMd', 225 | 'traditionalXml', 226 | 'riskConfidenceHtmlDark', 227 | 'modernMarketing', 228 | 'highLevelReport' 229 | ] 230 | } 231 | }, 232 | required: ['name'] 233 | }, 234 | minItems: 1 235 | } 236 | }, 237 | required: ['templateThemes'] 238 | }, 239 | excludedRoutes: { 240 | type: 'array', 241 | items: { type: 'string' }, 242 | uniqueItems: true, 243 | minItems: 0 244 | } 245 | }, 246 | required: ['username'], 247 | title: 'AttributesObjOfTopLevelResourceObjectOfTypeAppScanner' 248 | }, 249 | 250 | AttributesObjOfTopLevelResourceObjectOfTypeRoute: { 251 | type: 'object', 252 | additionalProperties: false, 253 | properties: { 254 | attackFields: { 255 | type: 'array', 256 | items: { $ref: '#/definitions/AttackField' }, 257 | uniqueItems: true, 258 | minItems: 0 259 | }, 260 | method: { type: 'string', enum: ['GET', 'PUT', 'POST'] }, 261 | submit: { type: 'string', pattern: '^[a-zA-Z0-9_\\-\\s]{1,100}$' } 262 | }, 263 | required: ['attackFields', 'method', 'submit'], 264 | title: 'AttributesObjOfTopLevelResourceObjectOfTypeRoute' 265 | }, 266 | 267 | AttackField: { 268 | type: 'object', 269 | additionalProperties: false, 270 | properties: { 271 | name: { type: 'string', pattern: '^[a-zA-Z0-9._\\-]{1,100}$' }, 272 | value: { 273 | anyOf: [ 274 | { type: 'string' }, 275 | { type: 'boolean' }, 276 | { type: 'number' } 277 | ] 278 | }, 279 | visible: { type: 'boolean' } // Todo: KC: Need to check whether visible should be required. 280 | }, 281 | required: [ 282 | 'name', 283 | 'value' 284 | ], 285 | title: 'AttackField' 286 | } 287 | } 288 | }; 289 | 290 | export { init, schema }; 291 | -------------------------------------------------------------------------------- /src/schemas/job.aPi.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | const internals = { config: null }; 11 | 12 | const init = (config) => { 13 | internals.config = config; 14 | }; 15 | 16 | const schema = { 17 | $schema: 'http://json-schema.org/draft-07/schema#', 18 | $ref: '#/definitions/Job', 19 | definitions: { 20 | Job: { 21 | type: 'object', 22 | additionalProperties: false, 23 | properties: { 24 | data: { $ref: '#/definitions/Data' }, 25 | included: { 26 | type: 'array', 27 | items: { $ref: '#/definitions/TopLevelResourceObject' } 28 | } 29 | }, 30 | required: [ 31 | 'data', 32 | 'included' 33 | ], 34 | title: 'Job' 35 | }, 36 | Data: { 37 | type: 'object', 38 | additionalProperties: false, 39 | properties: { 40 | type: { type: 'string', enum: ['Api'] }, 41 | attributes: { $ref: '#/definitions/DataAttributes' }, 42 | relationships: { $ref: '#/definitions/Relationships' } 43 | }, 44 | required: [ 45 | 'attributes', 46 | 'relationships', 47 | 'type' 48 | ], 49 | title: 'Data' 50 | }, 51 | DataAttributes: { 52 | type: 'object', 53 | additionalProperties: false, 54 | properties: { 55 | version: { type: 'string', get const() { return internals.config.job.version; } }, 56 | sutAuthentication: { $ref: '#/definitions/SutAuthentication' }, 57 | sutIp: { type: 'string', oneOf: [{ format: 'ipv6' }, { format: 'hostname' }] }, 58 | sutPort: { type: 'integer', minimum: 1, maximum: 65535 }, 59 | sutProtocol: { type: 'string', enum: ['https', 'http'], default: 'https' }, 60 | loggedInIndicator: { type: 'string', minLength: 1 }, 61 | loggedOutIndicator: { type: 'string', minLength: 1 } 62 | }, 63 | oneOf: [ 64 | { required: ['loggedInIndicator'] }, 65 | { required: ['loggedOutIndicator'] } 66 | ], 67 | required: [ 68 | 'sutAuthentication', 69 | 'sutIp', 70 | 'sutPort', 71 | 'sutProtocol', 72 | 'version' 73 | ], 74 | title: 'DataAttributes' 75 | }, 76 | SutAuthentication: { 77 | type: 'object', 78 | additionalProperties: false, 79 | properties: { 80 | emissaryAuthenticationStrategy: { type: 'string', enum: ['MaintainJwt'], default: 'MaintainJwt' }, 81 | route: { type: 'string', pattern: '^/[-?&=\\w/]{1,1000}$' } 82 | }, 83 | required: [ 84 | 'route' 85 | ], 86 | title: 'SutAuthentication' 87 | }, 88 | Relationships: { 89 | type: 'object', 90 | additionalProperties: false, 91 | properties: { 92 | data: { 93 | type: 'array', 94 | items: { $ref: '#/definitions/ResourceLinkage' } 95 | } 96 | }, 97 | required: [ 98 | 'data' 99 | ], 100 | title: 'Relationships' 101 | }, 102 | ResourceLinkage: { 103 | type: 'object', 104 | additionalProperties: false, 105 | properties: { 106 | type: { type: 'string', enum: ['tlsScanner', 'appScanner'] }, 107 | id: { type: 'string' } 108 | }, 109 | required: ['id', 'type'], 110 | if: { properties: { type: { enum: ['tlsScanner'] } } }, 111 | then: { properties: { id: { type: 'string', pattern: 'NA' } } }, 112 | else: { 113 | if: { properties: { type: { enum: ['appScanner'] } } }, 114 | then: { properties: { id: { type: 'string', pattern: '^\\w[-\\w]{1,200}$' } } } 115 | }, 116 | title: 'ResourceLinkage' 117 | }, 118 | TopLevelResourceObject: { 119 | type: 'object', 120 | additionalProperties: false, 121 | properties: { 122 | type: { type: 'string', enum: ['tlsScanner', 'appScanner'] }, 123 | id: { type: 'string' }, 124 | attributes: {}, 125 | relationships: {} 126 | }, 127 | required: [ 128 | 'attributes', 129 | 'id', 130 | 'type' 131 | ], 132 | if: { properties: { type: { enum: ['tlsScanner'] } } }, 133 | then: { 134 | properties: { 135 | id: { type: 'string', pattern: 'NA' }, 136 | attributes: { $ref: '#/definitions/AttributesObjOfTopLevelResourceObjectOfTypeTlsScanner' } 137 | } 138 | }, 139 | // If we want to use flags for regex, etc, then need to use ajv-keywords: https://github.com/epoberezkin/ajv-keywords#regexp 140 | else: { 141 | if: { properties: { type: { enum: ['appScanner'] } } }, 142 | then: { 143 | properties: { 144 | id: { type: 'string', pattern: '^\\w[-\\w]{1,200}$' }, 145 | attributes: { $ref: '#/definitions/AttributesObjOfTopLevelResourceObjectOfTypeAppScanner' } 146 | } 147 | } 148 | }, 149 | title: 'TopLevelResourceObject', 150 | errorMessage: { 151 | properties: { 152 | type: 'should be one of either tlsScanner or appScanner', 153 | id: 'If type is tlsScanner, the id should be NA. If type is appScanner, the id should be a valid appScanner.' 154 | } 155 | } 156 | }, 157 | 158 | AttributesObjOfTopLevelResourceObjectOfTypeTlsScanner: { 159 | type: 'object', 160 | additionalProperties: false, 161 | properties: { 162 | tlsScannerSeverity: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, 163 | alertThreshold: { type: 'integer', minimum: 0, maximum: 9999 } 164 | }, 165 | required: [], 166 | title: 'AttributesObjOfTopLevelResourceObjectOfTypeTlsScanner' 167 | }, 168 | 169 | AttributesObjOfTopLevelResourceObjectOfTypeAppScanner: { 170 | type: 'object', 171 | additionalProperties: false, 172 | properties: { 173 | username: { type: 'string', pattern: '^([a-zA-Z0-9_-]{1,100}|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z0-9]{2,})$' }, // https://www.py4u.net/discuss/1646374 174 | aScannerAttackStrength: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'INSANE'] }, 175 | aScannerAlertThreshold: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] }, 176 | alertThreshold: { type: 'integer', minimum: 0, maximum: 9999 }, 177 | sitesTreePopulationStrategy: { type: 'string', enum: ['ImportUrls', 'OpenApi', 'Soap', 'GraphQl'], default: 'ImportUrls' }, 178 | spiderStrategy: { type: 'string', enum: ['Standard'], default: 'Standard' }, 179 | scannersStrategy: { type: 'string', enum: ['ApiStandard'], default: 'ApiStandard' }, 180 | scanningStrategy: { type: 'string', enum: ['ApiStandard'], default: 'ApiStandard' }, 181 | postScanningStrategy: { type: 'string', enum: ['ApiStandard'], default: 'ApiStandard' }, 182 | reportingStrategy: { type: 'string', enum: ['Standard'], default: 'Standard' }, 183 | reports: { 184 | type: 'object', 185 | additionalProperties: false, 186 | properties: { 187 | templateThemes: { 188 | type: 'array', 189 | items: { 190 | type: 'object', 191 | additionalProperties: false, 192 | properties: { 193 | name: { 194 | type: 'string', 195 | enum: [ 196 | 'traditionalHtml', 197 | 'traditionalHtmlPlusLight', 198 | 'traditionalHtmlPlusDark', 199 | 'traditionalJson', 200 | 'traditionalMd', 201 | 'traditionalXml', 202 | 'riskConfidenceHtmlDark', 203 | 'modernMarketing', 204 | 'highLevelReport' 205 | ] 206 | } 207 | }, 208 | required: ['name'] 209 | }, 210 | minItems: 1 211 | } 212 | }, 213 | required: ['templateThemes'] 214 | }, 215 | openApi: { $ref: '#/definitions/OpenApi' }, 216 | soap: { $ref: '#/definitions/Soap' }, 217 | graphQl: { $ref: '#/definitions/GraphQl' }, 218 | importUrls: { $ref: '#/definitions/ImportUrls' }, 219 | excludedRoutes: { 220 | type: 'array', 221 | items: { type: 'string' }, 222 | uniqueItems: true, 223 | minItems: 0 224 | } 225 | }, 226 | oneOf: [ 227 | { required: ['openApi'] }, 228 | { required: ['soap'] }, 229 | { required: ['graphQl'] }, 230 | { required: ['importUrls'] } 231 | ], 232 | required: ['username'], 233 | title: 'AttributesObjOfTopLevelResourceObjectOfTypeAppScanner' 234 | }, 235 | 236 | OpenApi: { 237 | type: 'object', 238 | additionalProperties: false, 239 | properties: { 240 | importFileContentBase64: { type: 'string', pattern: '^(?:[A-Za-z\\d+/]{4})*(?:[A-Za-z\\d+/]{3}=|[A-Za-z\\d+/]{2}==)?$' }, // https://regexland.com/base64/ 241 | importUrl: { type: 'string', format: 'uri' } 242 | }, 243 | oneOf: [ 244 | { required: ['importFileContentBase64'] }, 245 | { required: ['importUrl'] } 246 | ], 247 | required: [], 248 | title: 'OpenApi' 249 | }, 250 | 251 | Soap: { 252 | type: 'object', 253 | additionalProperties: false, 254 | properties: { 255 | importFileContentBase64: { type: 'string', pattern: '^(?:[A-Za-z\\d+/]{4})*(?:[A-Za-z\\d+/]{3}=|[A-Za-z\\d+/]{2}==)?$' }, // https://regexland.com/base64/ 256 | importUrl: { type: 'string', format: 'uri' } 257 | }, 258 | oneOf: [ 259 | { required: ['importFileContentBase64'] }, 260 | { required: ['importUrl'] } 261 | ], 262 | required: [], 263 | title: 'Soap' 264 | }, 265 | 266 | GraphQl: { 267 | type: 'object', 268 | additionalProperties: false, 269 | properties: { 270 | importFileContentBase64: { type: 'string', pattern: '^(?:[A-Za-z\\d+/]{4})*(?:[A-Za-z\\d+/]{3}=|[A-Za-z\\d+/]{2}==)?$' }, // https://regexland.com/base64/ 271 | importUrl: { type: 'string', format: 'uri' }, 272 | // If the following are not set, then no changes to Zaproxy defaults are made. 273 | maxQueryDepth: { type: 'integer', minimum: 0, maximum: 100 }, // Zaproxy default: 5 274 | maxArgsDepth: { type: 'integer', minimum: 0, maximum: 100 }, // Zaproxy default: 5 275 | optionalArgsEnabled: { type: 'boolean' }, // Zaproxy default: true 276 | argsType: { type: 'string', enum: ['INLINE', 'VARIABLES', 'BOTH'] }, // Zaproxy default: 'BOTH' 277 | querySplitType: { type: 'string', enum: ['LEAF', 'ROOT_FIELD', 'OPERATION'] }, // Zaproxy default: 'LEAF' 278 | requestMethod: { type: 'string', enum: ['POST_JSON', 'POST_GRAPHQL', 'GET'] } // Zaproxy default: 'POST_JSON' 279 | }, 280 | oneOf: [ 281 | { required: ['importFileContentBase64'] }, 282 | { required: ['importUrl'] } 283 | ], 284 | required: [], 285 | title: 'GraphQl' 286 | }, 287 | 288 | ImportUrls: { 289 | type: 'object', 290 | additionalProperties: false, 291 | properties: { importFileContentBase64: { type: 'string', pattern: '^(?:[A-Za-z\\d+/]{4})*(?:[A-Za-z\\d+/]{3}=|[A-Za-z\\d+/]{2}==)?$' } }, // https://regexland.com/base64/ 292 | required: ['importFileContentBase64'], 293 | title: 'ImportUrls' 294 | } 295 | } 296 | }; 297 | 298 | export { init, schema }; 299 | -------------------------------------------------------------------------------- /test/presenter/apiDecoratingAdapter.js: -------------------------------------------------------------------------------- 1 | // Use of this software is governed by the Business Source License 2 | // included in the file /licenses/bsl.md 3 | 4 | // As of the Change Date specified in that file, in accordance with 5 | // the Business Source License, use of this software will be governed 6 | // by the Apache License, Version 2.0 7 | 8 | import { readFile } from 'fs/promises'; 9 | import test from 'ava'; 10 | import sinon from 'sinon'; 11 | import nock from 'nock'; 12 | import { EventSource } from 'mocksse'; 13 | 14 | import config from '../../config/config.js'; 15 | 16 | const apiUrl = config.get('purpleteamApi.url'); 17 | const jobFilePath = config.get('job.fileUri'); 18 | const apiDecoratingAdapterPath = '../../src/presenter/apiDecoratingAdapter.js'; 19 | const viewPath = '../../src/view/index.js'; 20 | 21 | // As stored in the `request` object body from file: /testResources/jobs/job_4.0.0-alpha.3 22 | const expectedJob = '{\"data\":{\"type\":\"BrowserApp\",\"attributes\":{\"version\":\"4.0.0-alpha.3\",\"sutAuthentication\":{\"sitesTreeSutAuthenticationPopulationStrategy\":\"FormStandard\",\"emissaryAuthenticationStrategy\":\"FormStandard\",\"route\":\"/login\",\"usernameFieldLocater\":\"userName\",\"passwordFieldLocater\":\"password\",\"submit\":\"btn btn-danger\",\"expectedPageSourceSuccess\":\"Log Out\"},\"sutIp\":\"pt-sut-cont\",\"sutPort\":4000,\"sutProtocol\":\"http\",\"browser\":\"chrome\",\"loggedInIndicator\":\"

Found. Redirecting to /dashboard

\"},\"relationships\":{\"data\":[{\"type\":\"tlsScanner\",\"id\":\"NA\"},{\"type\":\"appScanner\",\"id\":\"lowPrivUser\"},{\"type\":\"appScanner\",\"id\":\"adminUser\"}]}},\"included\":[{\"type\":\"tlsScanner\",\"id\":\"NA\",\"attributes\":{\"tlsScannerSeverity\":\"LOW\",\"alertThreshold\":3}},{\"type\":\"appScanner\",\"id\":\"lowPrivUser\",\"attributes\":{\"sitesTreePopulationStrategy\":\"WebDriverStandard\",\"spiderStrategy\":\"Standard\",\"scannersStrategy\":\"BrowserAppStandard\",\"scanningStrategy\":\"BrowserAppStandard\",\"postScanningStrategy\":\"BrowserAppStandard\",\"reportingStrategy\":\"Standard\",\"reports\":{\"templateThemes\":[{\"name\":\"traditionalHtml\"},{\"name\":\"traditionalHtmlPlusLight\"}]},\"username\":\"user1\",\"password\":\"User1_123\",\"aScannerAttackStrength\":\"HIGH\",\"aScannerAlertThreshold\":\"LOW\",\"alertThreshold\":12},\"relationships\":{\"data\":[{\"type\":\"route\",\"id\":\"/profile\"}]}},{\"type\":\"appScanner\",\"id\":\"adminUser\",\"attributes\":{\"sitesTreePopulationStrategy\":\"WebDriverStandard\",\"spiderStrategy\":\"Standard\",\"scannersStrategy\":\"BrowserAppStandard\",\"scanningStrategy\":\"BrowserAppStandard\",\"postScanningStrategy\":\"BrowserAppStandard\",\"reportingStrategy\":\"Standard\",\"username\":\"admin\",\"password\":\"Admin_123\"},\"relationships\":{\"data\":[{\"type\":\"route\",\"id\":\"/memos\"},{\"type\":\"route\",\"id\":\"/profile\"}]}},{\"type\":\"route\",\"id\":\"/profile\",\"attributes\":{\"attackFields\":[{\"name\":\"firstName\",\"value\":\"PurpleJohn\",\"visible\":true},{\"name\":\"lastName\",\"value\":\"PurpleDoe\",\"visible\":true},{\"name\":\"ssn\",\"value\":\"PurpleSSN\",\"visible\":true},{\"name\":\"dob\",\"value\":\"12235678\",\"visible\":true},{\"name\":\"bankAcc\",\"value\":\"PurpleBankAcc\",\"visible\":true},{\"name\":\"bankRouting\",\"value\":\"0198212#\",\"visible\":true},{\"name\":\"address\",\"value\":\"PurpleAddress\",\"visible\":true},{\"name\":\"website\",\"value\":\"https://purpleteam-labs.com\",\"visible\":true},{\"name\":\"_csrf\",\"value\":\"\"},{\"name\":\"submit\",\"value\":\"\"}],\"method\":\"POST\",\"submit\":\"submit\"}},{\"type\":\"route\",\"id\":\"/memos\",\"attributes\":{\"attackFields\":[{\"name\":\"memo\",\"value\":\"PurpleMemo\",\"visible\":true}],\"method\":\"POST\",\"submit\":\"btn btn-primary\"}}]}'; // eslint-disable-line no-useless-escape 23 | 24 | test.before(async (t) => { 25 | /* eslint-disable no-param-reassign */ 26 | t.context.jobFileContent = await readFile(jobFilePath, { encoding: 'utf8' }); 27 | t.context.jobFileContentLocalMissingComma = await readFile(`${process.cwd()}/testResources/jobs/job_4.0.0-alpha.3_local_missing_comma`, { encoding: 'utf8' }); 28 | t.context.jobFileContentLocalMissingTypeOfAppScanner = await readFile(`${process.cwd()}/testResources/jobs/job_4.0.0-alpha.3_local_missing_type_of_appScanner`, { encoding: 'utf8' }); 29 | /* eslint-enable no-param-reassign */ 30 | }); 31 | 32 | test.serial('testPlans - Should provide the cUi with the test plan to display', async (t) => { 33 | const { context: { jobFileContent } } = t; 34 | 35 | const expectedArgPasssedToTestPlan = [{ 36 | name: 'app', 37 | message: `@app_scan 38 | Feature: Web application free of security vulnerabilities known to the Emissary 39 | 40 | # Before hooks are run before Background 41 | 42 | Background: 43 | Given a new Test Session based on each Build User supplied appScanner resourceObject 44 | And the Emissary sites tree is populated with each Build User supplied route of each appScanner resourceObject 45 | And the Emissary authentication is configured for the SUT 46 | And the application is spidered for each appScanner resourceObject 47 | 48 | Scenario: The application should not contain vulnerabilities known to the Emissary that exceed the Build User defined threshold 49 | Given the active scanners are configured 50 | When the active scan is run 51 | Then the vulnerability count should not exceed the Build User defined threshold of vulnerabilities known to the Emissary 52 | 53 | 54 | 55 | @simple_math 56 | Feature: Simple maths 57 | In order to do maths 58 | As a developer 59 | I want to increment variables 60 | 61 | Scenario: easy maths 62 | Given a variable set to 1 63 | When I increment the variable by 1 64 | Then the variable should contain 2 65 | 66 | Scenario Outline: much more complex stuff 67 | Given a variable set to 68 | When I increment the variable by 69 | Then the variable should contain 70 | 71 | Examples: 72 | | var | increment | result | 73 | | 100 | 5 | 105 | 74 | | 99 | 1234 | 1333 | 75 | | 12 | 5 | 17 | 76 | 77 | ` 78 | }, { 79 | name: 'server', 80 | message: 'No test plan available for the server tester. The server tester is currently in-active.' 81 | }, { 82 | name: 'tls', 83 | message: `@tls_scan 84 | Feature: Web application free of TLS vulnerabilities known to the TLS Emissary 85 | 86 | # Before hooks are run before Background 87 | # Todo update app_scan.feature and docs around tester session wording 88 | Background: 89 | Given a new TLS Test Session based on the Build User supplied tlsScanner resourceObject 90 | 91 | Scenario: The application should not contain vulnerabilities known to the TLS Emissary that exceed the Build User defined threshold 92 | Given the TLS Emissary is run with arguments 93 | Then the vulnerability count should not exceed the Build User defined threshold of vulnerabilities known to the TLS Emissary 94 | 95 | ` 96 | }]; 97 | 98 | nock(apiUrl).post('/testplan', expectedJob).reply(200, expectedArgPasssedToTestPlan); 99 | const { default: view } = await import(viewPath); 100 | const { default: ptLogger /* , init: initPtLogger */ } = await import('purpleteam-logger'); 101 | const { default: aPi } = await import(apiDecoratingAdapterPath); 102 | 103 | const testPlanStub = sinon.stub(view, 'testPlan'); 104 | view.testPlan = testPlanStub; 105 | 106 | t.teardown(() => { 107 | nock.cleanAll(); 108 | testPlanStub.restore(); 109 | }); 110 | 111 | aPi.inject({ /* Model, */ view /* , ptLogger, cUiLogger: initPtLogger(config.get('loggers.cUi')) */ }); 112 | 113 | await aPi.testPlans(jobFileContent); 114 | 115 | t.deepEqual(testPlanStub.getCall(0).args[0], { testPlans: expectedArgPasssedToTestPlan, ptLogger }); 116 | }); 117 | 118 | test.serial('postToApi - on - connect EHOSTUNREACH - should print message - orchestrator is down...', async (t) => { 119 | const { context: { jobFileContent } } = t; 120 | 121 | nock(apiUrl).post('/testplan', expectedJob).replyWithError({ code: 'EHOSTUNREACH' }); 122 | const { /* default: ptLogger, */ init: initPtLogger } = await import('purpleteam-logger'); 123 | const { default: aPi } = await import(apiDecoratingAdapterPath); 124 | 125 | const cUiLogger = initPtLogger(config.get('loggers.cUi')); 126 | const critStub = sinon.stub(cUiLogger, 'crit'); 127 | cUiLogger.crit = critStub; 128 | 129 | t.teardown(() => { 130 | nock.cleanAll(); 131 | critStub.restore(); 132 | }); 133 | 134 | aPi.inject({ /* Model, view, ptLogger, */ cUiLogger }); 135 | 136 | await aPi.testPlans(jobFileContent); 137 | 138 | t.is(critStub.getCall(0).args[0], 'orchestrator is down, or an incorrect URL has been specified in the CLI config.'); 139 | t.deepEqual(critStub.getCall(0).args[1], { tags: ['apiDecoratingAdapter'] }); 140 | t.is(critStub.getCall(1), null); 141 | }); 142 | 143 | test.serial('postToApi - on - invalid JSON syntax - should print useful error message', async (t) => { 144 | const { context: { jobFileContentLocalMissingComma } } = t; 145 | 146 | const { /* default: ptLogger, */ init: initPtLogger } = await import('purpleteam-logger'); 147 | const { default: aPi } = await import(apiDecoratingAdapterPath); 148 | 149 | const cUiLogger = initPtLogger(config.get('loggers.cUi')); 150 | const critStub = sinon.stub(cUiLogger, 'crit'); 151 | cUiLogger.crit = critStub; 152 | 153 | t.teardown(() => { 154 | critStub.restore(); 155 | }); 156 | 157 | aPi.inject({ /* Model, view, ptLogger, */ cUiLogger }); 158 | 159 | await aPi.testPlans(jobFileContentLocalMissingComma); 160 | 161 | t.is(critStub.getCall(0).args[0], 'Error occurred while instantiating the model. Details follow: Invalid syntax in "Job": Unexpected string in JSON at position 1142'); 162 | t.deepEqual(critStub.getCall(0).args[1], { tags: ['apiDecoratingAdapter'] }); 163 | t.is(critStub.getCall(1), null); 164 | }); 165 | 166 | test.serial('postToApi - on - invalid job based on purpleteam schema - should print useful error message', async (t) => { 167 | const { context: { jobFileContentLocalMissingTypeOfAppScanner } } = t; 168 | 169 | /* eslint-disable no-useless-escape */ 170 | const expectedPrintedErrorMessage = `Error occurred while instantiating the model. Details follow: An error occurred while validating the Job. Details follow: 171 | name: ValidationError 172 | message. Errors: [ 173 | { 174 | "instancePath": "/included/1", 175 | "schemaPath": "#/if", 176 | "keyword": "if", 177 | "params": { 178 | "failingKeyword": "then" 179 | }, 180 | "message": "must match \\"then\\" schema" 181 | }, 182 | { 183 | "instancePath": "/included/1", 184 | "schemaPath": "#/required", 185 | "keyword": "required", 186 | "params": { 187 | "missingProperty": "type" 188 | }, 189 | "message": "must have required property 'type'" 190 | }, 191 | { 192 | "instancePath": "/included/1/id", 193 | "schemaPath": "#/errorMessage", 194 | "keyword": "errorMessage", 195 | "params": { 196 | "errors": [ 197 | { 198 | "instancePath": "/included/1/id", 199 | "schemaPath": "#/then/properties/id/pattern", 200 | "keyword": "pattern", 201 | "params": { 202 | "pattern": "NA" 203 | }, 204 | "message": "must match pattern \\"NA\\"", 205 | "emUsed": true 206 | } 207 | ] 208 | }, 209 | "message": "If type is tlsScanner, the id should be NA. If type is appScanner, the id should be a valid appScanner. If type is route, the id should be a valid route." 210 | } 211 | ]`; 212 | /* eslint-enable no-useless-escape */ 213 | 214 | const { /* default: ptLogger, */ init: initPtLogger } = await import('purpleteam-logger'); 215 | const { default: aPi } = await import(apiDecoratingAdapterPath); 216 | 217 | const cUiLogger = initPtLogger(config.get('loggers.cUi')); 218 | const critStub = sinon.stub(cUiLogger, 'crit'); 219 | cUiLogger.crit = critStub; 220 | 221 | t.teardown(() => { 222 | critStub.restore(); 223 | }); 224 | 225 | aPi.inject({ /* Model, view, ptLogger, */ cUiLogger }); 226 | 227 | await aPi.testPlans(jobFileContentLocalMissingTypeOfAppScanner); 228 | 229 | t.deepEqual(critStub.getCall(0).args[0], expectedPrintedErrorMessage); 230 | t.deepEqual(critStub.getCall(0).args[1], { tags: ['apiDecoratingAdapter'] }); 231 | t.is(critStub.getCall(1), null); 232 | }); 233 | 234 | test.serial('postToApi - on - unknown error - should print unknown error', async (t) => { 235 | const { context: { jobFileContent } } = t; 236 | 237 | const expectedResponse = 'is this a useful error message'; 238 | 239 | nock(apiUrl).post('/testplan', expectedJob).replyWithError({ message: expectedResponse }); 240 | 241 | const { /* default: ptLogger, */ init: initPtLogger } = await import('purpleteam-logger'); 242 | const { default: aPi } = await import(apiDecoratingAdapterPath); 243 | 244 | const cUiLogger = initPtLogger(config.get('loggers.cUi')); 245 | const critStub = sinon.stub(cUiLogger, 'crit'); 246 | cUiLogger.crit = critStub; 247 | 248 | const expectedPrintedErrorMessage = `Error occurred while attempting to communicate with the purpleteam API. Error was: Unknown error. Error follows: RequestError: ${expectedResponse}`; 249 | 250 | t.teardown(() => { 251 | nock.cleanAll(); 252 | critStub.restore(); 253 | }); 254 | 255 | aPi.inject({ /* Model, view, ptLogger, */ cUiLogger }); 256 | 257 | await aPi.testPlans(jobFileContent); 258 | 259 | t.deepEqual(critStub.getCall(0).args[0], expectedPrintedErrorMessage); 260 | t.deepEqual(critStub.getCall(0).args[1], { tags: ['apiDecoratingAdapter'] }); 261 | t.is(critStub.getCall(1), null); 262 | }); 263 | 264 | // 265 | // 266 | // 267 | // 268 | // Todo: As part of adding LP for AWS, add another set of tests similar to the above "postToApi" for cloud env, but we only need to cover the 4 knownError cases in the `gotPt = got.extend` hooks. 269 | // 270 | // 271 | // 272 | // 273 | 274 | test.serial('test and subscribeToTesterFeedback - should subscribe to models tester events - should propagate initial tester responses from each tester to model - then verify event flow back through presenter and then to view', async (t) => { 275 | const { context: { jobFileContent } } = t; 276 | 277 | const apiResponse = { 278 | testerStatuses: [ 279 | { 280 | name: 'app', 281 | message: 'Tester initialised.' 282 | }, 283 | { 284 | name: 'server', 285 | message: 'No server testing available currently. The server tester is currently in-active.' 286 | }, 287 | { 288 | name: 'tls', 289 | message: 'Tester initialised.' 290 | } 291 | ], 292 | testerFeedbackCommsMedium: 'sse' 293 | }; 294 | 295 | nock(apiUrl).post('/test', expectedJob).reply(200, apiResponse); 296 | 297 | const { default: view } = await import(viewPath); 298 | const { default: ptLogger /* , init: initPtLogger */ } = await import('purpleteam-logger'); 299 | const { default: aPi } = await import(apiDecoratingAdapterPath); 300 | 301 | const testStub = sinon.stub(view, 'test'); 302 | view.test = testStub; 303 | 304 | const handleTesterProgressStub = sinon.stub(view, 'handleTesterProgress'); 305 | view.handleTesterProgress = handleTesterProgressStub; 306 | 307 | t.teardown(() => { 308 | nock.cleanAll(); 309 | testStub.restore(); 310 | handleTesterProgressStub.restore(); 311 | }); 312 | 313 | aPi.inject({ /* Model, */ view, /* ptLogger, cUiLogger */ EventSource }); 314 | 315 | await aPi.test(jobFileContent); 316 | 317 | const expectedTesterSessions = [ // Taken from the model test 318 | { testerType: 'app', sessionId: 'lowPrivUser', threshold: 12 }, 319 | { testerType: 'app', sessionId: 'adminUser', threshold: 0 }, 320 | { testerType: 'server', sessionId: 'NA', threshold: 0 }, 321 | { testerType: 'tls', sessionId: 'NA', threshold: 3 } 322 | ]; 323 | 324 | t.deepEqual(testStub.getCall(0).args[0], expectedTesterSessions); 325 | t.is(testStub.callCount, 1); 326 | 327 | t.is(handleTesterProgressStub.callCount, 4); 328 | t.deepEqual(handleTesterProgressStub.getCall(0).args, [{ testerType: 'app', sessionId: 'lowPrivUser', message: 'Tester initialised.', ptLogger }]); 329 | t.deepEqual(handleTesterProgressStub.getCall(1).args, [{ testerType: 'app', sessionId: 'adminUser', message: 'Tester initialised.', ptLogger }]); 330 | t.deepEqual(handleTesterProgressStub.getCall(2).args, [{ testerType: 'server', sessionId: 'NA', message: 'No server testing available currently. The server tester is currently in-active.', ptLogger }]); 331 | t.deepEqual(handleTesterProgressStub.getCall(3).args, [{ testerType: 'tls', sessionId: 'NA', message: 'Tester initialised.', ptLogger }]); 332 | }); 333 | 334 | test.serial('test and subscribeToTesterFeedback - should subscribe to models tester events - should propagate initial tester responses from each tester to model, even if app tester is offline - then verify event flow back through presenter and then to view', async (t) => { 335 | const { context: { jobFileContent } } = t; 336 | 337 | const apiResponse = { 338 | testerStatuses: [ 339 | // { 340 | // name: 'app', 341 | // message: 'Tester initialised.' 342 | // }, 343 | { 344 | name: 'server', 345 | message: 'No server testing available currently. The server tester is currently in-active.' 346 | }, 347 | { 348 | name: 'tls', 349 | message: 'Tester initialised.' 350 | } 351 | ], 352 | testerFeedbackCommsMedium: 'sse' 353 | }; 354 | 355 | nock(apiUrl).post('/test', expectedJob).reply(200, apiResponse); 356 | 357 | const { default: view } = await import(viewPath); 358 | const { default: ptLogger /* , init: initPtLogger */ } = await import('purpleteam-logger'); 359 | const { default: aPi } = await import(apiDecoratingAdapterPath); 360 | 361 | const testStub = sinon.stub(view, 'test'); 362 | view.test = testStub; 363 | 364 | const handleTesterProgressStub = sinon.stub(view, 'handleTesterProgress'); 365 | view.handleTesterProgress = handleTesterProgressStub; 366 | 367 | t.teardown(() => { 368 | nock.cleanAll(); 369 | testStub.restore(); 370 | handleTesterProgressStub.restore(); 371 | }); 372 | 373 | aPi.inject({ /* Model, */ view, /* ptLogger, cUiLogger */ EventSource }); 374 | 375 | await aPi.test(jobFileContent); 376 | 377 | const expectedTesterSessions = [ // Taken from the model test 378 | { testerType: 'app', sessionId: 'lowPrivUser', threshold: 12 }, 379 | { testerType: 'app', sessionId: 'adminUser', threshold: 0 }, 380 | { testerType: 'server', sessionId: 'NA', threshold: 0 }, 381 | { testerType: 'tls', sessionId: 'NA', threshold: 3 } 382 | ]; 383 | 384 | t.deepEqual(testStub.getCall(0).args[0], expectedTesterSessions); 385 | t.is(testStub.callCount, 1); 386 | 387 | t.is(handleTesterProgressStub.callCount, 4); 388 | t.deepEqual(handleTesterProgressStub.getCall(0).args, [{ testerType: 'app', sessionId: 'lowPrivUser', message: '"app" Tester for session with Id "lowPrivUser" doesn\'t currently appear to be online', ptLogger }]); 389 | t.deepEqual(handleTesterProgressStub.getCall(1).args, [{ testerType: 'app', sessionId: 'adminUser', message: '"app" Tester for session with Id "adminUser" doesn\'t currently appear to be online', ptLogger }]); 390 | t.deepEqual(handleTesterProgressStub.getCall(2).args, [{ testerType: 'server', sessionId: 'NA', message: 'No server testing available currently. The server tester is currently in-active.', ptLogger }]); 391 | t.deepEqual(handleTesterProgressStub.getCall(3).args, [{ testerType: 'tls', sessionId: 'NA', message: 'Tester initialised.', ptLogger }]); 392 | }); 393 | 394 | test('getJobFile - should return the Job file contents', async (t) => { 395 | const { context: { jobFileContent: jobFileContentExpected } } = t; 396 | const { default: aPi } = await import(apiDecoratingAdapterPath); 397 | const jobFileContentActual = await aPi.getJobFile(jobFilePath); 398 | t.deepEqual(jobFileContentExpected, jobFileContentActual); 399 | }); 400 | -------------------------------------------------------------------------------- /src/view/cUi.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import contrib from 'blessed-contrib'; 11 | import { createRequire } from 'module'; 12 | import config from '../../config/config.js'; 13 | import { testerViewTypes, testerPctCompleteType, statTableType, newBugsType, totalProgressType } from './blessedTypes/index.js'; 14 | 15 | const require = createRequire(import.meta.url); 16 | const { name: projectName } = require('../../package'); 17 | 18 | // blessed breaks tests, so fake it. 19 | const { default: blessed } = await import(config.get('modulePaths.blessed')); 20 | 21 | const testerNames = testerViewTypes.map((tv) => tv.testOpts.args.name); 22 | 23 | const internals = { 24 | infoOuts: { 25 | app: { 26 | loggers: [/* { 27 | sessionId: '', instance: testerViewType.testInstance, gridCoords: { row: , col: , rowSpan: , colSpan: } 28 | }, { 29 | sessionId: '', instance: testerViewType.testInstance, gridCoords: { row: , col: , rowSpan: , colSpan: } 30 | } */], 31 | testerPctComplete: { instance: 'To be assigned', percent: 'To be assigned', color: 'To be assigned' }, 32 | statTable: { 33 | instance: 'To be assigned', 34 | records: [/* { 35 | sessionId: '', threshold: , bugs: 0, pctComplete: 0 36 | }, { 37 | sessionId: '', threshold: , bugs: 0, pctComplete: 0 38 | } */] 39 | }, 40 | newBugs: { instance: 'To be assigned', value: 'To be assigned', color: 'To be assigned' }, 41 | totalProgress: { instance: 'To be assigned', percent: 'To be assigned' }, 42 | focussedPage: false 43 | }, 44 | server: { 45 | loggers: [], 46 | testerPctComplete: { instance: 'To be assigned', percent: 'To be assigned', color: 'To be assigned' }, 47 | statTable: { 48 | instance: 'To be assigned', 49 | records: [] 50 | }, 51 | newBugs: { instance: 'To be assigned', value: 'To be assigned', color: 'To be assigned' }, 52 | totalProgress: { instance: 'To be assigned', percent: 'To be assigned' }, 53 | focussedPage: false 54 | }, 55 | tls: { 56 | loggers: [], 57 | testerPctComplete: { instance: 'To be assigned', percent: 'To be assigned', color: 'To be assigned' }, 58 | statTable: { 59 | instance: 'To be assigned', 60 | records: [] 61 | }, 62 | newBugs: { instance: 'To be assigned', value: 'To be assigned', color: 'To be assigned' }, 63 | totalProgress: { instance: 'To be assigned', percent: 'To be assigned' }, 64 | focussedPage: false 65 | } 66 | } 67 | }; 68 | 69 | const screen = blessed.screen({ 70 | smartCSR: true, 71 | autoPadding: false, 72 | warnings: true, 73 | title: projectName 74 | }); 75 | 76 | 77 | const colourOfDonut = (pct) => { 78 | let colourToSet; 79 | if (pct < 20) colourToSet = 'red'; 80 | else if (pct >= 20 && pct < 70) colourToSet = 'magenta'; 81 | else if (pct >= 70) colourToSet = 'blue'; 82 | return colourToSet; 83 | }; 84 | 85 | 86 | const setDataOnTesterPctCompleteWidget = () => { 87 | const { infoOuts } = internals; 88 | const testerPctCompleteInstance = infoOuts[testerNames.find((tN) => infoOuts[tN].focussedPage)].testerPctComplete.instance; 89 | testerPctCompleteInstance.update(testerNames.map((tN) => { 90 | const record = infoOuts[tN].testerPctComplete; 91 | return { percent: record.percent, label: tN, color: record.color }; 92 | })); 93 | }; 94 | 95 | 96 | const setDataOnStatTableWidget = () => { 97 | const { infoOuts } = internals; 98 | const statTableInstance = infoOuts[testerNames.find((tN) => infoOuts[tN].focussedPage)].statTable.instance; 99 | statTableInstance.setData({ 100 | headers: statTableType.headers, 101 | data: (() => { 102 | const statTableDataRows = []; 103 | testerNames.forEach((tn) => { 104 | statTableDataRows.push(...infoOuts[tn].statTable.records.map((row) => [tn, row.sessionId, row.threshold, row.bugs, row.pctComplete])); 105 | }); 106 | return statTableDataRows; 107 | })() 108 | }); 109 | statTableInstance.focus(); 110 | }; 111 | 112 | 113 | const setDataOnNewBugsWidget = () => { 114 | const { infoOuts } = internals; 115 | const newBugs = infoOuts[testerNames.find((tN) => infoOuts[tN].focussedPage)].newBugs; // eslint-disable-line prefer-destructuring 116 | newBugs.instance.setDisplay(newBugs.value); 117 | newBugs.instance.setOptions({ color: newBugs.color }); 118 | }; 119 | 120 | 121 | const setDataOnTotalProgressWidget = () => { 122 | const { infoOuts } = internals; 123 | const { totalProgress } = infoOuts[testerNames.find((tN) => infoOuts[tN].focussedPage)]; 124 | let roundedPercent = Math.round(totalProgress.percent); 125 | // Bug in the blessed contrib component that does't render the guage propertly if percent is 1 or less. 126 | if (roundedPercent <= 1) roundedPercent = 0; 127 | totalProgress.instance.setStack([{ percent: roundedPercent, stroke: 'blue' }, { percent: 100 - roundedPercent, stroke: 'red' }]); 128 | }; 129 | 130 | 131 | // Assign the infoOut values to the view components 132 | const setDataOnAllPageWidgets = () => { 133 | setDataOnTesterPctCompleteWidget(); 134 | setDataOnStatTableWidget(); 135 | setDataOnNewBugsWidget(); 136 | setDataOnTotalProgressWidget(); 137 | screen.render(); 138 | }; 139 | 140 | 141 | const handleTesterProgress = ({ testerType, sessionId, message, ptLogger }) => { 142 | const logger = internals.infoOuts[testerType].loggers.find((l) => l.sessionId === sessionId); 143 | if (logger.instance !== 'To be assigned') { 144 | try { 145 | const lines = message.split('\n'); 146 | lines.forEach((line) => { logger.instance.log(line); }); 147 | } catch (e) { 148 | throw new Error(`An error occurred while attempting to split a testerProgress event message. The message was "${message}", the error was "${e}"`); 149 | } 150 | } 151 | ptLogger.get(`${testerType}-${sessionId}`).notice(message); 152 | }; 153 | 154 | 155 | const handleTesterPctComplete = ({ testerType, sessionId, message }) => { 156 | const { infoOuts } = internals; 157 | // statTable 158 | infoOuts[testerType].statTable.records.find((r) => r.sessionId === sessionId).pctComplete = Math.round(message); 159 | // testerPctComplete 160 | infoOuts[testerType].testerPctComplete.percent = infoOuts[testerType] 161 | .statTable.records.reduce((accum, curr) => accum + (curr.pctComplete), 0) 162 | / infoOuts[testerType].statTable.records.length 163 | / 100; 164 | infoOuts[testerType].testerPctComplete.color = colourOfDonut(infoOuts[testerType].testerPctComplete.percent); 165 | // totalProgress 166 | const pctsComplete = []; 167 | testerNames.forEach((tN) => { 168 | pctsComplete.push(...infoOuts[tN].statTable.records.map((r) => r.pctComplete)); 169 | }); 170 | const totalProgress = pctsComplete.reduce((accum, curr) => accum + curr) / pctsComplete.length; 171 | testerNames.forEach((tN) => { 172 | infoOuts[tN].totalProgress.percent = totalProgress; 173 | }); 174 | setDataOnAllPageWidgets(); 175 | }; 176 | 177 | 178 | const handleTesterBugCount = ({ testerType, sessionId, message }) => { 179 | const { infoOuts } = internals; 180 | // statTable 181 | const statTableRecord = infoOuts[testerType].statTable.records.find((r) => r.sessionId === sessionId); 182 | statTableRecord.bugs = message; 183 | // Collect 184 | const statTableRecords = (() => { 185 | const rows = []; 186 | testerNames.forEach((tN) => { rows.push(...infoOuts[tN].statTable.records); }); 187 | return rows; 188 | })(); 189 | // Calculate 190 | let newBugs = 0; 191 | statTableRecords.forEach((r) => { newBugs += r.bugs > r.threshold ? r.bugs - r.threshold : 0; }); 192 | // Populate 193 | testerNames.forEach((tN) => { 194 | if (newBugs) { 195 | const newBugsObj = infoOuts[tN].newBugs; 196 | newBugsObj.value = newBugs; 197 | newBugsObj.color = 'red'; 198 | newBugsObj.elementPadding = 4; 199 | } 200 | }); 201 | setDataOnAllPageWidgets(); 202 | }; 203 | 204 | /* $lab:coverage:off$ */ 205 | const calculateGridCoordsForLoggers = (sessionIds) => { 206 | const loggerCount = sessionIds.length; 207 | 208 | const layout = { 209 | 1: { [sessionIds[0]]: { row: 0, col: 0, rowSpan: 10.5, colSpan: 12 } }, 210 | 2: { 211 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 5.25, colSpan: 12 }, 212 | [sessionIds[1]]: { row: 5.25, col: 0, rowSpan: 5.25, colSpan: 12 } 213 | }, 214 | 3: { 215 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 12 }, 216 | [sessionIds[1]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 12 }, 217 | [sessionIds[2]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 12 } 218 | }, 219 | 4: { 220 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 12 }, 221 | [sessionIds[1]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 12 }, 222 | [sessionIds[2]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 6 }, 223 | [sessionIds[3]]: { row: 7, col: 6, rowSpan: 3.5, colSpan: 6 } 224 | }, 225 | 5: { 226 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 12 }, 227 | [sessionIds[1]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 6 }, 228 | [sessionIds[2]]: { row: 3.5, col: 6, rowSpan: 3.5, colSpan: 6 }, 229 | [sessionIds[3]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 6 }, 230 | [sessionIds[4]]: { row: 7, col: 6, rowSpan: 3.5, colSpan: 6 } 231 | }, 232 | 6: { 233 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 6 }, 234 | [sessionIds[1]]: { row: 0, col: 6, rowSpan: 3.5, colSpan: 6 }, 235 | [sessionIds[2]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 6 }, 236 | [sessionIds[3]]: { row: 3.5, col: 6, rowSpan: 3.5, colSpan: 6 }, 237 | [sessionIds[4]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 6 }, 238 | [sessionIds[5]]: { row: 7, col: 6, rowSpan: 3.5, colSpan: 6 } 239 | }, 240 | 7: { 241 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 6 }, 242 | [sessionIds[1]]: { row: 0, col: 6, rowSpan: 3.5, colSpan: 6 }, 243 | [sessionIds[2]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 6 }, 244 | [sessionIds[3]]: { row: 3.5, col: 6, rowSpan: 3.5, colSpan: 6 }, 245 | [sessionIds[4]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 4 }, 246 | [sessionIds[5]]: { row: 7, col: 4, rowSpan: 3.5, colSpan: 4 }, 247 | [sessionIds[6]]: { row: 7, col: 8, rowSpan: 3.5, colSpan: 4 } 248 | }, 249 | 8: { 250 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 6 }, 251 | [sessionIds[1]]: { row: 0, col: 6, rowSpan: 3.5, colSpan: 6 }, 252 | [sessionIds[2]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 4 }, 253 | [sessionIds[3]]: { row: 3.5, col: 4, rowSpan: 3.5, colSpan: 4 }, 254 | [sessionIds[4]]: { row: 3.5, col: 8, rowSpan: 3.5, colSpan: 4 }, 255 | [sessionIds[5]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 4 }, 256 | [sessionIds[6]]: { row: 7, col: 4, rowSpan: 3.5, colSpan: 4 }, 257 | [sessionIds[7]]: { row: 7, col: 8, rowSpan: 3.5, colSpan: 4 } 258 | }, 259 | 9: { 260 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 4 }, 261 | [sessionIds[1]]: { row: 0, col: 4, rowSpan: 3.5, colSpan: 4 }, 262 | [sessionIds[2]]: { row: 0, col: 8, rowSpan: 3.5, colSpan: 4 }, 263 | [sessionIds[3]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 4 }, 264 | [sessionIds[4]]: { row: 3.5, col: 4, rowSpan: 3.5, colSpan: 4 }, 265 | [sessionIds[5]]: { row: 3.5, col: 8, rowSpan: 3.5, colSpan: 4 }, 266 | [sessionIds[6]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 4 }, 267 | [sessionIds[7]]: { row: 7, col: 4, rowSpan: 3.5, colSpan: 4 }, 268 | [sessionIds[8]]: { row: 7, col: 8, rowSpan: 3.5, colSpan: 4 } 269 | }, 270 | 10: { 271 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 4 }, 272 | [sessionIds[1]]: { row: 0, col: 4, rowSpan: 3.5, colSpan: 4 }, 273 | [sessionIds[2]]: { row: 0, col: 8, rowSpan: 3.5, colSpan: 4 }, 274 | [sessionIds[3]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 4 }, 275 | [sessionIds[4]]: { row: 3.5, col: 4, rowSpan: 3.5, colSpan: 4 }, 276 | [sessionIds[5]]: { row: 3.5, col: 8, rowSpan: 3.5, colSpan: 4 }, 277 | [sessionIds[6]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 3 }, 278 | [sessionIds[7]]: { row: 7, col: 3, rowSpan: 3.5, colSpan: 3 }, 279 | [sessionIds[8]]: { row: 7, col: 6, rowSpan: 3.5, colSpan: 3 }, 280 | [sessionIds[9]]: { row: 7, col: 9, rowSpan: 3.5, colSpan: 3 } 281 | }, 282 | 11: { 283 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 4 }, 284 | [sessionIds[1]]: { row: 0, col: 4, rowSpan: 3.5, colSpan: 4 }, 285 | [sessionIds[2]]: { row: 0, col: 8, rowSpan: 3.5, colSpan: 4 }, 286 | [sessionIds[3]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 3 }, 287 | [sessionIds[4]]: { row: 3.5, col: 3, rowSpan: 3.5, colSpan: 3 }, 288 | [sessionIds[5]]: { row: 3.5, col: 6, rowSpan: 3.5, colSpan: 3 }, 289 | [sessionIds[6]]: { row: 3.5, col: 9, rowSpan: 3.5, colSpan: 3 }, 290 | [sessionIds[7]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 3 }, 291 | [sessionIds[8]]: { row: 7, col: 3, rowSpan: 3.5, colSpan: 3 }, 292 | [sessionIds[9]]: { row: 7, col: 6, rowSpan: 3.5, colSpan: 3 }, 293 | [sessionIds[10]]: { row: 7, col: 9, rowSpan: 3.5, colSpan: 3 } 294 | }, 295 | 12: { 296 | [sessionIds[0]]: { row: 0, col: 0, rowSpan: 3.5, colSpan: 3 }, 297 | [sessionIds[1]]: { row: 0, col: 3, rowSpan: 3.5, colSpan: 3 }, 298 | [sessionIds[2]]: { row: 0, col: 6, rowSpan: 3.5, colSpan: 3 }, 299 | [sessionIds[3]]: { row: 0, col: 9, rowSpan: 3.5, colSpan: 3 }, 300 | [sessionIds[4]]: { row: 3.5, col: 0, rowSpan: 3.5, colSpan: 3 }, 301 | [sessionIds[5]]: { row: 3.5, col: 3, rowSpan: 3.5, colSpan: 3 }, 302 | [sessionIds[6]]: { row: 3.5, col: 6, rowSpan: 3.5, colSpan: 3 }, 303 | [sessionIds[7]]: { row: 3.5, col: 9, rowSpan: 3.5, colSpan: 3 }, 304 | [sessionIds[8]]: { row: 7, col: 0, rowSpan: 3.5, colSpan: 3 }, 305 | [sessionIds[9]]: { row: 7, col: 3, rowSpan: 3.5, colSpan: 3 }, 306 | [sessionIds[10]]: { row: 7, col: 6, rowSpan: 3.5, colSpan: 3 }, 307 | [sessionIds[11]]: { row: 7, col: 9, rowSpan: 10.5, colSpan: 3 } 308 | } 309 | }; 310 | 311 | return layout[`${loggerCount}`]; 312 | }; 313 | 314 | 315 | const initCarousel = () => { 316 | const carouselPages = testerViewTypes.map((testerViewType) => (scrn) => { 317 | const grid = new contrib.grid({ rows: 12, cols: 12, screen: scrn }); // eslint-disable-line new-cap 318 | const testerType = testerViewType.testOpts.args.name; 319 | 320 | const { loggers, testerPctComplete, statTable, newBugs, totalProgress } = internals.infoOuts[testerType]; 321 | 322 | testerNames.forEach((tN) => { internals.infoOuts[tN].focussedPage = testerType === tN; }); 323 | 324 | // One per test session, per tester. 325 | loggers.forEach((logger) => { 326 | const { bufferLength, label, name, style, tags } = testerViewType.testOpts.args; 327 | logger.instance = grid.set( // eslint-disable-line no-param-reassign 328 | logger.gridCoords.row, 329 | logger.gridCoords.col, 330 | logger.gridCoords.rowSpan, 331 | logger.gridCoords.colSpan, 332 | testerViewType.testOpts.type, 333 | { bufferLength, label: `${label} - Session: ${logger.sessionId}`, name, style, tags } 334 | ); 335 | }); 336 | 337 | testerPctComplete.instance = grid.set( 338 | testerPctCompleteType.gridCoords.row, 339 | testerPctCompleteType.gridCoords.col, 340 | testerPctCompleteType.gridCoords.rowSpan, 341 | testerPctCompleteType.gridCoords.colSpan, 342 | testerPctCompleteType.type, 343 | testerPctCompleteType.args 344 | ); 345 | 346 | statTable.instance = grid.set( 347 | statTableType.gridCoords.row, 348 | statTableType.gridCoords.col, 349 | statTableType.gridCoords.rowSpan, 350 | statTableType.gridCoords.colSpan, 351 | statTableType.type, 352 | statTableType.args 353 | ); 354 | 355 | const newBugsArgs = newBugsType.args; 356 | newBugsArgs.color = newBugs.color; 357 | 358 | newBugs.instance = grid.set( 359 | newBugsType.gridCoords.row, 360 | newBugsType.gridCoords.col, 361 | newBugsType.gridCoords.rowSpan, 362 | newBugsType.gridCoords.colSpan, 363 | newBugsType.type, 364 | newBugsArgs 365 | ); 366 | 367 | totalProgress.instance = grid.set( 368 | totalProgressType.gridCoords.row, 369 | totalProgressType.gridCoords.col, 370 | totalProgressType.gridCoords.rowSpan, 371 | totalProgressType.gridCoords.colSpan, 372 | totalProgressType.type, 373 | totalProgressType.args 374 | ); 375 | 376 | setDataOnAllPageWidgets(); 377 | 378 | // There is a bug with the contrib.lcd where if the user makes the screen too small, the characters loose shape. 379 | // There is another bug with blessed, where there is no parent of the below instances, this exhibits itself in blessed/lib/widgets/element at https://github.com/chjj/blessed/blob/eab243fc7ad27f1d2932db6134f7382825ee3488/lib/widgets/element.js#L1060 380 | // https://github.com/chjj/blessed/issues/350 381 | scrn.on('resize', function resizeHandler() { 382 | loggers.forEach((logger) => logger.instance.emit('attach')); 383 | testerPctComplete.instance.parent = this; 384 | testerPctComplete.instance.emit('attach'); 385 | statTable.instance.emit('attach'); 386 | // newBugs.instance.emit('attach'); // Doesn't work, buggy blessed. 387 | totalProgress.instance.parent = this; 388 | totalProgress.instance.emit('attach'); 389 | }); 390 | }); 391 | 392 | screen.key(['escape', 'q', 'C-c'], (ch, key) => process.exit(0)); // eslint-disable-line no-unused-vars 393 | 394 | const carousel = new contrib.carousel(carouselPages, { screen, interval: 0, controlKeys: true }); // eslint-disable-line new-cap 395 | carousel.start(); 396 | }; 397 | 398 | 399 | const setDataOnLogWidget = (testPlans) => { 400 | const { infoOuts } = internals; 401 | const testerName = testerNames.find((tN) => infoOuts[tN].focussedPage); 402 | const logger = infoOuts[testerName].loggers; 403 | logger.instance.log(testPlans.find((plan) => plan.name === testerName).message); 404 | }; 405 | 406 | 407 | const initTPCarousel = (testPlans) => { 408 | const carouselPages = testerViewTypes.map((testerViewType) => (scrn) => { 409 | const grid = new contrib.grid({ rows: 12, cols: 12, screen: scrn }); // eslint-disable-line new-cap 410 | const testerType = testerViewType.testPlanOpts.args.name; 411 | 412 | testerNames.forEach((tN) => { internals.infoOuts[tN].focussedPage = testerType === tN; }); 413 | 414 | internals.infoOuts[testerType].loggers = { 415 | instance: grid.set( 416 | testerViewType.testPlanOpts.gridCoords.row, 417 | testerViewType.testPlanOpts.gridCoords.col, 418 | testerViewType.testPlanOpts.gridCoords.rowSpan, 419 | testerViewType.testPlanOpts.gridCoords.colSpan, 420 | testerViewType.testPlanOpts.type, 421 | testerViewType.testPlanOpts.args 422 | ) 423 | }; 424 | 425 | setDataOnLogWidget(testPlans); 426 | }); 427 | 428 | screen.key(['escape', 'q', 'C-c'], (ch, key) => process.exit(0)); // eslint-disable-line no-unused-vars 429 | 430 | const carousel = new contrib.carousel(carouselPages, { screen, interval: 0, controlKeys: true }); // eslint-disable-line new-cap 431 | carousel.start(); 432 | }; 433 | /* $lab:coverage:on$ */ 434 | 435 | const setupInfoOutsForTest = (testerSessions) => { 436 | testerNames.forEach((tN) => { 437 | const { infoOuts } = internals; 438 | const sessionsPerTester = testerSessions.filter((t) => t.testerType === tN); 439 | const loggerGridCoordsPerTester = calculateGridCoordsForLoggers(sessionsPerTester.map((row) => row.sessionId)); 440 | infoOuts[tN].loggers = sessionsPerTester.map((t) => ({ sessionId: t.sessionId, instance: 'To be assigned', gridCoords: loggerGridCoordsPerTester[t.sessionId] })); 441 | infoOuts[tN].statTable.records = sessionsPerTester.map((t) => ({ sessionId: t.sessionId, threshold: t.threshold, bugs: 0, pctComplete: 0 })); 442 | const testerPctCompleteTypeData = testerPctCompleteType.args.data.find((record) => record.label === tN); 443 | infoOuts[tN].testerPctComplete = { instance: 'To be assigned', percent: testerPctCompleteTypeData.percent, color: testerPctCompleteTypeData.color }; 444 | const newBugsTypeData = newBugsType.args; 445 | infoOuts[tN].newBugs = { instance: 'To be assigned', value: newBugsTypeData.display, color: newBugsTypeData.color, elementPadding: newBugsTypeData.elementPadding }; 446 | }); 447 | }; 448 | 449 | 450 | const testPlan = ({ testPlans }) => { 451 | initTPCarousel(testPlans); 452 | }; 453 | 454 | 455 | const test = (testerSessions) => { 456 | setupInfoOutsForTest(testerSessions); 457 | initCarousel(); 458 | }; 459 | 460 | const status = (cUiLogger, statusOfPurpleteamApi) => { 461 | cUiLogger.notice(statusOfPurpleteamApi, { tags: ['cUi'] }); 462 | }; 463 | 464 | export default { 465 | testPlan, 466 | test, 467 | status, 468 | handleTesterProgress, 469 | handleTesterPctComplete, 470 | handleTesterBugCount 471 | }; 472 | -------------------------------------------------------------------------------- /test/presenter/apiDecoratingAdapter_sSeAndLp.js: -------------------------------------------------------------------------------- 1 | // Use of this software is governed by the Business Source License 2 | // included in the file /licenses/bsl.md 3 | 4 | // As of the Change Date specified in that file, in accordance with 5 | // the Business Source License, use of this software will be governed 6 | // by the Apache License, Version 2.0 7 | 8 | import { readFile } from 'fs/promises'; 9 | import test from 'ava'; 10 | import sinon from 'sinon'; 11 | import nock from 'nock'; 12 | import { MockEvent, EventSource } from 'mocksse'; 13 | 14 | import config from '../../config/config.js'; 15 | import { TesterFeedbackRoutePrefix } from '../../src/strings/index.js'; 16 | 17 | const apiUrl = config.get('purpleteamApi.url'); 18 | const jobFilePath = config.get('job.fileUri'); 19 | const apiDecoratingAdapterPath = '../../src/presenter/apiDecoratingAdapter.js'; 20 | const viewPath = '../../src/view/index.js'; 21 | 22 | // As stored in the `request` object body from file: /testResources/jobs/job_4.0.0-alpha.3 23 | const expectedJob = '{\"data\":{\"type\":\"BrowserApp\",\"attributes\":{\"version\":\"4.0.0-alpha.3\",\"sutAuthentication\":{\"sitesTreeSutAuthenticationPopulationStrategy\":\"FormStandard\",\"emissaryAuthenticationStrategy\":\"FormStandard\",\"route\":\"/login\",\"usernameFieldLocater\":\"userName\",\"passwordFieldLocater\":\"password\",\"submit\":\"btn btn-danger\",\"expectedPageSourceSuccess\":\"Log Out\"},\"sutIp\":\"pt-sut-cont\",\"sutPort\":4000,\"sutProtocol\":\"http\",\"browser\":\"chrome\",\"loggedInIndicator\":\"

Found. Redirecting to /dashboard

\"},\"relationships\":{\"data\":[{\"type\":\"tlsScanner\",\"id\":\"NA\"},{\"type\":\"appScanner\",\"id\":\"lowPrivUser\"},{\"type\":\"appScanner\",\"id\":\"adminUser\"}]}},\"included\":[{\"type\":\"tlsScanner\",\"id\":\"NA\",\"attributes\":{\"tlsScannerSeverity\":\"LOW\",\"alertThreshold\":3}},{\"type\":\"appScanner\",\"id\":\"lowPrivUser\",\"attributes\":{\"sitesTreePopulationStrategy\":\"WebDriverStandard\",\"spiderStrategy\":\"Standard\",\"scannersStrategy\":\"BrowserAppStandard\",\"scanningStrategy\":\"BrowserAppStandard\",\"postScanningStrategy\":\"BrowserAppStandard\",\"reportingStrategy\":\"Standard\",\"reports\":{\"templateThemes\":[{\"name\":\"traditionalHtml\"},{\"name\":\"traditionalHtmlPlusLight\"}]},\"username\":\"user1\",\"password\":\"User1_123\",\"aScannerAttackStrength\":\"HIGH\",\"aScannerAlertThreshold\":\"LOW\",\"alertThreshold\":12},\"relationships\":{\"data\":[{\"type\":\"route\",\"id\":\"/profile\"}]}},{\"type\":\"appScanner\",\"id\":\"adminUser\",\"attributes\":{\"sitesTreePopulationStrategy\":\"WebDriverStandard\",\"spiderStrategy\":\"Standard\",\"scannersStrategy\":\"BrowserAppStandard\",\"scanningStrategy\":\"BrowserAppStandard\",\"postScanningStrategy\":\"BrowserAppStandard\",\"reportingStrategy\":\"Standard\",\"username\":\"admin\",\"password\":\"Admin_123\"},\"relationships\":{\"data\":[{\"type\":\"route\",\"id\":\"/memos\"},{\"type\":\"route\",\"id\":\"/profile\"}]}},{\"type\":\"route\",\"id\":\"/profile\",\"attributes\":{\"attackFields\":[{\"name\":\"firstName\",\"value\":\"PurpleJohn\",\"visible\":true},{\"name\":\"lastName\",\"value\":\"PurpleDoe\",\"visible\":true},{\"name\":\"ssn\",\"value\":\"PurpleSSN\",\"visible\":true},{\"name\":\"dob\",\"value\":\"12235678\",\"visible\":true},{\"name\":\"bankAcc\",\"value\":\"PurpleBankAcc\",\"visible\":true},{\"name\":\"bankRouting\",\"value\":\"0198212#\",\"visible\":true},{\"name\":\"address\",\"value\":\"PurpleAddress\",\"visible\":true},{\"name\":\"website\",\"value\":\"https://purpleteam-labs.com\",\"visible\":true},{\"name\":\"_csrf\",\"value\":\"\"},{\"name\":\"submit\",\"value\":\"\"}],\"method\":\"POST\",\"submit\":\"submit\"}},{\"type\":\"route\",\"id\":\"/memos\",\"attributes\":{\"attackFields\":[{\"name\":\"memo\",\"value\":\"PurpleMemo\",\"visible\":true}],\"method\":\"POST\",\"submit\":\"btn btn-primary\"}}]}'; // eslint-disable-line no-useless-escape 24 | 25 | test.before(async (t) => { 26 | t.context.jobFileContent = await readFile(jobFilePath, { encoding: 'utf8' }); // eslint-disable-line no-param-reassign 27 | }); 28 | 29 | 30 | // Happy day test 31 | test.serial('subscribeToTesterFeedback SSE and handlers - given a mock event for each of the available testers sessions - given invocation of all the tester events - relevant handler instances should be run', async (t) => { 32 | const { context: { jobFileContent } } = t; 33 | nock.cleanAll(); 34 | const apiResponse = { 35 | testerStatuses: [ 36 | { 37 | name: 'app', 38 | message: 'Tester initialised.' 39 | }, 40 | { 41 | name: 'server', 42 | message: 'No server testing available currently. The server tester is currently in-active.' 43 | }, 44 | { 45 | name: 'tls', 46 | message: 'Tester initialised.' 47 | } 48 | ], 49 | testerFeedbackCommsMedium: 'sse' 50 | }; 51 | 52 | nock(apiUrl).post('/test', expectedJob).reply(200, apiResponse); 53 | const { default: view } = await import(viewPath); 54 | const { default: aPi } = await import(apiDecoratingAdapterPath); 55 | 56 | const testStub = sinon.stub(view, 'test'); 57 | view.test = testStub; 58 | 59 | const viewHandlerStats = { 60 | testerProgress: { 61 | expectedCallCount: 8, 62 | actualCallCount: 0, 63 | params: [ 64 | // Initial 65 | { 66 | matched: undefined, 67 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 'Tester initialised.', ptLogger: true } 68 | }, 69 | { 70 | matched: undefined, 71 | expected: { testerType: 'app', sessionId: 'adminUser', message: 'Tester initialised.', ptLogger: true } 72 | }, 73 | { 74 | matched: undefined, 75 | expected: { testerType: 'server', sessionId: 'NA', message: 'No server testing available currently. The server tester is currently in-active.', ptLogger: true } 76 | }, 77 | { 78 | matched: undefined, 79 | expected: { testerType: 'tls', sessionId: 'NA', message: 'Tester initialised.', ptLogger: true } 80 | }, 81 | // SSE 82 | { 83 | matched: undefined, 84 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 'Initialising SSE subscription to "app-lowPrivUser" channel for the event "testerProgress"', ptLogger: true } 85 | }, 86 | { 87 | matched: undefined, 88 | expected: { testerType: 'app', sessionId: 'adminUser', message: 'Initialising SSE subscription to "app-adminUser" channel for the event "testerProgress"', ptLogger: true } 89 | }, 90 | { 91 | matched: undefined, 92 | expected: { testerType: 'server', sessionId: 'NA', message: 'Initialising SSE subscription to "server-NA" channel for the event "testerProgress"', ptLogger: true } 93 | }, 94 | { 95 | matched: undefined, 96 | expected: { testerType: 'tls', sessionId: 'NA', message: 'Initialising SSE subscription to "tls-NA" channel for the event "testerProgress"', ptLogger: true } 97 | } 98 | ] 99 | }, 100 | testerPctComplete: { 101 | expectedCallCount: 4, 102 | actualCallCount: 0, 103 | params: [ 104 | // SSE 105 | { 106 | matched: undefined, 107 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 8 } 108 | }, 109 | { 110 | matched: undefined, 111 | expected: { testerType: 'app', sessionId: 'adminUser', message: 99 } 112 | }, 113 | { 114 | matched: undefined, 115 | expected: { testerType: 'server', sessionId: 'NA', message: 1 } 116 | }, 117 | { 118 | matched: undefined, 119 | expected: { testerType: 'tls', sessionId: 'NA', message: 0 } 120 | } 121 | ] 122 | }, 123 | testerBugCount: { 124 | expectedCallCount: 4, 125 | actualCallCount: 0, 126 | params: [ 127 | // SSE 128 | { 129 | matched: undefined, 130 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 3 } 131 | }, 132 | { 133 | matched: undefined, 134 | expected: { testerType: 'app', sessionId: 'adminUser', message: 7 } 135 | }, 136 | { 137 | matched: undefined, 138 | expected: { testerType: 'server', sessionId: 'NA', message: 1 } 139 | }, 140 | { 141 | matched: undefined, 142 | expected: { testerType: 'tls', sessionId: 'NA', message: 900 } 143 | } 144 | ] 145 | } 146 | }; 147 | 148 | new MockEvent({ // eslint-disable-line no-new 149 | url: `${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/app/lowPrivUser`, 150 | setInterval: 1, 151 | responses: [ 152 | { lastEventId: 'one', type: 'testerProgress', data: '{ "progress": "Initialising SSE subscription to \\"app-lowPrivUser\\" channel for the event \\"testerProgress\\"" }' }, 153 | { lastEventId: 'two', type: 'testerPctComplete', data: '{ "pctComplete": 8 }' }, 154 | { lastEventId: 'three', type: 'testerBugCount', data: '{ "bugCount": 3 }' } 155 | ] 156 | }); 157 | new MockEvent({ // eslint-disable-line no-new 158 | url: `${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/app/adminUser`, 159 | setInterval: 1, 160 | responses: [ 161 | { lastEventId: 'four', type: 'testerProgress', data: '{ "progress": "Initialising SSE subscription to \\"app-adminUser\\" channel for the event \\"testerProgress\\"" }' }, 162 | { lastEventId: 'five', type: 'testerPctComplete', data: '{ "pctComplete": 99 }' }, 163 | { lastEventId: 'six', type: 'testerBugCount', data: '{ "bugCount": 7 }' } 164 | ] 165 | }); 166 | new MockEvent({ // eslint-disable-line no-new 167 | url: `${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/server/NA`, 168 | setInterval: 1, 169 | responses: [ 170 | { lastEventId: 'seven', type: 'testerProgress', data: '{ "progress": "Initialising SSE subscription to \\"server-NA\\" channel for the event \\"testerProgress\\"" }' }, 171 | { lastEventId: 'eight', type: 'testerPctComplete', data: '{ "pctComplete": 1 }' }, 172 | { lastEventId: 'nine', type: 'testerBugCount', data: '{ "bugCount": 1 }' } 173 | ] 174 | }); 175 | new MockEvent({ // eslint-disable-line no-new 176 | url: `${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/tls/NA`, 177 | setInterval: 1, 178 | responses: [ 179 | { lastEventId: 'ten', type: 'testerProgress', data: '{ "progress": "Initialising SSE subscription to \\"tls-NA\\" channel for the event \\"testerProgress\\"" }' }, 180 | { lastEventId: 'eleven', type: 'testerPctComplete', data: '{ "pctComplete": 0 }' }, 181 | { lastEventId: 'twelve', type: 'testerBugCount', data: '{ "bugCount": 900 }' } 182 | ] 183 | }); 184 | 185 | t.teardown(() => { 186 | nock.cleanAll(); 187 | testStub.restore(); 188 | }); 189 | 190 | await new Promise((resolve, reject) => { 191 | const resolveIfAllHandlerCallCountsAreDone = () => { 192 | (viewHandlerStats.testerProgress.actualCallCount === viewHandlerStats.testerProgress.expectedCallCount 193 | && viewHandlerStats.testerPctComplete.actualCallCount === viewHandlerStats.testerPctComplete.expectedCallCount 194 | && viewHandlerStats.testerBugCount.actualCallCount === viewHandlerStats.testerBugCount.expectedCallCount 195 | && viewHandlerStats.testerProgress.params.every((p) => p.matched) 196 | && viewHandlerStats.testerPctComplete.params.every((p) => p.matched) 197 | && viewHandlerStats.testerBugCount.params.every((p) => p.matched)) 198 | && resolve(); 199 | }; 200 | 201 | const handler = { 202 | get(target, property, receiver) { 203 | if (property === 'handleTesterProgress') { 204 | return ({ testerType, sessionId, message, ptLogger }) => { 205 | viewHandlerStats.testerProgress.actualCallCount += 1; 206 | const matchIndex = viewHandlerStats.testerProgress.params.findIndex((p) => 207 | p.expected.testerType === testerType // eslint-disable-line implicit-arrow-linebreak 208 | && p.expected.sessionId === sessionId 209 | && p.expected.message === message 210 | && !!ptLogger 211 | ); // eslint-disable-line function-paren-newline 212 | matchIndex < 0 && reject(new Error('An expected match was not found for the parameter set of the view\'s handleTesterProgress method.')); 213 | viewHandlerStats.testerProgress.params[matchIndex].matched === true && reject(new Error(`A "testerProgress" event with the same details was already matched. The parameter was: { testerType: ${testerType}, sessionId: ${sessionId}, message: ${message}, ptLogger: ${ptLogger} }`)); 214 | viewHandlerStats.testerProgress.params[matchIndex].matched = true; 215 | resolveIfAllHandlerCallCountsAreDone(); 216 | }; 217 | } 218 | if (property === 'handleTesterPctComplete') { 219 | return ({ testerType, sessionId, message }) => { 220 | viewHandlerStats.testerPctComplete.actualCallCount += 1; 221 | const matchIndex = viewHandlerStats.testerPctComplete.params.findIndex((p) => 222 | p.expected.testerType === testerType // eslint-disable-line implicit-arrow-linebreak 223 | && p.expected.sessionId === sessionId 224 | && p.expected.message === message 225 | ); // eslint-disable-line function-paren-newline 226 | matchIndex < 0 && reject(new Error('An expected match was not found for the parameter set of the view\'s handleTesterPctComplete method.')); 227 | viewHandlerStats.testerPctComplete.params[matchIndex].matched === true && reject(new Error('A "testerPctComplete" event with the same details was already matched.')); 228 | viewHandlerStats.testerPctComplete.params[matchIndex].matched = true; 229 | resolveIfAllHandlerCallCountsAreDone(); 230 | }; 231 | } 232 | if (property === 'handleTesterBugCount') { 233 | return ({ testerType, sessionId, message }) => { 234 | viewHandlerStats.testerBugCount.actualCallCount += 1; 235 | const matchIndex = viewHandlerStats.testerBugCount.params.findIndex((p) => 236 | p.expected.testerType === testerType // eslint-disable-line implicit-arrow-linebreak 237 | && p.expected.sessionId === sessionId 238 | && p.expected.message === message 239 | ); // eslint-disable-line function-paren-newline 240 | matchIndex < 0 && reject(new Error('An expected match was not found for the parameter set of the view\'s handleTesterBugCount method.')); 241 | viewHandlerStats.testerBugCount.params[matchIndex].matched === true && reject(new Error('A "testerBugCount" event with the same details was already matched.')); 242 | viewHandlerStats.testerBugCount.params[matchIndex].matched = true; 243 | resolveIfAllHandlerCallCountsAreDone(); 244 | }; 245 | } 246 | return (...args) => { 247 | target[property].call(receiver, ...args); 248 | }; 249 | } 250 | }; 251 | 252 | const proxyView = new Proxy(view, handler); 253 | 254 | aPi.inject({ /* Model, */ view: proxyView, /* ptLogger, cUiLogger */ EventSource }); 255 | aPi.test(jobFileContent); 256 | }); 257 | 258 | const expectedTesterSessions = [ // Taken from the model test 259 | { testerType: 'app', sessionId: 'lowPrivUser', threshold: 12 }, 260 | { testerType: 'app', sessionId: 'adminUser', threshold: 0 }, 261 | { testerType: 'server', sessionId: 'NA', threshold: 0 }, 262 | { testerType: 'tls', sessionId: 'NA', threshold: 3 } 263 | ]; 264 | 265 | t.deepEqual(testStub.getCall(0).args[0], expectedTesterSessions); 266 | t.is(testStub.callCount, 1); 267 | }); 268 | 269 | 270 | // Happy day test 271 | test.serial('longPollTesterFeedback LP and handlers - given a mock event for each of the available testers sessions - given invocation of all the tester events - relevant handler instances should be run', async (t) => { 272 | const { context: { jobFileContent } } = t; 273 | nock.cleanAll(); 274 | const apiResponse = { 275 | testerStatuses: [ 276 | { 277 | name: 'app', 278 | message: 'Tester initialised.' 279 | }, 280 | { 281 | name: 'server', 282 | message: 'No server testing available currently. The server tester is currently in-active.' 283 | }, 284 | { 285 | name: 'tls', 286 | message: 'Tester initialised.' 287 | } 288 | ], 289 | testerFeedbackCommsMedium: 'lp' 290 | }; 291 | 292 | nock(apiUrl /* , { allowUnmocked: true } */).post('/test', expectedJob).reply(200, apiResponse); 293 | const { default: view } = await import(viewPath); 294 | const { default: aPi } = await import(apiDecoratingAdapterPath); 295 | 296 | const testStub = sinon.stub(view, 'test'); 297 | view.test = testStub; 298 | 299 | const viewHandlerStats = { 300 | testerProgress: { 301 | expectedCallCount: 8, 302 | actualCallCount: 0, 303 | params: [ 304 | // Initial 305 | { 306 | matched: undefined, 307 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 'Tester initialised.', ptLogger: true } 308 | }, 309 | { 310 | matched: undefined, 311 | expected: { testerType: 'app', sessionId: 'adminUser', message: 'Tester initialised.', ptLogger: true } 312 | }, 313 | { 314 | matched: undefined, 315 | expected: { testerType: 'server', sessionId: 'NA', message: 'No server testing available currently. The server tester is currently in-active.', ptLogger: true } 316 | }, 317 | { 318 | matched: undefined, 319 | expected: { testerType: 'tls', sessionId: 'NA', message: 'Tester initialised.', ptLogger: true } 320 | }, 321 | // LP 322 | { 323 | matched: undefined, 324 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 'response one', ptLogger: true } 325 | }, 326 | { 327 | matched: undefined, 328 | expected: { testerType: 'app', sessionId: 'adminUser', message: 'response two', ptLogger: true } 329 | }, 330 | { 331 | matched: undefined, 332 | expected: { testerType: 'server', sessionId: 'NA', message: 'response three', ptLogger: true } 333 | }, 334 | { 335 | matched: undefined, 336 | expected: { testerType: 'tls', sessionId: 'NA', message: 'response four', ptLogger: true } 337 | } 338 | ] 339 | }, 340 | testerPctComplete: { 341 | expectedCallCount: 4, 342 | actualCallCount: 0, 343 | params: [ 344 | // LP 345 | { 346 | matched: undefined, 347 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 8 } 348 | }, 349 | { 350 | matched: undefined, 351 | expected: { testerType: 'app', sessionId: 'adminUser', message: 99 } 352 | }, 353 | { 354 | matched: undefined, 355 | expected: { testerType: 'server', sessionId: 'NA', message: 1 } 356 | }, 357 | { 358 | matched: undefined, 359 | expected: { testerType: 'tls', sessionId: 'NA', message: 0 } 360 | } 361 | ] 362 | }, 363 | testerBugCount: { 364 | expectedCallCount: 4, 365 | actualCallCount: 0, 366 | params: [ 367 | // LP 368 | { 369 | matched: undefined, 370 | expected: { testerType: 'app', sessionId: 'lowPrivUser', message: 3 } 371 | }, 372 | { 373 | matched: undefined, 374 | expected: { testerType: 'app', sessionId: 'adminUser', message: 7 } 375 | }, 376 | { 377 | matched: undefined, 378 | expected: { testerType: 'server', sessionId: 'NA', message: 1 } 379 | }, 380 | { 381 | matched: undefined, 382 | expected: { testerType: 'tls', sessionId: 'NA', message: 900 } 383 | } 384 | ] 385 | } 386 | }; 387 | 388 | 389 | nock(apiUrl) 390 | .get(`/${TesterFeedbackRoutePrefix('lp')}/app/lowPrivUser`) 391 | .reply(200, [ 392 | { data: { progress: 'response one' }, event: 'testerProgress', id: 1 }, 393 | { data: { pctComplete: 8 }, event: 'testerPctComplete', id: 2 }, 394 | { data: { bugCount: 3 }, event: 'testerBugCount', id: 3 } 395 | ]) 396 | .get(`/${TesterFeedbackRoutePrefix('lp')}/app/lowPrivUser`) 397 | .reply(200, [ 398 | { data: { progress: 'Tester finished' }, event: 'testerProgress', id: 4 } 399 | ]) 400 | .get(`/${TesterFeedbackRoutePrefix('lp')}/app/adminUser`) 401 | .reply(200, [ 402 | { data: { progress: 'response two' }, event: 'testerProgress', id: 5 }, 403 | { data: { pctComplete: 99 }, event: 'testerPctComplete', id: 6 }, 404 | { data: { bugCount: 7 }, event: 'testerBugCount', id: 7 } 405 | ]) 406 | .get(`/${TesterFeedbackRoutePrefix('lp')}/app/adminUser`) 407 | .reply(200, [ 408 | { data: { progress: 'Tester finished' }, event: 'testerProgress', id: 8 } 409 | ]) 410 | .get(`/${TesterFeedbackRoutePrefix('lp')}/server/NA`) 411 | .reply(200, [ 412 | { data: { progress: 'response three' }, event: 'testerProgress', id: 9 }, 413 | { data: { pctComplete: 1 }, event: 'testerPctComplete', id: 10 }, 414 | { data: { bugCount: 1 }, event: 'testerBugCount', id: 11 } 415 | ]) 416 | .get(`/${TesterFeedbackRoutePrefix('lp')}/server/NA`) 417 | .reply(200, [ 418 | { data: { progress: 'Tester finished' }, event: 'testerProgress', id: 12 } 419 | ]) 420 | .get(`/${TesterFeedbackRoutePrefix('lp')}/tls/NA`) 421 | .reply(200, [ 422 | { data: { progress: 'response four' }, event: 'testerProgress', id: 13 }, 423 | { data: { pctComplete: 0 }, event: 'testerPctComplete', id: 14 }, 424 | { data: { bugCount: 900 }, event: 'testerBugCount', id: 15 } 425 | ]) 426 | .get(`/${TesterFeedbackRoutePrefix('lp')}/tls/NA`) 427 | .reply(200, [ 428 | { data: { progress: 'Tester finished' }, event: 'testerProgress', id: 16 } 429 | ]); 430 | 431 | t.teardown(() => { 432 | nock.cleanAll(); 433 | testStub.restore(); 434 | }); 435 | 436 | await new Promise((resolve, reject) => { 437 | const resolveIfAllHandlerCallCountsAreDone = () => { 438 | (viewHandlerStats.testerProgress.actualCallCount === viewHandlerStats.testerProgress.expectedCallCount 439 | && viewHandlerStats.testerPctComplete.actualCallCount === viewHandlerStats.testerPctComplete.expectedCallCount 440 | && viewHandlerStats.testerBugCount.actualCallCount === viewHandlerStats.testerBugCount.expectedCallCount 441 | && viewHandlerStats.testerProgress.params.every((p) => p.matched) 442 | && viewHandlerStats.testerPctComplete.params.every((p) => p.matched) 443 | && viewHandlerStats.testerBugCount.params.every((p) => p.matched)) 444 | && resolve(); 445 | }; 446 | 447 | const handler = { 448 | get(target, property, receiver) { 449 | if (property === 'handleTesterProgress') { 450 | return ({ testerType, sessionId, message, ptLogger }) => { 451 | viewHandlerStats.testerProgress.actualCallCount += 1; 452 | const matchIndex = viewHandlerStats.testerProgress.params.findIndex((p) => 453 | p.expected.testerType === testerType // eslint-disable-line implicit-arrow-linebreak 454 | && p.expected.sessionId === sessionId 455 | && p.expected.message === message 456 | && !!ptLogger 457 | ); // eslint-disable-line function-paren-newline 458 | matchIndex < 0 && reject(new Error('An expected match was not found for the parameter set of the view\'s handleTesterProgress method.')); 459 | viewHandlerStats.testerProgress.params[matchIndex].matched === true && reject(new Error(`A "testerProgress" event with the same details was already matched. The parameter was: { testerType: ${testerType}, sessionId: ${sessionId}, message: ${message}, ptLogger: ${ptLogger} }`)); 460 | viewHandlerStats.testerProgress.params[matchIndex].matched = true; 461 | resolveIfAllHandlerCallCountsAreDone(); 462 | }; 463 | } 464 | if (property === 'handleTesterPctComplete') { 465 | return ({ testerType, sessionId, message }) => { 466 | viewHandlerStats.testerPctComplete.actualCallCount += 1; 467 | const matchIndex = viewHandlerStats.testerPctComplete.params.findIndex((p) => 468 | p.expected.testerType === testerType // eslint-disable-line implicit-arrow-linebreak 469 | && p.expected.sessionId === sessionId 470 | && p.expected.message === message 471 | ); // eslint-disable-line function-paren-newline 472 | matchIndex < 0 && reject(new Error('An expected match was not found for the parameter set of the view\'s handleTesterPctComplete method.')); 473 | viewHandlerStats.testerPctComplete.params[matchIndex].matched === true && reject(new Error('A "testerPctComplete" event with the same details was already matched.')); 474 | viewHandlerStats.testerPctComplete.params[matchIndex].matched = true; 475 | resolveIfAllHandlerCallCountsAreDone(); 476 | }; 477 | } 478 | if (property === 'handleTesterBugCount') { 479 | return ({ testerType, sessionId, message }) => { 480 | viewHandlerStats.testerBugCount.actualCallCount += 1; 481 | const matchIndex = viewHandlerStats.testerBugCount.params.findIndex((p) => 482 | p.expected.testerType === testerType // eslint-disable-line implicit-arrow-linebreak 483 | && p.expected.sessionId === sessionId 484 | && p.expected.message === message 485 | ); // eslint-disable-line function-paren-newline 486 | matchIndex < 0 && reject(new Error('An expected match was not found for the parameter set of the view\'s handleTesterBugCount method.')); 487 | viewHandlerStats.testerBugCount.params[matchIndex].matched === true && reject(new Error('A "testerBugCount" event with the same details was already matched.')); 488 | viewHandlerStats.testerBugCount.params[matchIndex].matched = true; 489 | resolveIfAllHandlerCallCountsAreDone(); 490 | }; 491 | } 492 | return (...args) => { 493 | target[property].call(receiver, ...args); 494 | }; 495 | } 496 | }; 497 | 498 | const proxyView = new Proxy(view, handler); 499 | 500 | aPi.inject({ /* Model, */ view: proxyView /* , ptLogger *//* , cUiLogger, EventSource */ }); 501 | aPi.test(jobFileContent); 502 | }); 503 | 504 | const expectedTesterSessions = [ // Taken from the model test 505 | { testerType: 'app', sessionId: 'lowPrivUser', threshold: 12 }, 506 | { testerType: 'app', sessionId: 'adminUser', threshold: 0 }, 507 | { testerType: 'server', sessionId: 'NA', threshold: 0 }, 508 | { testerType: 'tls', sessionId: 'NA', threshold: 3 } 509 | ]; 510 | 511 | t.deepEqual(testStub.getCall(0).args[0], expectedTesterSessions); 512 | t.is(testStub.callCount, 1); 513 | }); 514 | -------------------------------------------------------------------------------- /src/presenter/apiDecoratingAdapter.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2017-2022 BinaryMist Limited. All rights reserved. 2 | 3 | // Use of this software is governed by the Business Source License 4 | // included in the file /licenses/bsl.md 5 | 6 | // As of the Change Date specified in that file, in accordance with 7 | // the Business Source License, use of this software will be governed 8 | // by the Apache License, Version 2.0 9 | 10 | import { promises as fsPromises } from 'fs'; 11 | import got from 'got'; 12 | import importedPtLogger, { init as importedInitPtLogger } from 'purpleteam-logger'; 13 | import importedEventSource from 'eventsource'; 14 | import Bourne from '@hapi/bourne'; 15 | import { createRequire } from 'module'; 16 | import ImportedModel from '../models/model.js'; 17 | import importedView from '../view/index.js'; 18 | import { TesterUnavailable, TesterFeedbackRoutePrefix, NowAsFileName } from '../strings/index.js'; 19 | import config from '../../config/config.js'; 20 | 21 | const require = createRequire(import.meta.url); 22 | const { name: pkgName, version: pkgVersion, description: pkgDescription, homepage: pkgHomepage } = require('../../package'); 23 | 24 | const apiUrl = config.get('purpleteamApi.url'); 25 | 26 | const env = ((e) => { 27 | const supportedEnvs = ['cloud', 'local']; 28 | return supportedEnvs.find((sE) => e.startsWith(sE)); 29 | })(config.get('env')); 30 | 31 | const internals = { 32 | Model: undefined, 33 | view: undefined, 34 | ptLogger: undefined, 35 | cUiLogger: undefined, 36 | EventSource: undefined, 37 | longPoll: { 38 | nullProgressCounter: 0, 39 | nullProgressMaxRetries: config.get('testerFeedbackComms.longPoll.nullProgressMaxRetries') 40 | } 41 | }; 42 | 43 | const inject = ({ 44 | Model = ImportedModel, 45 | view = importedView, 46 | ptLogger = importedPtLogger, 47 | cUiLogger = importedInitPtLogger(config.get('loggers.cUi')), 48 | EventSource = importedEventSource 49 | }) => { 50 | internals.Model = Model; 51 | internals.view = view; 52 | internals.ptLogger = ptLogger; 53 | internals.cUiLogger = cUiLogger; 54 | internals.EventSource = EventSource; 55 | }; 56 | 57 | const getJobFile = async (filePath) => { 58 | try { 59 | const fileContents = await fsPromises.readFile(filePath, { encoding: 'utf8' }); 60 | return fileContents; 61 | } catch (err) { 62 | internals.cUiLogger.error(`Could not read file: ${filePath}, the error was: ${err}.`, { tags: ['apiDecoratingAdapter'] }); 63 | throw err; 64 | } 65 | }; 66 | 67 | const gotCloudAuth = got.extend({ 68 | prefixUrl: config.get('purpleteamAuth.url'), 69 | body: 'grant_type=client_credentials', 70 | responseType: 'json', 71 | resolveBodyOnly: true, 72 | headers: { 73 | 'user-agent': `${pkgName}/${pkgVersion} ${pkgDescription} ${pkgHomepage}`, 74 | 'Content-type': 'application/x-www-form-urlencoded', 75 | Authorization: `Basic ${(() => Buffer.from(`${config.get('purpleteamAuth.appClientId')}:${config.get('purpleteamAuth.appClientSecret')}`).toString('base64'))()}` 76 | } 77 | }); 78 | 79 | const getAccessToken = async () => { 80 | let accessToken; 81 | await gotCloudAuth.post().then((response) => { 82 | accessToken = response.access_token; 83 | }).catch((error) => { 84 | const knownErrors = [ 85 | { ENOTFOUND: 'The authorisation service appears to be down, or an incorrect URL has been specified in the CLI config.' }, // error.code 86 | { 'Response code 400 (Bad Request)': 'The authorisation service responded with 400 (Bad Request). This could be because your authentication details are incorrect.' } // error.message 87 | ]; 88 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.code)) 89 | ?? knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.message)) 90 | ?? { default: `An unknown error occurred while attempting to get the access token. Error follows: ${error}` }; 91 | internals.cUiLogger.crit(Object.values(knownError)[0], { tags: ['apiDecoratingAdapter'] }); 92 | }); 93 | return accessToken; 94 | }; 95 | 96 | /* eslint-disable no-param-reassign */ 97 | const gotPt = got.extend({ 98 | prefixUrl: { local: `${apiUrl}/`, cloud: `${apiUrl}/${config.get('purpleteamApi.stage')}/${config.get('purpleteamApi.customerId')}/` }[env], 99 | headers: { 100 | local: { 'user-agent': `${pkgName}/${pkgVersion} ${pkgDescription} ${pkgHomepage}` }, 101 | cloud: { 'user-agent': `${pkgName}/${pkgVersion} ${pkgDescription} ${pkgHomepage}`, 'x-api-key': config.get('purpleteamApi.apiKey') } 102 | }[env], 103 | retry: { 104 | limit: 2, // Default is 2 105 | methods: [/* Defaults */'GET', 'PUT', 'HEAD', 'OPTIONS', 'TRACE'/* Non-defaults *//* , 'POST' */], 106 | statusCodes: [/* Defaults */408, 413, 429, 500, 502, 503, 504, 521, 522, 524, /* Non-defaults */ 512] 107 | }, 108 | hooks: { 109 | beforeRequest: { 110 | local: [], 111 | cloud: [ 112 | async (options) => { 113 | if (!gotPt.defaults.options.headers.authorization) { 114 | gotPt.defaults.options.headers = { 115 | ...(gotPt.defaults.options.headers ?? {}), 116 | authorization: `Bearer ${await getAccessToken()}` 117 | }; 118 | options.headers.authorization = gotPt.defaults.options.headers.authorization; 119 | } 120 | } 121 | ] 122 | }[env], 123 | afterResponse: { 124 | local: [], 125 | cloud: [ 126 | // We use this function for custom retry logic. 127 | async (response, retryWithMergedOptions) => { 128 | if (response.statusCode === 401 || response.statusCode === 403) { // Unauthorised or Forbidden 129 | const optionAugmentations = { headers: { authorization: `Bearer ${await getAccessToken()}` } }; 130 | // Save for further requests. 131 | gotPt.defaults.options.merge(optionAugmentations); 132 | // Make a new retry 133 | return retryWithMergedOptions(optionAugmentations); 134 | } 135 | return response; 136 | } 137 | ] 138 | }[env], 139 | beforeError: { 140 | local: [ 141 | (error) => { 142 | const knownErrors = [ 143 | { EHOSTUNREACH: 'orchestrator is down, or an incorrect URL has been specified in the CLI config.' } // error.code 144 | // Others? 145 | ]; 146 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.code)); 147 | if (knownError) { 148 | error.processed = true; 149 | const [message] = Object.values(knownError); 150 | error.message = message; 151 | } 152 | return error; 153 | } 154 | ], 155 | cloud: [ 156 | (error) => { 157 | const knownErrors = [ 158 | { 'Response code 500 (Internal Server Error)': 'purpleteam Cloud API responded with "orchestrator is down".' }, // error.message 159 | { ENOTFOUND: 'purpleteam Cloud API is down, or an incorrect URL has been specified in the CLI config.' }, // error.code 160 | { 'Response code 401 (Unauthorized)': 'You are not authorised to access the purpleteam Cloud API.' }, // error.message 161 | { 'Response code 504 (Gateway Timeout)': 'purpleteam Cloud API responded with "gateway timeout".' } // error.message 162 | ]; 163 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.code)) 164 | ?? knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.message)); 165 | if (knownError) { 166 | error.processed = true; 167 | const [message] = Object.values(knownError); 168 | error.message = message; 169 | } 170 | return error; 171 | } 172 | ] 173 | }[env] 174 | }, 175 | mutableDefaults: true 176 | }); 177 | /* eslint-enable no-param-reassign */ 178 | 179 | const requestStatus = async () => { 180 | const { view, cUiLogger } = internals; 181 | await gotPt.get('status').then((response) => { 182 | view.status(cUiLogger, response.body); 183 | }).catch((error) => { 184 | view.status(cUiLogger, error.processed ? error.message : `An unknown error occurred while attempting to get the status. Error follows: ${error}`); 185 | }); 186 | }; 187 | 188 | const requestOutcomes = async () => { 189 | const outcomesFilePath = `${config.get('outcomes.filePath')}`.replace('time', NowAsFileName()); 190 | const { defaults: { options } } = gotPt; 191 | let result; 192 | await gotPt.get('outcomes', { 193 | responseType: 'buffer', 194 | resolveBodyOnly: true, 195 | headers: { Accept: 'application/zip' }, 196 | retry: { 197 | // Outcomes file may not be ready yet, so retry 198 | statusCodes: [...options.retry.statusCodes, 404], 199 | calculateDelay: ({ attemptCount, retryOptions /* , error, computedValue */ }) => { 200 | if (attemptCount > retryOptions.limit) return 0; 201 | return attemptCount * 2000; 202 | } 203 | } 204 | }).then(async (response) => { 205 | await fsPromises.writeFile(outcomesFilePath, response) 206 | .then(() => { result = `Outcomes have been downloaded to: ${outcomesFilePath}.`; }) 207 | .catch((error) => { result = `Error occurred while writing the outcomes file: ${outcomesFilePath}, error was: ${error}.`; }); 208 | }).catch((error) => { 209 | // Errors not tested. 210 | result = `Error occurred while downloading the outcomes file, error was: ${error.processed ? error.message : error}.`; 211 | }); 212 | return result; 213 | }; 214 | 215 | const requestTestOrTestPlan = async (jobFileContents, route) => { 216 | const { cUiLogger } = internals; 217 | let result; 218 | const retrying = (() => ({ 219 | // The CLI needs to stop trying before the back-end fails due to containers not being up quickly enough. 220 | // If the CLI retries after the back-end (specifically the App Tester) has given up and issued it's "Tester failure:" message 221 | // Then the Test Run will be attempted to be started again, this could result in an endless loop of retries. 222 | // Currently the App Tester takes the longest to initialise due to having to spin up it's s2 containers. 223 | // The back-end (specifically App Tester) timeouts (stored in config) are: 224 | // s2Containers.serviceDiscoveryServiceInstances.timeoutToBeAvailable: currently: 200000 225 | // s2Containers.responsive.timeout: currently: 120000 226 | // Which is a total of 320000. So the CLI needs to stop retrying before that. The sum of our retries is 313000 7 seconds before back-end max. 227 | 228 | // 20 seconds is the longPoll timeout in the orchestrator's testerWatcher so that it's well under API Gateway's 30 seconds. The config property is testerFeedbackComms.longPoll.timeout 229 | // 15 seconds is the retry timeout for the TLS Tester keepMessageChannelAlive. The config property is messageChannelHeartBeatInterval. 230 | // There is also a counter in handleLongPollTesterEvents for nullProgressMaxRetries. 231 | 232 | // The timeout.response value appears to be added to what's returned from calculateDelay. 233 | // No need to timeout or retry for local, as we don't have the AWS API Gateway 30 second timeout to contend with. So we don't timeout at all. 234 | cloud: { 235 | timeout: { response: 10000 }, 236 | retry: { 237 | calculateDelay: ({ attemptCount /* , retryOptions, error , computedValue */ }) => { // eslint-disable-line arrow-body-style 238 | attemptCount === 1 && console.log('\n\n'); // eslint-disable-line no-console 239 | attemptCount > 1 && cUiLogger.notice(`Retrying Tester initialisation. Attempt ${attemptCount} of 15.` /* , { tags: ['apiDecoratingAdapter'] } */); 240 | const attemptCountInterval = { /* eslint-disable no-multi-spaces */ 241 | 1: 13000, // 23000 242 | 2: 13000, // 23000 243 | 3: 13000, // 23000 244 | 4: 13000, // 23000 245 | 5: 13000, // 23000 246 | 6: 13000, // 23000 247 | 7: 13000, // 23000 248 | 8: 13000, // 23000 249 | 9: 13000, // 23000 250 | 10: 13000, // 23000 251 | 11: 13000, // 23000 252 | 12: 13000, // 23000 253 | 13: 13000, // 23000 254 | 14: 4000, // 14000 255 | 15: 0 // Cancel 256 | }; /* eslint-enable no-multi-spaces */ 257 | return attemptCountInterval[attemptCount]; 258 | } 259 | } 260 | } 261 | // If there was a need for local retry, do the same thing as above, we've tested this. Bear in mind it introduces complexity and possible eadge cases. 262 | }[env]))(); 263 | 264 | await gotPt.post(route, { 265 | headers: { 'Content-Type': 'application/vnd.api+json' }, 266 | json: jobFileContents, 267 | responseType: 'json', 268 | resolveBodyOnly: true, 269 | ...retrying 270 | }).then((response) => { 271 | // We can't return from here until the Testers are running. 272 | result = response; 273 | }).catch((error) => { 274 | if (error.processed) { 275 | cUiLogger.crit(error.message, { tags: ['apiDecoratingAdapter'] }); 276 | } else { 277 | const knownErrors = [ 278 | // Server-side will catch invalid job against purpleteam schema and respond with ValidationError... only if client-side validation is disabled though. 279 | { ValidationError: error.message }, // error.name 280 | // Client-side will catch invalid JSON 281 | { 400: `Invalid syntax in "Job" sent to the purpleteam API. Details follow:\n${error.response?.body?.message}` }, // error.response.statusCode 282 | { 512: error.response?.body?.message } // error.response.statusCode 283 | // Others? 284 | ]; 285 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.code)) 286 | ?? knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.message)) 287 | ?? knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.name)) 288 | ?? knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.response?.statusCode)) 289 | ?? { default: `Unknown error. Error follows: ${error}` }; 290 | cUiLogger.crit(`Error occurred while attempting to communicate with the purpleteam API. Error was: ${Object.values(knownError)[0]}`, { tags: ['apiDecoratingAdapter'] }); 291 | } 292 | }); 293 | return result; 294 | }; 295 | const requestTest = async (jobFileContents) => requestTestOrTestPlan(jobFileContents, 'test'); 296 | const requestTestPlan = async (jobFileContents) => requestTestOrTestPlan(jobFileContents, 'testplan'); 297 | 298 | const handleServerSentTesterEvents = async (event, model, testerNameAndSession) => { 299 | // If event.origin is incorrect, eventSource drops the message and this handler is not called. 300 | const eventDataPropPascalCase = event.type.replace('tester', ''); 301 | const eventDataProp = `${eventDataPropPascalCase.charAt(0).toLowerCase()}${eventDataPropPascalCase.substring(1)}`; 302 | let message = Bourne.parse(event.data)[eventDataProp]; 303 | if (message != null) { 304 | if (event.type === 'testerProgress' && message.startsWith('All Test Sessions of all Testers are finished')) { // Message defined in Orchestrator. 305 | message = message.concat(`\n${await requestOutcomes()}`); 306 | } 307 | model.propagateTesterMessage({ 308 | testerType: testerNameAndSession.testerType, 309 | sessionId: testerNameAndSession.sessionId, 310 | message, 311 | event: event.type 312 | }); 313 | } else { 314 | internals.cUiLogger.warning(`A falsy ${event.type} event message was received from the orchestrator`, { tags: ['apiDecoratingAdapter'] }); 315 | } 316 | }; 317 | 318 | const subscribeToTesterFeedback = (model, testerStatuses, subscribeToOngoingFeedback) => { 319 | const { testerNamesAndSessions } = model; 320 | testerNamesAndSessions.forEach((testerNameAndSession) => { 321 | // Todo: KC: Add test for the following logging. 322 | const loggerType = `${testerNameAndSession.testerType}-${testerNameAndSession.sessionId}`; 323 | const { transports, dirname } = config.get('loggers.testerProgress'); 324 | internals.ptLogger.add(loggerType, { transports, filename: `${dirname}${loggerType}_${NowAsFileName()}.log` }); 325 | 326 | const testerRepresentative = testerStatuses.find((element) => element.name === testerNameAndSession.testerType); 327 | if (testerRepresentative) { 328 | model.propagateTesterMessage({ 329 | testerType: testerNameAndSession.testerType, 330 | sessionId: testerNameAndSession.sessionId, 331 | message: testerRepresentative.message 332 | }); 333 | if (subscribeToOngoingFeedback && testerRepresentative.message !== TesterUnavailable(testerNameAndSession.testerType)) { 334 | const eventSource = new internals.EventSource(`${apiUrl}/${TesterFeedbackRoutePrefix('sse')}/${testerNameAndSession.testerType}/${testerNameAndSession.sessionId}`); // sessionId is 'NA' for tls? 335 | const handleServerSentTesterEventsClosure = (event) => { 336 | handleServerSentTesterEvents(event, model, testerNameAndSession); 337 | }; 338 | eventSource.addEventListener('testerProgress', handleServerSentTesterEventsClosure); 339 | eventSource.addEventListener('testerPctComplete', handleServerSentTesterEventsClosure); 340 | eventSource.addEventListener('testerBugCount', handleServerSentTesterEventsClosure); 341 | eventSource.addEventListener('end', () => { eventSource.close(); }); 342 | eventSource.addEventListener('error', (error) => { 343 | const knownErrors = [ 344 | { 400: `This could be due to a validation failure of the parameters supplied to /${TesterFeedbackRoutePrefix('sse')}/${testerNameAndSession.testerType}/${testerNameAndSession.sessionId}` } // error.status 345 | // Others? 346 | ]; 347 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.status)) 348 | ?? { default: `Unknown error. Error follows: ${error.message ?? JSON.stringify(error)}` }; 349 | const errorMessage = Object.values(knownError)[0]; // eslint-disable-line prefer-destructuring 350 | model.propagateTesterMessage({ 351 | testerType: testerNameAndSession.testerType, 352 | sessionId: testerNameAndSession.sessionId, 353 | message: `Error occurred while attempting to open a connection with event source from the purpleteam API. Error was: ${errorMessage}` 354 | }); 355 | }); 356 | } 357 | } else { 358 | model.propagateTesterMessage({ 359 | testerType: testerNameAndSession.testerType, 360 | sessionId: testerNameAndSession.sessionId, 361 | message: `"${testerNameAndSession.testerType}" Tester for session with Id "${testerNameAndSession.sessionId}" doesn't currently appear to be online` 362 | }); 363 | } 364 | }); 365 | }; 366 | 367 | const handleLongPollTesterEvents = async (eventSet, model, testerNameAndSession) => { 368 | const { longPoll: { nullProgressMaxRetries } } = internals; 369 | const accumulation = await eventSet.reduce(async (accum, cV) => { 370 | let { keepRequestingMessages } = await accum; 371 | const { event: eventType } = cV; 372 | const eventDataPropPascalCase = eventType.replace('tester', ''); 373 | const eventDataProp = `${eventDataPropPascalCase.charAt(0).toLowerCase()}${eventDataPropPascalCase.substring(1)}`; 374 | let message = cV.data[eventDataProp]; 375 | if (message !== null) { 376 | internals.longPoll.nullProgressCounter = 0; 377 | if (eventType === 'testerProgress' && message.startsWith('Tester finished')) { 378 | keepRequestingMessages = false; 379 | } 380 | if (eventType === 'testerProgress' && message.startsWith('All Test Sessions of all Testers are finished')) { // Message defined in Orchestrator. 381 | message = message.concat(`\n${await requestOutcomes()}`); 382 | keepRequestingMessages = false; 383 | } 384 | model.propagateTesterMessage({ 385 | testerType: testerNameAndSession.testerType, 386 | sessionId: testerNameAndSession.sessionId, 387 | message, 388 | event: eventType 389 | }); 390 | } else { 391 | // If message === null, don't do anything. See lPTesterWatcherCallback from orchestrate.js of the orchestrator for further details. 392 | // If we miss the last testerProgress event we'll need to stop polling after several sequential empty `eventSet`s. 393 | internals.longPoll.nullProgressCounter += 1; 394 | keepRequestingMessages = !(internals.longPoll.nullProgressCounter >= nullProgressMaxRetries); 395 | } 396 | return { keepRequestingMessages }; 397 | }, { keepRequestingMessages: true }); 398 | return accumulation.keepRequestingMessages; 399 | }; 400 | 401 | const longPollTesterFeedback = async (model, testerStatuses, subscribeToOngoingFeedback) => { 402 | const { testerNamesAndSessions } = model; 403 | await Promise.all(testerNamesAndSessions.map(async (testerNameAndSession) => { 404 | // Todo: KC: Add test for the following logging. 405 | const loggerType = `${testerNameAndSession.testerType}-${testerNameAndSession.sessionId}`; 406 | const { transports, dirname } = config.get('loggers.testerProgress'); 407 | internals.ptLogger.add(loggerType, { transports, filename: `${dirname}${loggerType}_${NowAsFileName()}.log` }); 408 | 409 | const testerRepresentative = testerStatuses.find((element) => element.name === testerNameAndSession.testerType); 410 | if (testerRepresentative) { 411 | model.propagateTesterMessage({ 412 | testerType: testerNameAndSession.testerType, 413 | sessionId: testerNameAndSession.sessionId, 414 | message: testerRepresentative.message 415 | }); 416 | if (subscribeToOngoingFeedback && testerRepresentative.message !== TesterUnavailable(testerNameAndSession.testerType)) { 417 | // If Long Polling via recursion becomes a problem due to: memory usage or stack size, we could: 418 | // 1. Move requestPollTesterFeedback to me module scoped in this file and call it via setTimeout 419 | // 2. Use EventEmitter, subscribe requestPollTesterFeedback to an event, fire the event from the gotPt callback 420 | const requestPollTesterFeedback = async () => { 421 | let keepRequestingMessages; 422 | await gotPt.get(`${TesterFeedbackRoutePrefix('lp')}/${testerNameAndSession.testerType}/${testerNameAndSession.sessionId}`, { responseType: 'json' }).then(async (response) => { 423 | keepRequestingMessages = await handleLongPollTesterEvents(response.body, model, testerNameAndSession); 424 | if (keepRequestingMessages) await requestPollTesterFeedback(); 425 | }).catch((error) => { 426 | let errorMessage; 427 | if (error.processed) { 428 | errorMessage = error.message; 429 | } else { 430 | const knownErrors = [ 431 | { ValidationError: `${error.response?.body?.name}: ${error.response?.body?.message}` } // error.response.body.name 432 | // Others? 433 | ]; 434 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.response?.statusCode)) 435 | ?? knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.response?.body?.name)) 436 | ?? { default: `Unknown error. Error follows: ${error}` }; 437 | errorMessage = Object.values(knownError)[0]; // eslint-disable-line prefer-destructuring 438 | } 439 | model.propagateTesterMessage({ 440 | testerType: testerNameAndSession.testerType, 441 | sessionId: testerNameAndSession.sessionId, 442 | message: `Error occurred while attempting to Poll the purpleteam API for Tester feedback. Error was: ${errorMessage}` 443 | }); 444 | }); 445 | }; 446 | await requestPollTesterFeedback(); 447 | } 448 | } else { 449 | model.propagateTesterMessage({ 450 | testerType: testerNameAndSession.testerType, 451 | sessionId: testerNameAndSession.sessionId, 452 | message: `"${testerNameAndSession.testerType}" Tester for session with Id "${testerNameAndSession.sessionId}" doesn't currently appear to be online` 453 | }); 454 | } 455 | })); 456 | }; 457 | 458 | const getTesterFeedback = { 459 | sse: async (model, testerStatuses, subscribeToOngoingFeedback) => { 460 | subscribeToTesterFeedback(model, testerStatuses, subscribeToOngoingFeedback); 461 | }, 462 | lp: async (model, testerStatuses, subscribeToOngoingFeedback) => { 463 | await longPollTesterFeedback(model, testerStatuses, subscribeToOngoingFeedback); 464 | } 465 | }; 466 | 467 | const getInitialisedModel = (jobFileContents) => { 468 | let model; 469 | try { 470 | model = new internals.Model(jobFileContents); 471 | } catch (error) { 472 | const knownErrors = [ 473 | { SyntaxError: `Invalid syntax in "Job": ${error.message}` }, // error.name 474 | { ValidationError: `An error occurred while validating the Job. Details follow:\nname: ${error.name}\nmessage. Errors: ${error.message}` } // error.name 475 | ]; 476 | const knownError = knownErrors.find((e) => Object.prototype.hasOwnProperty.call(e, error.name)) 477 | ?? { default: `Unknown error. Error follows: ${error}` }; 478 | internals.cUiLogger.crit(`Error occurred while instantiating the model. Details follow: ${Object.values(knownError)[0]}`, { tags: ['apiDecoratingAdapter'] }); 479 | return undefined; 480 | } 481 | return model; 482 | }; 483 | 484 | const testPlans = async (jobFileContents) => { 485 | const model = getInitialisedModel(jobFileContents); 486 | if (!model) return; 487 | const resultingTestPlans = await requestTestPlan(model.job); 488 | resultingTestPlans && internals.view.testPlan({ testPlans: resultingTestPlans, ptLogger: internals.ptLogger }); 489 | }; 490 | 491 | const handleModelTesterEvents = (eventName, testerType, sessionId, message) => { 492 | internals.view[`handle${eventName.charAt(0).toUpperCase()}${eventName.substring(1)}`]({ testerType, sessionId, message, ptLogger: internals.ptLogger }); 493 | }; 494 | 495 | const test = async (jobFileContents) => { 496 | const model = getInitialisedModel(jobFileContents); 497 | if (!model) return; 498 | 499 | const result = await requestTest(model.job); 500 | let testerStatuses; 501 | let testerFeedbackCommsMedium; 502 | 503 | if (result) { 504 | ({ testerStatuses, testerFeedbackCommsMedium } = result); 505 | internals.view.test(model.testerSessions()); 506 | model.eventNames.forEach((eN) => { 507 | model.on(eN, (testerType, sessionId, message) => { handleModelTesterEvents(eN, testerType, sessionId, message); }); 508 | }); 509 | 510 | const subscribeToOngoingFeedback = !testerStatuses.find((e) => e.message.startsWith('Tester failure:')); 511 | 512 | await getTesterFeedback[testerFeedbackCommsMedium](model, testerStatuses, subscribeToOngoingFeedback); 513 | 514 | // To cancel the event stream: 515 | // https://github.com/mtharrison/susie#how-do-i-finish-a-sse-stream-for-good 516 | // https://www.html5rocks.com/en/tutorials/eventsource/basics/#toc-canceling 517 | // https://developer.mozilla.org/en-US/docs/Web/API/EventSource/close 518 | } 519 | }; 520 | 521 | const status = async () => { await requestStatus(); }; 522 | 523 | export default { 524 | inject, 525 | getJobFile, 526 | testPlans, 527 | test, 528 | status 529 | }; 530 | --------------------------------------------------------------------------------