├── .eslintignore ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── bin └── j1 ├── cortex.yaml ├── examples-json ├── alert-rules.json ├── entities.json ├── questions.json └── relationships.json ├── examples-yaml ├── alert-rules.yml ├── entities.yml ├── questions.yml └── relationships.yml ├── examples └── sync-api │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── data │ │ ├── code-modules.json │ │ └── code-repos.json │ ├── index.ts │ └── utils │ │ ├── authenticate.ts │ │ ├── cleanup.ts │ │ ├── create-scope.ts │ │ ├── generate-entities.ts │ │ ├── generate-relationships.ts │ │ ├── get-all-integration-instances.ts │ │ ├── get-integration-definition.ts │ │ ├── handle-invalid-enum.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── map-prop.ts │ │ ├── queries.ts │ │ ├── sleep.ts │ │ └── wait-for-graph-results.ts │ └── tsconfig.json ├── jest.config.js ├── package.json ├── prettier.config.js ├── scripts └── bulk-delete.bash ├── src ├── example-testing-data │ ├── example-data.json │ ├── example-deferred-result.json │ ├── example-definition.json │ ├── example-end-result.ts │ ├── example-entity.ts │ ├── example-integration-instance.json │ └── example-sync-job.ts ├── index.new.test.ts ├── index.test.ts ├── index.ts ├── j1cli.ts ├── networkRequest.ts ├── queries.ts ├── types.ts └── util │ ├── error.ts │ ├── getProp.ts │ └── query │ ├── index.ts │ ├── query.ts │ └── types.ts ├── tsconfig.dist.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | examples 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - id: setup-node 10 | name: Setup Node 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 18.x 14 | 15 | - name: Check out code repository source code 16 | uses: actions/checkout@v2 17 | 18 | - name: Install dependencies 19 | run: yarn 20 | 21 | - name: Run build 22 | run: yarn build 23 | 24 | # Publishing is done in a separate job to allow 25 | # for all matrix builds to complete. 26 | release: 27 | needs: test 28 | runs-on: ubuntu-latest 29 | if: github.ref == 'refs/heads/main' 30 | 31 | steps: 32 | - name: Setup Node 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: 18.x 36 | 37 | - name: Check out repo 38 | uses: actions/checkout@v3 39 | with: 40 | fetch-depth: 2 41 | 42 | - name: Check if publish needed 43 | run: | 44 | name="$(jq -r .name package.json)" 45 | npmver="$(npm show $name version || echo v0.0.0)" 46 | pkgver="$(jq -r .version package.json)" 47 | if [ "$npmver" = "$pkgver" ] 48 | then 49 | echo "Package version ($pkgver) is the same as last published NPM version ($npmver), skipping publish." 50 | else 51 | echo "Package version ($pkgver) is different from latest NPM version ($npmver), publishing!" 52 | echo "publish=true" >> $GITHUB_ENV 53 | fi 54 | 55 | - name: Publish 56 | if: env.publish == 'true' 57 | env: 58 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 59 | publish: ${{ env.publish }} 60 | run: | 61 | echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" > .npmrc 62 | yarn 63 | npm publish 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | local 3 | .env 4 | dist 5 | coverage 6 | 7 | .eslintcache 8 | j1-scope-key 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to 7 | [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 8 | 9 | ## [Unreleased] 10 | 11 | ## [2.1.2] - 2025-04-03 12 | 13 | - Remove potentially unwanted logging of request URLs 14 | 15 | ## [2.1.1] - 2025-02-17 16 | 17 | - Adds a retry for status URL check 18 | 19 | ## [2.1.0] - 2024-07-23 20 | 21 | - Update the `queryv1` function to use cursors instead of the legacy `LIMIT SKIP` pagination approach 22 | - Add support for a progress bar 23 | 24 | ## [2.0.1] - 2024-07-02 25 | 26 | - Converted `Content-Type` request header to lower case. This conforms to https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2. And also addresses an issue where Content-Type and content-type headers were both being added. 27 | Thanks SrdjanCosicPrica for the contribution! 28 | 29 | ## [2.0.0] - 2024-05-13 30 | 31 | - Removed the `ingestEntities` and `ingestCommitRange` methods 32 | 33 | ## [1.1.0] - 2024-02-15 34 | 35 | - Added `abortSyncJob` and `publishEvents` methods 36 | 37 | ## [1.0.1] - 2023-10-26 38 | 39 | - Improved error handling when calling `networkRequest` 40 | 41 | ## [1.0.0] - 2023-10-23 42 | 43 | - Removed Cognito `authenticateUser` method 44 | 45 | ## [0.26.2] - 2022-04-05 46 | 47 | ### Added 48 | 49 | - Both the CLI and the `JupiterOneClient` constructor now accept an optional 50 | parameter that allows users to specify a base URL to use during execution. 51 | 52 | ## [0.26.1] - 2022-02-17 53 | 54 | ### Updated 55 | 56 | - Fix detection of inline question rule bodies when creating/updating alert 57 | rules 58 | 59 | ## [0.26.0] - 2022-02-07 60 | 61 | ### Breaking Change 62 | 63 | The `integrationDefinitions.list` return data structure changed from: 64 | 65 | ``` 66 | { 67 | definitions: [{...}, ...], 68 | pageInfo: { key: value } 69 | } 70 | ``` 71 | 72 | to: 73 | 74 | ``` 75 | { 76 | data: [{...}, ...], 77 | errors: [{...}, ...] 78 | } 79 | ``` 80 | 81 | ### Updated 82 | 83 | - Re-named get-prop.ts to getProp.ts 84 | - Updated integrationDefinitions.list to use standardized query function 85 | - Added necessary types 86 | - Updated related tests 87 | 88 | ## [0.25.2] - 2022-02-07 89 | 90 | ### Added 91 | 92 | - Helper function to easily query GraphQL queries with a cursor 93 | - IntegrationInstances.list unit tests 94 | 95 | ### Updated 96 | 97 | - Resolve TypeError when calling integrationInstances.list without an argument 98 | 99 | ## [0.25.1] - 2022-02-03 100 | 101 | ### Added 102 | 103 | - IntegrationDefinitions list method 104 | 105 | ### Updated 106 | 107 | - {} types with Record 108 | - Packages that had vulnerabilities 109 | - Replace jest config in package.json with additional config in jest.config.js 110 | 111 | ## [0.25.0] - 2021-12-15 112 | 113 | ### Added 114 | 115 | - bulkUpload unit tests 116 | 117 | ### Updated 118 | 119 | - bulkUpload method signature 120 | 121 | ## [0.24.2] - 2021-12-15 122 | 123 | ### Added 124 | 125 | - Unit test to check for all exposed properties on the J1 Client 126 | 127 | ## [0.24.1] - 2021-12-08 128 | 129 | ### Added 130 | 131 | - Upgrade 132 | - j1 sdk jest configuration 133 | - j1 sdk prettier configuration 134 | - code coverage package.json command 135 | - test for queryV1 136 | 137 | ### Updated 138 | 139 | - husky to v7 140 | - Abstract fetch calls in queryV1 to helper 141 | 142 | ### Added 143 | 144 | ## [0.24.0] - 2021-11-15 145 | 146 | - Changed GraphQL mutation for creation and update of Question Rule Instances to 147 | use new fields. 148 | - Added automatic logic for referenced question rule instances. Rule instances 149 | with a `question` will use old logic. Instances that omit `question` can use 150 | `questionName` or `questionId` to reference a question instead. 151 | 152 | ## [0.23.7] - 2021-11-10 153 | 154 | ### Added 155 | 156 | - Added the following methods to `JupiterOneClient`: 157 | 158 | ```ts 159 | const client = await new JupiterOneClient(options).init(); 160 | 161 | await client.integrationInstances.list(); 162 | await client.integrationInstances.get(id); 163 | await client.integrationInstances.create(instance); 164 | await client.integrationInstances.update(id, update); 165 | await client.integrationInstances.delete(id); 166 | ``` 167 | 168 | ## 0.23.6 169 | 170 | - Replace deleteEntity with deleteEntityV2 171 | - Add typings and resolve typing errors 172 | - Remove entity property in `uploadGraphObjectsForDeleteSyncJob` 173 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jupiterone/integrations 2 | 3 | CODEOWNERS @jupiterone/security -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 JupiterOne | LifeOmic Security LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupiterone-client-nodejs 2 | 3 | A node.js client wrapper and CLI utility for JupiterOne public API. 4 | 5 | This is currently an experimental project and subject to change. 6 | 7 | ## Installation 8 | 9 | To install the client local to the current project: 10 | 11 | ```bash 12 | npm install @jupiterone/jupiterone-client-nodejs 13 | ``` 14 | 15 | To install the client globally: 16 | 17 | ```bash 18 | npm install @jupiterone/jupiterone-client-nodejs -g 19 | ``` 20 | 21 | ## Using the Node.js client 22 | 23 | ```javascript 24 | const { JupiterOneClient } = require('@jupiterone/jupiterone-client-nodejs'); 25 | 26 | const j1Client = await new JupiterOneClient({ 27 | account: 'my-account-id', 28 | accessToken: 'my-api-token', 29 | apiBaseUrl: 'https://api.us.jupiterone.io', // Optional parameter 30 | }).init(); 31 | const integrationInstance = await j1Client.integrationInstances.get( 32 | 'my-integration-instance-id', 33 | ); 34 | ``` 35 | 36 | ## Using the J1 CLI 37 | 38 | Usage: 39 | 40 | ```bash 41 | $ j1 --help 42 | Usage: j1 [options] 43 | 44 | Options: 45 | -v, --version output the version number 46 | -a, --account JupiterOne account ID. 47 | -u, --user JupiterOne user email. 48 | -k, --key JupiterOne API access token. 49 | -q, --query Execute a query. 50 | -o, --operation Supported operations: create, update, upsert, delete, bulk-delete, provision-alert-rule-pack 51 | --entity Specifies entity operations. 52 | --relationship Specifies relationship operations. 53 | --alert Specifies alert rule operations. 54 | -f, --file Input JSON file. Or the filename of the alert rule pack. 55 | --api-base-url Optionally specify base URL to use during execution. (defaults to `https://api.us.jupiterone.io`) 56 | -h, --help output usage information 57 | ``` 58 | 59 | #### Relevant Environment Variables 60 | 61 | J1_API_TOKEN - Sets the JupiterOne API access token as environment variable 62 | instead of passing it through -k parameter 63 | 64 | J1_DEV_ENABLED - Alters the base url. Valid values: 'true' | 'false' (string) 65 | 66 | ## Examples 67 | 68 | ### Run a J1QL query 69 | 70 | ```bash 71 | j1 -a j1dev -q 'Find jupiterone_account' 72 | Validating inputs... 73 | Authenticating with JupiterOne... OK 74 | [ 75 | { 76 | "id": "06ab12cd-a402-406c-8582-abcdef001122", 77 | "entity": { 78 | "_beginOn": 1553777431867, 79 | "_createdOn": 1553366320704, 80 | "_deleted": false, 81 | "displayName": "YCO, Inc.", 82 | "_type": [ 83 | "jupiterone_account" 84 | ], 85 | "_key": "1a2b3c4d-44ce-4a2f-8cd8-99dd88cc77bb", 86 | "_accountId": "j1dev", 87 | "_source": "api", 88 | "_id": "1a2b3c4d-44ce-4a2f-8cd8-99dd88cc77bb", 89 | "_class": [ 90 | "Account" 91 | ], 92 | "_version": 6 93 | }, 94 | "properties": { 95 | "emailDomain": "yourcompany.com", 96 | "phoneNumber": "877-555-4321", 97 | "webURL": "https://yourcompany.com/", 98 | "name": "YCO" 99 | } 100 | } 101 | ] 102 | Done! 103 | ``` 104 | 105 | #### Advanced Node Usage 106 | 107 | You are able to pass in Apollo Query Options into the `queryV1` method. This is 108 | beneficial when you need to change how the cache behaves, for example. More 109 | information about what data you can provide found here: 110 | https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy 111 | 112 | To do so: 113 | 114 | ``` 115 | 116 | // Pass in options like shown below: 117 | 118 | const options = { 119 | 'fetchPolicy': 'network-only' 120 | } 121 | 122 | j1.queryV1('FIND jupiterone_account', options) 123 | 124 | ``` 125 | 126 | ### Create or update entities from a JSON input file 127 | 128 | ```bash 129 | j1 -o create --entity -a j1dev -f ./local/entities.json 130 | Validating inputs... 131 | Authenticating with JupiterOne... Authenticated! 132 | Created entity 12345678-fe34-44ee-b3b0-abcdef123456. 133 | Created entity 12345678-e75f-40d6-858e-123456abcdef. 134 | Done! 135 | 136 | j1 -o update --entity -a j1dev -f ./local/entities.json 137 | Validating inputs... 138 | Authenticating with JupiterOne... Authenticated! 139 | Updated entity 12345678-fe34-44ee-b3b0-abcdef123456. 140 | Updated entity 12345678-e75f-40d6-858e-123456abcdef. 141 | Done! 142 | ``` 143 | 144 | **NOTE:** the `create` operation will also update an existing entity, if an 145 | entity matching the provided Key, Type, and Class already exists in JupiterOne. 146 | The `update` operation will fail unless that entity Id already exists. 147 | 148 | The input JSON file is a single entity or an array of entities. For example: 149 | 150 | ```json 151 | [ 152 | { 153 | "entityId": "12345678-fe34-44ee-b3b0-abcdef123456", 154 | "entityKey": "test:entity:1", 155 | "entityType": "generic_resource", 156 | "entityClass": "Resource", 157 | "properties": { 158 | "name": "Test Entity Resource 1", 159 | "displayName": "TER1" 160 | } 161 | }, 162 | { 163 | "entityId": "12345678-e75f-40d6-858e-123456abcdef", 164 | "entityKey": "test:entity:3", 165 | "entityType": "generic_resource", 166 | "entityClass": "Resource", 167 | "properties": { 168 | "name": "Test Entity Resource 2", 169 | "displayName": "TER2" 170 | } 171 | } 172 | ] 173 | ``` 174 | 175 | The `entityId` property is only necessary for `update` operations. 176 | 177 | ### Create or update alert rules from a JSON input file 178 | 179 | ```bash 180 | j1 -o create --alert -a j1dev -f ./local/alerts.json 181 | Validating inputs... 182 | Authenticating with JupiterOne... OK 183 | Created alert rule . 184 | Done! 185 | ``` 186 | 187 | The input JSON file is one or an array of alert rule instances. The following is 188 | an example of a single alert rule instance: 189 | 190 | ```json 191 | { 192 | "instance": { 193 | "name": "unencrypted-prod-data", 194 | "description": "Data stores in production tagged critical and unencrypted", 195 | "specVersion": 1, 196 | "pollingInterval": "ONE_DAY", 197 | "outputs": ["alertLevel"], 198 | "operations": [ 199 | { 200 | "when": { 201 | "type": "FILTER", 202 | "specVersion": 1, 203 | "condition": [ 204 | "AND", 205 | ["queries.unencryptedCriticalData.total", "!=", 0] 206 | ] 207 | }, 208 | "actions": [ 209 | { 210 | "type": "SET_PROPERTY", 211 | "targetProperty": "alertLevel", 212 | "targetValue": "CRITICAL" 213 | }, 214 | { 215 | "type": "CREATE_ALERT" 216 | } 217 | ] 218 | } 219 | ], 220 | "question": { 221 | "queries": [ 222 | { 223 | "query": "Find DataStore with (production=true or tag.Production=true) and classification='critical' and encrypted!=true as d return d.tag.AccountName as Account, d.displayName as UnencryptedDataStores, d._type as Type, d.encrypted as Encrypted", 224 | "version": "v1", 225 | "name": "unencryptedCriticalData" 226 | } 227 | ] 228 | } 229 | } 230 | } 231 | ``` 232 | 233 | Add `"id": ""` property to the instance JSON when updating an alert rule. 234 | 235 | ### Bulk Delete 236 | 237 | ```bash 238 | j1 -q 'Find SomeDataClass with someProp="some value"' 239 | j1 -e -o bulk-delete -f ./results.json 240 | ``` 241 | 242 | The first CLI command queries data using a J1QL query and saves the data locally 243 | to `results.json`. The second CLI command takes `results.json` as input and bulk 244 | deletes all the entities in the file. 245 | 246 | ### Provision Alert Rules from Rule Pack 247 | 248 | The following command will provision all the default alert rules from 249 | `jupiterone-alert-rules` with the rule pack name `aws-config`: 250 | 251 | ```bash 252 | j1 -a -u -o provision-alert-rule-pack --alert -f aws-config 253 | ``` 254 | 255 | You can specify your own rule pack to provision as well, by specifying the full 256 | file path to the `rule-pack.json` file: 257 | 258 | ```bash 259 | j1 -a -u -o provision-alert-rule-pack --alert -f path/to/your/rule-pack.json 260 | ``` 261 | 262 | For more details about the rules and rule packs, see the 263 | `jupiterone-alert-rules` project. 264 | -------------------------------------------------------------------------------- /bin/j1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/j1cli.js') 3 | -------------------------------------------------------------------------------- /cortex.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: '@jupiterone/jupiterone-client-nodejs' 4 | description: A node.js client wrapper for JupiterOne public API 5 | x-cortex-git: 6 | github: 7 | repository: JupiterOne/jupiterone-client-nodejs 8 | x-cortex-owners: 9 | - type: group 10 | name: JupiterOne/integrations 11 | x-cortex-tag: '@jupiterone/jupiterone-client-nodejs' 12 | x-cortex-service-groups: 13 | - tier-4 14 | -------------------------------------------------------------------------------- /examples-json/alert-rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "instance": { 4 | "id": "1b9f58f4-1fc1-4335-98b6-716c9036d671", 5 | "name": "unencrypted-prod-data", 6 | "description": "Data stores in production tagged critical and unencrypted", 7 | "specVersion": 1, 8 | "pollingInterval": "ONE_HOUR", 9 | "outputs": ["alertLevel"], 10 | "operations": [ 11 | { 12 | "when": { 13 | "type": "FILTER", 14 | "specVersion": 1, 15 | "condition": [ 16 | "AND", 17 | ["queries.unencryptedCriticalData.total", "!=", 0] 18 | ] 19 | }, 20 | "actions": [ 21 | { 22 | "type": "SET_PROPERTY", 23 | "targetProperty": "alertLevel", 24 | "targetValue": "CRITICAL" 25 | }, 26 | { 27 | "type": "CREATE_ALERT" 28 | } 29 | ] 30 | } 31 | ], 32 | "question": { 33 | "queries": [ 34 | { 35 | "query": "Find DataStore with (production=true or tag.Production=true) and classification='critical' and encrypted!=true as d return d.tag.AccountName as Account, d.displayName as UnencryptedDataStores, d._type as Type, d.encrypted as Encrypted", 36 | "version": "v1", 37 | "name": "unencryptedCriticalData" 38 | } 39 | ] 40 | } 41 | } 42 | }, 43 | { 44 | "instance": { 45 | "id": "ecbb6bb8-59f1-4f6e-a515-aa29eb8c77cc", 46 | "name": "unclassified-prod-data", 47 | "description": "Data stores in production without a classification property/tag", 48 | "specVersion": 1, 49 | "pollingInterval": "ONE_HOUR", 50 | "outputs": ["alertLevel"], 51 | "operations": [ 52 | { 53 | "when": { 54 | "type": "FILTER", 55 | "specVersion": 1, 56 | "condition": [ 57 | "AND", 58 | ["queries.unclassifiedProdData.total", "!=", 0] 59 | ] 60 | }, 61 | "actions": [ 62 | { 63 | "type": "SET_PROPERTY", 64 | "targetProperty": "alertLevel", 65 | "targetValue": "HIGH" 66 | }, 67 | { 68 | "type": "CREATE_ALERT" 69 | } 70 | ] 71 | } 72 | ], 73 | "question": { 74 | "queries": [ 75 | { 76 | "name": "unclassifiedProdData", 77 | "query": "Find DataStore with (production=true or tag.Production=true) and classification=undefined", 78 | "version": "v1" 79 | } 80 | ] 81 | } 82 | } 83 | }, 84 | { 85 | "instance": { 86 | "id": "df58badb-c5c4-4b6b-a97c-6a5c5de94580", 87 | "name": "prod-data-no-owner", 88 | "description": "Data stores in production without an owner property/tag", 89 | "specVersion": 1, 90 | "pollingInterval": "ONE_DAY", 91 | "outputs": ["alertLevel"], 92 | "operations": [ 93 | { 94 | "when": { 95 | "type": "FILTER", 96 | "specVersion": 1, 97 | "condition": ["AND", ["queries.query.total", "!=", 0]] 98 | }, 99 | "actions": [ 100 | { 101 | "type": "SET_PROPERTY", 102 | "targetProperty": "alertLevel", 103 | "targetValue": "HIGH" 104 | }, 105 | { 106 | "type": "CREATE_ALERT" 107 | } 108 | ] 109 | } 110 | ], 111 | "question": { 112 | "queries": [ 113 | { 114 | "name": "query", 115 | "query": "Find DataStore with (production=true or tag.Production=true) and owner=undefined", 116 | "version": "v1" 117 | } 118 | ] 119 | } 120 | } 121 | } 122 | ] 123 | -------------------------------------------------------------------------------- /examples-json/entities.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "entityKey": "test:entity:2", 4 | "entityType": "generic_resource", 5 | "entityClass": "Resource", 6 | "properties": { 7 | "name": "Test Entity Resource 2", 8 | "displayName": "Test Entity Resource 2" 9 | } 10 | }, 11 | { 12 | "entityKey": "test:entity:3", 13 | "entityType": "generic_resource", 14 | "entityClass": "Resource", 15 | "properties": { 16 | "name": "Test Entity Resource 3", 17 | "displayName": "Test Entity Resource 3" 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /examples-json/questions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "my query", 4 | "queries": [ 5 | { 6 | "query": "FIND organizations" 7 | } 8 | ], 9 | "compliance": [ 10 | { 11 | "standard": "ISO 27002", 12 | "requirements": ["5.3.1", "7.1.3"] 13 | } 14 | ], 15 | "tags": ["Sometag"] 16 | }, 17 | { 18 | "title": "Are my assets tracked? How many entities are there?", 19 | "description": "Returns the current count of total assets/entities tracked in JupiterOne - either automatically ingested via integrations or manually entered through the Asset Inventory app or API.", 20 | "queries": [ 21 | { 22 | "name": "entityCount", 23 | "query": "Find * as e return count(e)" 24 | } 25 | ], 26 | "tags": ["compliance", "CIS Controls", "HIPAA", "HITRUST CSF", "PCI DSS"], 27 | "compliance": [ 28 | { 29 | "standard": "CIS Controls", 30 | "requirements": ["1.1", "1.4", "1.5", "2.3", "2.4", "2.5"] 31 | }, 32 | { 33 | "standard": "HITRUST CSF", 34 | "requirements": ["07.a"] 35 | } 36 | ] 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /examples-json/relationships.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "relationshipId": "3429d452-7cda-4ac1-b472-60c438a67e03", 4 | "relationshipKey": "test:entity:2|uses|test:entity:3", 5 | "relationshipType": "generic_resource_uses_resource", 6 | "relationshipClass": "USES", 7 | "fromEntityId": "75121349-fe34-44ee-b3b0-4d41bc912a23", 8 | "toEntityId": "3f61775c-e75f-40d6-858e-b868399b92e0" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /examples-yaml/alert-rules.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - instance: 3 | id: 1b9f58f4-1fc1-4335-98b6-716c9036d671 4 | name: unencrypted-prod-data 5 | description: Data stores in production tagged critical and unencrypted 6 | specVersion: 1 7 | pollingInterval: ONE_HOUR 8 | outputs: 9 | - alertLevel 10 | operations: 11 | - when: 12 | type: FILTER 13 | specVersion: 1 14 | condition: 15 | - AND 16 | - - queries.unencryptedCriticalData.total 17 | - '!=' 18 | - 0 19 | actions: 20 | - type: SET_PROPERTY 21 | targetProperty: alertLevel 22 | targetValue: CRITICAL 23 | - type: CREATE_ALERT 24 | question: 25 | queries: 26 | - query: 27 | Find DataStore with (production=true or tag.Production=true) and 28 | classification='critical' and encrypted!=true as d return 29 | d.tag.AccountName as Account, d.displayName as 30 | UnencryptedDataStores, d._type as Type, d.encrypted as Encrypted 31 | version: v1 32 | name: unencryptedCriticalData 33 | - instance: 34 | id: ecbb6bb8-59f1-4f6e-a515-aa29eb8c77cc 35 | name: unclassified-prod-data 36 | description: Data stores in production without a classification property/tag 37 | specVersion: 1 38 | pollingInterval: ONE_HOUR 39 | outputs: 40 | - alertLevel 41 | operations: 42 | - when: 43 | type: FILTER 44 | specVersion: 1 45 | condition: 46 | - AND 47 | - - queries.unclassifiedProdData.total 48 | - '!=' 49 | - 0 50 | actions: 51 | - type: SET_PROPERTY 52 | targetProperty: alertLevel 53 | targetValue: HIGH 54 | - type: CREATE_ALERT 55 | question: 56 | queries: 57 | - name: unclassifiedProdData 58 | query: 59 | Find DataStore with (production=true or tag.Production=true) and 60 | classification=undefined 61 | version: v1 62 | - instance: 63 | id: df58badb-c5c4-4b6b-a97c-6a5c5de94580 64 | name: prod-data-no-owner 65 | description: Data stores in production without an owner property/tag 66 | specVersion: 1 67 | pollingInterval: ONE_DAY 68 | outputs: 69 | - alertLevel 70 | operations: 71 | - when: 72 | type: FILTER 73 | specVersion: 1 74 | condition: 75 | - AND 76 | - - queries.query.total 77 | - '!=' 78 | - 0 79 | actions: 80 | - type: SET_PROPERTY 81 | targetProperty: alertLevel 82 | targetValue: HIGH 83 | - type: CREATE_ALERT 84 | question: 85 | queries: 86 | - name: query 87 | query: 88 | Find DataStore with (production=true or tag.Production=true) and 89 | owner=undefined 90 | version: v1 91 | -------------------------------------------------------------------------------- /examples-yaml/entities.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - entityKey: test:entity:2 3 | entityType: generic_resource 4 | entityClass: Resource 5 | properties: 6 | name: Test Entity Resource 2 7 | displayName: Test Entity Resource 2 8 | - entityKey: test:entity:3 9 | entityType: generic_resource 10 | entityClass: Resource 11 | properties: 12 | name: Test Entity Resource 3 13 | displayName: Test Entity Resource 3 14 | -------------------------------------------------------------------------------- /examples-yaml/questions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - id: managed-question-asset-inventory-count 3 | title: Are my assets tracked? How many entities are there? 4 | description: 5 | 'Returns the current count of total assets/entities tracked in JupiterOne - 6 | either automatically ingested via integrations or manually entered through 7 | the Asset Inventory app or API.' 8 | queries: 9 | - name: entityCount 10 | query: Find * as e return count(e) 11 | tags: 12 | - compliance 13 | - CIS Controls 14 | - HIPAA 15 | - HITRUST CSF 16 | - PCI DSS 17 | compliance: 18 | - standard: CIS Controls 19 | requirements: ['1.1', '1.2', '1.4', '1.5', '2.1', '2.3', '2.4', '2.5'] 20 | - standard: HITRUST CSF 21 | requirements: [07.a] 22 | - standard: HITRUST CSF 23 | requirements: ['2.4'] 24 | 25 | - title: A Short Question 26 | queries: 27 | - query: Find Person with lastName='Jones' 28 | tags: 29 | - Test 30 | -------------------------------------------------------------------------------- /examples-yaml/relationships.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - relationshipId: 3429d452-7cda-4ac1-b472-60c438a67e03 3 | relationshipKey: test:entity:2|uses|test:entity:3 4 | relationshipType: generic_resource_uses_resource 5 | relationshipClass: USES 6 | fromEntityId: 75121349-fe34-44ee-b3b0-4d41bc912a23 7 | toEntityId: 3f61775c-e75f-40d6-858e-b868399b92e0 8 | -------------------------------------------------------------------------------- /examples/sync-api/.gitignore: -------------------------------------------------------------------------------- 1 | j1-scope-key 2 | -------------------------------------------------------------------------------- /examples/sync-api/README.md: -------------------------------------------------------------------------------- 1 | # TL;DR 2 | 3 | If you want to form a relationship using a `_key`, you must include the 4 | `_source` and `_scope` of the entity that already exists in the graph. 5 | 6 | Assuming that the entity you are uploading is the `to` and the entity that you 7 | are forming a relationship with already exists in the graph and therefore is the 8 | `from`: 9 | 10 | This will _NOT_ work: 11 | 12 | ``` 13 | { 14 | _fromEntityKey: entityFrom.entity._key, 15 | _toEntityKey: entityTo.entity._key, 16 | } 17 | ``` 18 | 19 | This will work: 20 | 21 | ``` 22 | { 23 | _fromEntitySource: entityFrom.entity._source, 24 | _fromEntityScope: entityFrom.entity._integrationInstanceId, 25 | _fromEntityKey: entityFrom.entity._key, 26 | _toEntityKey: entityTo.entity._key, 27 | } 28 | ``` 29 | 30 | # Creating Relationships Between Entities You Own And Entities You Do Not 31 | 32 | Our goal here is to create relationships from one entity to another that you 33 | might not own. 34 | 35 | While this appears trivial, without knowing the details around how to form 36 | relationships, you might wonder why your data doesn't appear in the graph. 37 | 38 | This is due to the concept of ownership with the JupiterOne software. When we 39 | look at the graph in JupiterOne, it magically appears before us. We don’t have 40 | to worry about where the graph starts or where it might end. We type in a query 41 | and view the results. What’s not obvious from viewing the graph is that the 42 | graph we see is the aggregation of many subgraphs. There are subgraphs for the 43 | AWS integration and the system mapper and API ingested data amongst everything 44 | else. All of these different subgraphs come together to give us our cohesive set 45 | of results. These "Sub-graphs" are what denote “ownership”. When we say that 46 | Sub-graph 1 “owns” entity A, we mean that entity A is in Sub-graph 1. Ownership 47 | is important because it is how the software understands the state of the world. 48 | 49 | When interacting with entities that owned by various sources, we must be 50 | specific in our interactions so the end graph looks how we expect it to. 51 | 52 | At the end of this experiment, we should be able to run this query: 53 | 54 | ``` 55 | FIND CodeModule 56 | WITH displayName = ('hizurur' OR 'carnud' OR 'vici' OR 'iti' OR 'jifguilo' OR 'kiwoj' OR 'juvhove') 57 | AND from = 'testing' 58 | THAT USES << CodeRepo 59 | ``` 60 | 61 | and see our entities in the graph with the expected relationships formed. 62 | 63 | This experiment outlines a common use-case: 64 | 65 | You want to add new data to the JupiterOne graph and you want to form 66 | relationships with that data. 67 | 68 | What makes this use-case stand out is that you do not have an ID for your entity 69 | yet. 70 | 71 | This means to form a relationship with one sync job you've got to utilize the 72 | `_key` property of your entity! The catch is that you must be specific in how 73 | you do so. 74 | 75 | Let's dig in. 76 | 77 | ## Acquiring Data in the Graph to Form Our Relationships 78 | 79 | Assuming you already have your data prepared and ready to send to J1, the next 80 | thing to do is to gather the entities that already exist in the JupiterOne graph 81 | so that you can form relationships with them. 82 | 83 | Writing our query is straightforward enough: 84 | 85 | ``` 86 | FIND github_repo 87 | WITH displayName = ('graph-veracode' OR 'graph-knowbe4' OR 'graph-azure' OR 'graph-wazuh' OR 'graph-enrichment-examples' OR 'graph-whois' OR 'graph-zeit') 88 | ``` 89 | 90 | Sweet! We have `CodeRepos` that we're going to form relationships with. 91 | 92 | Here's an example query response payload: 93 | 94 | ``` 95 | { 96 | "_class": [ 97 | "CodeRepo" 98 | ], 99 | "_type": [ 100 | "github_repo" 101 | ], 102 | "_key": "MDEwOlJlcG9zaXRvcnkxNjkzMzI3NTQ=", 103 | "displayName": "graph-veracode", 104 | "_integrationType": "github", 105 | "_integrationClass": [ 106 | "ITS", 107 | "SCM", 108 | "VCS", 109 | "VersionControl" 110 | ], 111 | "_integrationDefinitionId": "1babe084-d58d-4ff0-9d98-e0d9bb8499be", 112 | "_integrationName": "JupiterOne", 113 | "_beginOn": "2022-01-19T20:26:17.842Z", 114 | "_id": "2218b983-139b-4447-9889-f04f48761b15", 115 | "_integrationInstanceId": "40d8cd20-054e-4b77-82bd-f01af7593170", 116 | "_rawDataHashes": "eyJkZWZhdWx0IjoiMUlKVFNaT00vM2FwQmtWTWt0alYxcml6ZjZsRGFNa1VTRHBvakxIR2sxVT0ifQ==", 117 | "_version": 18, 118 | "_accountId": "j1dev", 119 | "_deleted": false, 120 | "_source": "integration-managed", 121 | "_createdOn": "2020-03-23T19:10:09.298Z" 122 | } 123 | ``` 124 | 125 | ## Forming The Relationship 126 | 127 | Looking at the options for creating a relationship, we have a two primary 128 | choices: 129 | 130 | ``` 131 | { 132 | _fromEntityKey: string; 133 | _toEntityKey: string; 134 | _fromEntityId: string; 135 | _toEntityId: string; 136 | } 137 | ``` 138 | 139 | We can form the relationship in the following ways: 140 | 141 | - `CodeRepo` `_key` -> `CodeModule` `_key` 142 | - `CodeRepo` `_id` -> `CodeModule` `_key` 143 | 144 | Remember the comment from earlier: we do not have the `CodeModule` `_id` yet! 145 | This is important because these two options are _NOT_ equal in how they behave. 146 | Forming a relationship using the `_id` of the `CodeRepo` and the `_key` of the 147 | CodeModule will work because the `_id` is unique amongst all of the data in your 148 | account. The `_key` value is _NOT_ globally unique (i.e. two entities can have 149 | the same `_key`). 150 | 151 | When you form a relationship with two `_key` values and you do not specify the 152 | `source` and the `scope` of the data that already exists in the graph, the 153 | JupiterOne software does not understand what entity you're talking about and 154 | ultimately doesn't create the relationship. Since two entities could have the 155 | same `_key`, the software needs more information in order to identify the entity 156 | you're referencing. 157 | 158 | ### How do you get more information? 159 | 160 | Use the `source` and `scope` of your JupiterOne data alongside the `_key`! 161 | 162 | ``` 163 | { 164 | _fromEntitySource: string; 165 | _toEntitySource: string; 166 | _fromEntityScope: string; 167 | _toEntityScope: string; 168 | } 169 | ``` 170 | 171 | This: 172 | 173 | - `CodeRepo` `_key` -> `CodeModule` `_key` 174 | - `CodeRepo` `_id` -> `CodeModule` `_key` 175 | 176 | Must actually be: 177 | 178 | - `CodeRepo` `_key`, `_source`, `_scope` -> `CodeModule` `_key` 179 | - `CodeRepo` `_id` -> `CodeModule` `_key` 180 | 181 | In JSON, it looks like this: 182 | 183 | ``` 184 | { 185 | _fromEntitySource: entityFrom.entity._source, 186 | _fromEntityScope: entityFrom.entity._integrationInstanceId, 187 | _fromEntityKey: entityFrom.entity._key, 188 | _toEntityKey: entityTo.entity._key, 189 | } 190 | ``` 191 | 192 | ## Using This Example 193 | 194 | This example is set up so that you can run quick experiments to observe the 195 | behavior of different scenarios. 196 | 197 | To run: 198 | 199 | ``` 200 | Ensure your environment variables are set: 201 | 202 | J1_API_TOKEN= 203 | J1_ACCOUNT= 204 | J1_DEV_ENABLED=false 205 | 206 | Syntax: ts-node src/index.ts 207 | 208 | relationship_connection values: 209 | - ID_TO_KEY // WILL WORK 210 | - KEY_TO_KEY // WILL NOT WORK 211 | - KEY_WITH_SCOPE_AND_SOURCE_TO_KEY // WILL WORK 212 | 213 | 214 | $ ts-node src/index.ts ID_TO_KEY 215 | 216 | or compile to JavaScript and run with JavaScript: 217 | 218 | $ npm run build 219 | $ node dist/index.js 220 | ``` 221 | -------------------------------------------------------------------------------- /examples/sync-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupiterone-sync-api", 3 | "version": "1.0.0", 4 | "description": "An example of how to use the sync api to update data in the graph", 5 | "main": "src/index.ts", 6 | "author": "JupiterOne ", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "tsc -p tsconfig.json --declaration" 10 | }, 11 | "dependencies": { 12 | "@jupiterone/jupiterone-client-nodejs": "^0.25.0", 13 | "chalk": "^4.1.2", 14 | "chance": "^1.1.8", 15 | "uuid": "^8.3.2" 16 | }, 17 | "devDependencies": { 18 | "ts-node": "^10.5.0", 19 | "tsc": "^2.0.4", 20 | "typescript": "^4.5.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/sync-api/src/data/code-modules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "entity": { 4 | "_key": "npm_package:hizurur", 5 | "_class": "CodeModule", 6 | "_type": "npm_package", 7 | "displayName": "hizurur", 8 | "from": "testing" 9 | } 10 | }, 11 | { 12 | "entity": { 13 | "_key": "npm_package:carnud", 14 | "_class": "CodeModule", 15 | "_type": "npm_package", 16 | "displayName": "carnud", 17 | "from": "testing" 18 | } 19 | }, 20 | { 21 | "entity": { 22 | "_key": "npm_package:vici", 23 | "_class": "CodeModule", 24 | "_type": "npm_package", 25 | "displayName": "vici", 26 | "from": "testing" 27 | } 28 | }, 29 | { 30 | "entity": { 31 | "_key": "npm_package:iti", 32 | "_class": "CodeModule", 33 | "_type": "npm_package", 34 | "displayName": "iti", 35 | "from": "testing" 36 | } 37 | }, 38 | { 39 | "entity": { 40 | "_key": "npm_package:jifguilo", 41 | "_class": "CodeModule", 42 | "_type": "npm_package", 43 | "displayName": "jifguilo", 44 | "from": "testing" 45 | } 46 | }, 47 | { 48 | "entity": { 49 | "_key": "npm_package:kiwoj", 50 | "_class": "CodeModule", 51 | "_type": "npm_package", 52 | "displayName": "kiwoj", 53 | "from": "testing" 54 | } 55 | }, 56 | { 57 | "entity": { 58 | "_key": "npm_package:juvhove", 59 | "_class": "CodeModule", 60 | "_type": "npm_package", 61 | "displayName": "juvhove", 62 | "from": "testing" 63 | } 64 | } 65 | ] 66 | -------------------------------------------------------------------------------- /examples/sync-api/src/data/code-repos.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "entity": { 4 | "_key": "github_repository:ibzibid", 5 | "_class": "CodeRepo", 6 | "_type": "github_repository", 7 | "displayName": "ibzibid", 8 | "from": "testing" 9 | } 10 | }, 11 | { 12 | "entity": { 13 | "_key": "github_repository:os", 14 | "_class": "CodeRepo", 15 | "_type": "github_repository", 16 | "displayName": "os", 17 | "from": "testing" 18 | } 19 | }, 20 | { 21 | "entity": { 22 | "_key": "github_repository:ot", 23 | "_class": "CodeRepo", 24 | "_type": "github_repository", 25 | "displayName": "ot", 26 | "from": "testing" 27 | } 28 | }, 29 | { 30 | "entity": { 31 | "_key": "github_repository:leh", 32 | "_class": "CodeRepo", 33 | "_type": "github_repository", 34 | "displayName": "leh", 35 | "from": "testing" 36 | } 37 | }, 38 | { 39 | "entity": { 40 | "_key": "github_repository:codmuz", 41 | "_class": "CodeRepo", 42 | "_type": "github_repository", 43 | "displayName": "codmuz", 44 | "from": "testing" 45 | } 46 | }, 47 | { 48 | "entity": { 49 | "_key": "github_repository:hacu", 50 | "_class": "CodeRepo", 51 | "_type": "github_repository", 52 | "displayName": "hacu", 53 | "from": "testing" 54 | } 55 | }, 56 | { 57 | "entity": { 58 | "_key": "github_repository:haz", 59 | "_class": "CodeRepo", 60 | "_type": "github_repository", 61 | "displayName": "haz", 62 | "from": "testing" 63 | } 64 | } 65 | ] 66 | -------------------------------------------------------------------------------- /examples/sync-api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | successLog, 3 | logger, 4 | handleInvalidEnum, 5 | authenticate, 6 | cleanup, 7 | generateRelationships, 8 | waitForGraphResults, 9 | getIntegrationDefinition, 10 | getAllIntegrationInstances, 11 | queries, 12 | mapProp, 13 | createScope, 14 | } from './utils'; 15 | import { RelationshipTypes } from './utils/generate-relationships'; 16 | import codeModules from './data/code-modules.json'; 17 | import codeRepos from './data/code-repos.json'; 18 | 19 | const codeModulesForUpload = mapProp(codeModules, 'entity'); 20 | const codeReposForUpload = mapProp(codeRepos, 'entity'); 21 | 22 | const INTEGRATION_INSTANCE_TARGET = 23 | 'Relationships Between Entities Under Different Ownership'; 24 | const INTEGRATION_DEFINITION_TARGET = 'Custom'; 25 | 26 | const main = async (): Promise => { 27 | const action = process.argv[2]; 28 | if (handleInvalidEnum(RelationshipTypes, action) === false) return; 29 | 30 | const j1 = await authenticate(); 31 | 32 | // Integrations begin with a definition. We'll start there 33 | // and progress down in the hierarchy to find an integration 34 | // instance 35 | const integrationDefinition = await getIntegrationDefinition( 36 | j1, 37 | INTEGRATION_DEFINITION_TARGET, 38 | ); 39 | if (integrationDefinition === null) return; 40 | 41 | // Get all the integration instances underneath a definition 42 | // so we can start looking for the instance we're interested in 43 | const allIntegrationInstances = await getAllIntegrationInstances( 44 | j1, 45 | integrationDefinition, 46 | ); 47 | 48 | // GET or CREATE the Integration Instance 49 | const targetIntegrationInstance = 50 | allIntegrationInstances.find( 51 | (integrationInstance) => 52 | integrationInstance.name.toLowerCase() === 53 | INTEGRATION_INSTANCE_TARGET.toLowerCase(), 54 | ) ?? 55 | (await j1.integrationInstances.create({ 56 | name: INTEGRATION_INSTANCE_TARGET, 57 | integrationDefinitionId: integrationDefinition.id, 58 | })); 59 | 60 | if (!targetIntegrationInstance) { 61 | logger('Unable to acquire integration instance... exiting.'); 62 | return; 63 | } 64 | 65 | // Cleanup data from our last execution 66 | await cleanup(j1, targetIntegrationInstance.id); 67 | 68 | await j1.bulkUpload({ 69 | syncJobOptions: { 70 | source: 'integration-managed', 71 | integrationInstanceId: targetIntegrationInstance.id, 72 | }, 73 | entities: codeReposForUpload, 74 | }); 75 | 76 | const codeReposFromGraph = await waitForGraphResults( 77 | j1, 78 | queries.codeRepoByIntegrationId(targetIntegrationInstance.id), 79 | )(1); 80 | 81 | if (!codeReposFromGraph) { 82 | logger('Cannot find results in J1... exiting.'); 83 | return; 84 | } 85 | 86 | const relationshipConnection = RelationshipTypes[action]; 87 | const scope = createScope(); 88 | 89 | const bulkUPayload = { 90 | syncJobOptions: { 91 | scope, 92 | }, 93 | entities: codeModulesForUpload, 94 | relationships: generateRelationships( 95 | codeReposFromGraph, 96 | codeModules, 97 | relationshipConnection, 98 | ), 99 | }; 100 | 101 | await j1.bulkUpload(bulkUPayload); 102 | 103 | successLog(` 104 | If you did not use KEY_TO_KEY, you should be able to use this query to find your results in the graph: 105 | 106 | ${queries.codeModuleUsesCodeRepo} 107 | RETURN TREE 108 | `); 109 | }; 110 | 111 | main().catch(console.error); 112 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { JupiterOneClient } from '@jupiterone/jupiterone-client-nodejs'; 2 | 3 | export const authenticate = async (): Promise => { 4 | console.log('Authenticating...'); 5 | 6 | const input = { 7 | accessToken: process.env.J1_ACCESS_TOKEN, 8 | account: process.env.J1_ACCOUNT, 9 | dev: process.env.J1_DEV_ENABLED === 'true', 10 | }; 11 | 12 | const j1 = new JupiterOneClient(input); 13 | 14 | await j1.init(); 15 | 16 | console.log('Successfully authenticated...'); 17 | 18 | return j1; 19 | }; 20 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/cleanup.ts: -------------------------------------------------------------------------------- 1 | import { JupiterOneClient } from '@jupiterone/jupiterone-client-nodejs'; 2 | import { queries, mapProp } from './'; 3 | 4 | export const cleanup = async (j1: JupiterOneClient, integrationId: string) => { 5 | const codeModules = await j1.queryV1(queries.codeModuleUsesCodeRepo); 6 | const codeRepos = await j1.queryV1( 7 | queries.codeRepoByIntegrationId(integrationId), 8 | ); 9 | 10 | await j1.bulkDelete({ 11 | entities: [ 12 | ...mapProp(codeModules, 'entity'), 13 | ...mapProp(codeRepos, 'entity'), 14 | ], 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/create-scope.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { v4 as uuidV4 } from 'uuid'; 3 | 4 | const j1UniqueKeyFileLocation = `${process.cwd()}/j1-scope-key`; 5 | 6 | export const createScope = () => { 7 | let scope; 8 | if (!fs.existsSync(j1UniqueKeyFileLocation)) { 9 | scope = uuidV4(); 10 | fs.writeFileSync(j1UniqueKeyFileLocation, scope, 'utf8'); 11 | } else { 12 | scope = fs.readFileSync(j1UniqueKeyFileLocation, 'utf8'); 13 | } 14 | 15 | return scope; 16 | }; 17 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/generate-entities.ts: -------------------------------------------------------------------------------- 1 | import c from 'chance'; 2 | const chance = new c(); 3 | 4 | interface Entity { 5 | _key: string; 6 | _class: string; 7 | _type: string; 8 | displayName: string; 9 | from: string; 10 | } 11 | 12 | export const generateEntity = (): Entity => { 13 | const packageName = chance.word(); 14 | 15 | return { 16 | _key: `npm_package:${packageName}`, 17 | _class: 'CodeModule', 18 | _type: 'npm_package', 19 | displayName: packageName, 20 | from: 'testing', 21 | }; 22 | }; 23 | 24 | export const generateNumberOfEntities = (records: number): Entity[] => { 25 | return [...Array(records)].map(generateEntity); 26 | }; 27 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/generate-relationships.ts: -------------------------------------------------------------------------------- 1 | import { RelationshipForSync } from '@jupiterone/jupiterone-client-nodejs/dist/types'; 2 | import c from 'chance'; 3 | const chance = new c(); 4 | 5 | export enum RelationshipTypes { 6 | ID_TO_KEY, 7 | KEY_TO_KEY, 8 | KEY_WITH_SCOPE_AND_SOURCE_TO_KEY, 9 | } 10 | 11 | interface RelationshipConnections { 12 | _fromEntityId?: string; 13 | _fromEntityKey?: string; 14 | _toEntityKey?: string; 15 | _fromEntitySource?: string; 16 | _fromEntityScope?: string; 17 | } 18 | 19 | const relationshipConnections = { 20 | // This will work - 21 | // Matches on IDs are not source/scope specific 22 | // Our `entityTo` is within the scope/source of our upload 23 | [RelationshipTypes.ID_TO_KEY]: ( 24 | entityFrom, 25 | entityTo, 26 | ): RelationshipConnections => { 27 | return { 28 | _fromEntityId: entityFrom.entity._id, 29 | _toEntityKey: entityTo.entity._key, 30 | }; 31 | }, 32 | // This will NOT work - 33 | // Our `entityFrom` _key is in a different scope/source and we didn't specify that 34 | // Our `entityTo` is within the scope/source of our upload 35 | [RelationshipTypes.KEY_TO_KEY]: ( 36 | entityFrom, 37 | entityTo, 38 | ): RelationshipConnections => { 39 | return { 40 | _fromEntityKey: entityFrom.entity._key, 41 | _toEntityKey: entityTo.entity._key, 42 | }; 43 | }, 44 | // This will work - 45 | // Our `entityFrom` _key is in a different scope/source and we DID specify it 46 | // Our `entityTo` is within the scope/source of our upload 47 | [RelationshipTypes.KEY_WITH_SCOPE_AND_SOURCE_TO_KEY]: ( 48 | entityFrom, 49 | entityTo, 50 | ): RelationshipConnections => { 51 | return { 52 | _fromEntitySource: entityFrom.entity._source, 53 | _fromEntityScope: entityFrom.entity._integrationInstanceId, 54 | _fromEntityKey: entityFrom.entity._key, 55 | _toEntityKey: entityTo.entity._key, 56 | }; 57 | }, 58 | }; 59 | 60 | export const generateRelationships = ( 61 | entitiesFrom, 62 | entitiesTo, 63 | relationshipType, 64 | ): RelationshipForSync[] => { 65 | return entitiesFrom.map((entityFrom, i) => { 66 | const entityTo = entitiesTo[i]; 67 | const version = chance.semver(); 68 | 69 | return { 70 | _key: `${entityTo.entity.displayName}:USES:${entityFrom.entity.displayName}`, 71 | _type: 'code_repo:USES:npm_package', 72 | _class: 'USES', 73 | displayName: `USES v${version}`, 74 | version, 75 | ...(relationshipConnections?.[relationshipType]?.(entityFrom, entityTo) ?? 76 | {}), 77 | }; 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/get-all-integration-instances.ts: -------------------------------------------------------------------------------- 1 | export const getAllIntegrationInstances = async (j1, integrationDefinition) => { 2 | let instances = []; 3 | 4 | const getInstances = async (cursor) => { 5 | const integrationInstances = await j1.integrationInstances.list({ 6 | definitionId: integrationDefinition?.id, 7 | cursor, 8 | }); 9 | 10 | if ( 11 | integrationInstances?.instances && 12 | Array.isArray(integrationInstances.instances) 13 | ) { 14 | instances = [...instances, ...integrationInstances.instances]; 15 | } 16 | 17 | if (integrationInstances?.pageInfo?.hasNextPage) { 18 | return getInstances(integrationInstances.pageInfo.endCursor); 19 | } 20 | }; 21 | 22 | await getInstances(null); 23 | 24 | return instances; 25 | }; 26 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/get-integration-definition.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '.'; 2 | 3 | export const getIntegrationDefinition = async (j1, target: string) => { 4 | const integrationDefinitions = await j1.integrationDefinitions.list(); 5 | const targetIntegrationDefinition = integrationDefinitions.find( 6 | (definition) => definition.name.toLowerCase() === target.toLowerCase(), 7 | ); 8 | 9 | if (!targetIntegrationDefinition) { 10 | logger('Unable to find target integration definition.'); 11 | return null; 12 | } 13 | 14 | return targetIntegrationDefinition; 15 | }; 16 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/handle-invalid-enum.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logger'; 2 | interface Enum { 3 | [id: number]: string | number; 4 | } 5 | 6 | export const handleInvalidEnum = (anEnum: Enum, target: string): boolean => { 7 | if (!Object.values(anEnum).includes(target)) { 8 | logger('Invalid reference!', target); 9 | logger( 10 | 'Valid references are:', 11 | JSON.stringify( 12 | Object.values(anEnum).filter((rt) => typeof rt === 'string'), 13 | null, 14 | 4, 15 | ), 16 | ); 17 | 18 | return false; 19 | } 20 | return true; 21 | }; 22 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { authenticate } from './authenticate'; 2 | export { generateEntity, generateNumberOfEntities } from './generate-entities'; 3 | export { 4 | RelationshipTypes, 5 | generateRelationships, 6 | } from './generate-relationships'; 7 | export { logger, successLog } from './logger'; 8 | export { handleInvalidEnum } from './handle-invalid-enum'; 9 | export { cleanup } from './cleanup'; 10 | export { sleep } from './sleep'; 11 | export { waitForGraphResults } from './wait-for-graph-results'; 12 | export { getIntegrationDefinition } from './get-integration-definition'; 13 | export { getAllIntegrationInstances } from './get-all-integration-instances'; 14 | export { mapProp } from './map-prop'; 15 | export { createScope } from './create-scope'; 16 | export * as queries from './queries'; 17 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export const logger = (...messages: string[]): void => { 4 | console.log(chalk.red(...messages)); 5 | }; 6 | 7 | export const successLog = (...messages: string[]): void => { 8 | console.log(chalk.green(...messages)); 9 | }; 10 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/map-prop.ts: -------------------------------------------------------------------------------- 1 | export const mapProp = (array, prop) => array.map((item) => item?.[prop]); 2 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/queries.ts: -------------------------------------------------------------------------------- 1 | import codeModules from '../data/code-modules.json'; 2 | 3 | export const codeModuleUsesCodeRepo = ` 4 | FIND CodeModule 5 | WITH displayName = (${codeModules 6 | .map((codeModule) => `'${codeModule.entity.displayName}'`) 7 | .join(' OR ')}) 8 | AND from = 'testing' 9 | THAT USES << CodeRepo 10 | `; 11 | 12 | export const codeRepoByIntegrationId = (integrationId) => ` 13 | FIND CodeRepo 14 | WITH _integrationInstanceId="${integrationId}" 15 | `; 16 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = async (time: number) => { 2 | return new Promise((resolve) => { 3 | setTimeout(() => { 4 | return resolve(true); 5 | }, time); 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /examples/sync-api/src/utils/wait-for-graph-results.ts: -------------------------------------------------------------------------------- 1 | import { sleep, logger } from '.'; 2 | 3 | export const waitForGraphResults = (j1, j1Query) => { 4 | const repeat = async (counter: number = 1) => { 5 | counter = +counter; 6 | if (isNaN(counter)) { 7 | logger('Counter is not a number... exiting.'); 8 | return null; 9 | } 10 | if (counter > 5) return null; 11 | 12 | const results = await j1.queryV1(j1Query, { fetchPolicy: 'no-cache' }); 13 | 14 | if (!results || !results?.length) { 15 | console.log('Sleeping for 5 seconds...'); 16 | await sleep(5000); 17 | return repeat(++counter); 18 | } 19 | 20 | return results; 21 | }; 22 | 23 | return repeat; 24 | }; 25 | -------------------------------------------------------------------------------- /examples/sync-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es2018", 5 | "lib": ["es2018", "dom"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noUnusedLocals": true, 9 | "pretty": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true 12 | }, 13 | "exclude": ["dist"] 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | clearMocks: true, 4 | restoreMocks: true, 5 | testMatch: [ 6 | '/**/*.test.ts', 7 | '!**/node_modules/*', 8 | '!**/dist/*', 9 | '!**/*.bak/*', 10 | ], 11 | collectCoverage: false, 12 | transform: { 13 | '^.+\\.[tj]sx?$': [ 14 | 'ts-jest', 15 | { 16 | isolatedModules: true, 17 | }, 18 | ], 19 | }, 20 | testEnvironment: 'node', 21 | testPathIgnorePatterns: ['/dist/', '/node_modules/'], 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupiterone/jupiterone-client-nodejs", 3 | "version": "2.1.2", 4 | "description": "A node.js client wrapper for JupiterOne public API", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/JupiterOne/jupiterone-client-nodejs" 8 | }, 9 | "license": "MIT", 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "author": "JupiterOne ", 13 | "files": [ 14 | "LICENSE", 15 | "dist", 16 | "bin" 17 | ], 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "bin": { 22 | "j1": "bin/j1" 23 | }, 24 | "scripts": { 25 | "env": "export $(grep -v '^#' .env | xargs);", 26 | "format": "yarn prettier --write '**/*.{ts,js,json,css,md,yml}'", 27 | "type-check": "tsc --noEmit", 28 | "lint": "eslint . --cache --fix --ext .ts,.tsx", 29 | "pretest": "yarn lint && yarn type-check", 30 | "test": "jest", 31 | "test-coverage": "jest --coverage", 32 | "test:upsert": "yarn env; node src/j1cli.js -o upsert --entity --account $J1_ACCOUNT_ID --key $J1_API_TOKEN --input-file ./examples-yaml/entities.yml", 33 | "copydist": "cp -R LICENSE *.md yarn.lock package.json ./dist/", 34 | "distpackage": "(cd ./dist && sed -ibak -e 's#dist/inde[x]#index#g' package.json && rm package.jsonbak)", 35 | "prebuild": "yarn test", 36 | "build": "tsc -p tsconfig.dist.json --declaration", 37 | "prepack": "yarn build", 38 | "prepare": "husky install", 39 | "audit:fix": "npm_config_yes=true npx yarn-audit-fix" 40 | }, 41 | "dependencies": { 42 | "@jupiterone/jupiterone-alert-rules": "^0.20.0", 43 | "@lifeomic/attempt": "^3.0.3", 44 | "apollo-cache-inmemory": "^1.5.1", 45 | "apollo-client": "^2.6.10", 46 | "apollo-link": "^1.2.14", 47 | "apollo-link-batch-http": "^1.2.13", 48 | "apollo-link-retry": "^2.2.13", 49 | "bunyan-category": "^0.4.0", 50 | "chalk": "^4.1.2", 51 | "cli-progress": "^3.12.0", 52 | "commander": "^5.0.0", 53 | "graphql": "^14.6.0", 54 | "graphql-tag": "^2.10.1", 55 | "inquirer": "^8.2.0", 56 | "js-yaml": "^3.13.1", 57 | "node-fetch": "^2.6.7", 58 | "p-all": "^2.1.0" 59 | }, 60 | "devDependencies": { 61 | "@pollyjs/adapter-node-http": "^2.7.0", 62 | "@pollyjs/core": "^2.6.3", 63 | "@types/bunyan": "^1.8.8", 64 | "@types/jest": "^27.4.0", 65 | "@types/node": "^13.9.8", 66 | "@types/node-fetch": "^3.0.3", 67 | "@typescript-eslint/eslint-plugin": "^5.10.2", 68 | "@typescript-eslint/parser": "^5.10.2", 69 | "dotenv": "^7.0.0", 70 | "eslint": "^8.8.0", 71 | "eslint-config-prettier": "^6.10.1", 72 | "eslint-plugin-jest": "^23.8.2", 73 | "husky": "^7.0.0", 74 | "jest": "^27.4.7", 75 | "lint-staged": "^12.3.3", 76 | "prettier": "^2.0.2", 77 | "ts-jest": "^27.1.3", 78 | "typescript": "^3.8.3" 79 | }, 80 | "eslintConfig": { 81 | "extends": [ 82 | "eslint:recommended", 83 | "plugin:@typescript-eslint/recommended", 84 | "plugin:jest/recommended", 85 | "prettier", 86 | "prettier/@typescript-eslint" 87 | ], 88 | "env": { 89 | "node": true, 90 | "es6": true 91 | }, 92 | "parserOptions": { 93 | "project": "./tsconfig.json" 94 | }, 95 | "rules": { 96 | "@typescript-eslint/no-use-before-define": [ 97 | "error", 98 | { 99 | "functions": false 100 | } 101 | ], 102 | "@typescript-eslint/no-var-requires": [ 103 | "warn" 104 | ] 105 | } 106 | }, 107 | "prettier": { 108 | "trailingComma": "all", 109 | "proseWrap": "always", 110 | "singleQuote": true 111 | }, 112 | "lint-staged": { 113 | "linters": { 114 | "*.{ts,js,json,css,md,yml}": [ 115 | "yarn format", 116 | "git add" 117 | ] 118 | }, 119 | "ignore": [] 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | proseWrap: 'always', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /scripts/bulk-delete.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo Enter your JupiterOne accountId 3 | read J1_ACCOUNT_ID 4 | 5 | echo Enter your JupiterOne API token 6 | read J1_API_TOKEN 7 | 8 | echo Enter the J1QL query to get the list of entities to be deleted. For example: Find aws_instance with _source='system-mapper' 9 | read J1_QUERY 10 | 11 | echo Executing... 12 | 13 | j1 \ 14 | -q "$J1_QUERY as e return e._id as entityId" \ 15 | -a $J1_ACCOUNT_ID -k $J1_API_TOKEN \ 16 | --output-file /tmp/j1-temp-entities.json 17 | j1 \ 18 | -o delete --entity --hard-delete \ 19 | -f /tmp/j1-temp-entities.json \ 20 | -a $J1_ACCOUNT_ID -k $J1_API_TOKEN 21 | 22 | rm /tmp/j1-temp-entities.json 23 | -------------------------------------------------------------------------------- /src/example-testing-data/example-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "queryV1": { 4 | "type": "deferred", 5 | "data": null, 6 | "url": "https://an-s3-bucket/temp/j1dev/deferred/abc/state.json" 7 | } 8 | }, 9 | "loading": false, 10 | "networkStatus": 7, 11 | "stale": false 12 | } 13 | -------------------------------------------------------------------------------- /src/example-testing-data/example-deferred-result.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "COMPLETED", 3 | "url": "https://an-s3-bucket/temp/j1dev/deferred/abc/state.json", 4 | "correlationId": "abc", 5 | "data": { 6 | "id": "abc", 7 | "entity": { 8 | "_class": ["Class"], 9 | "_type": ["an_example_type"], 10 | "_key": "abc", 11 | "displayName": "display_name", 12 | "_integrationType": "github", 13 | "_integrationClass": ["ITS", "SCM", "VCS", "VersionControl"], 14 | "_integrationDefinitionId": "def", 15 | "_integrationName": "JupiterOne", 16 | "_beginOn": "2021-12-03T01:10:33.604Z", 17 | "_id": "ghi", 18 | "_integrationInstanceId": "nmi", 19 | "_version": 11, 20 | "_accountId": "an_account", 21 | "_deleted": false, 22 | "_source": "source", 23 | "_createdOn": "2021-08-20T20:15:09.583Z" 24 | }, 25 | "properties": { 26 | "disabled": false, 27 | "empty": false, 28 | "fork": false, 29 | "forkingAllowed": false 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/example-testing-data/example-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "an_id", 3 | "name": "custom", 4 | "type": "managed_lambda", 5 | "title": "Custom", 6 | "offsiteUrl": null, 7 | "offsiteButtonTitle": null, 8 | "offsiteStatusQuery": null, 9 | "integrationType": "an_integration_type", 10 | "integrationClass": [], 11 | "beta": false, 12 | "repoWebLink": null, 13 | "invocationPaused": null, 14 | "__typename": "IntegrationDefinition" 15 | } 16 | -------------------------------------------------------------------------------- /src/example-testing-data/example-end-result.ts: -------------------------------------------------------------------------------- 1 | import { exampleEntity } from './example-entity'; 2 | export const exampleEndResult = { 3 | type: 'list', 4 | data: [exampleEntity], 5 | totalCount: 1, 6 | cursor: 'a_jwt_token', 7 | }; 8 | -------------------------------------------------------------------------------- /src/example-testing-data/example-entity.ts: -------------------------------------------------------------------------------- 1 | export const exampleEntity = { 2 | id: 'abc', 3 | entity: { 4 | _class: ['Class'], 5 | _type: ['an_example_type'], 6 | _key: 'abc', 7 | displayName: 'display_name', 8 | _integrationType: 'github', 9 | _integrationClass: ['ITS', 'SCM', 'VCS', 'VersionControl'], 10 | _integrationDefinitionId: 'def', 11 | _integrationName: 'JupiterOne', 12 | _beginOn: '2021-12-03T01:10:33.604Z', 13 | _id: 'ghi', 14 | _integrationInstanceId: 'nmi', 15 | _version: 11, 16 | _accountId: 'an_account', 17 | _deleted: false, 18 | _source: 'source', 19 | _createdOn: '2021-08-20T20:15:09.583Z', 20 | }, 21 | properties: { 22 | disabled: false, 23 | empty: false, 24 | fork: false, 25 | forkingAllowed: false, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/example-testing-data/example-integration-instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountId": "a-j1-account", 3 | "config": { 4 | "@tag": { "AccountName": "Test" }, 5 | "apiUser": "an-api-user@jupiterone.com", 6 | "apiToken": "***masked***" 7 | }, 8 | "description": "An example integration instance", 9 | "id": "abc", 10 | "integrationDefinitionId": "def", 11 | "name": "Test", 12 | "pollingInterval": "ONE_WEEK", 13 | "pollingIntervalCronExpression": null, 14 | "__typename": "IntegrationInstance" 15 | } 16 | -------------------------------------------------------------------------------- /src/example-testing-data/example-sync-job.ts: -------------------------------------------------------------------------------- 1 | import { SyncJobModes } from '..'; 2 | 3 | export const exampleSyncJob = { 4 | job: { 5 | source: 'a_source', 6 | scope: 'a_scope', 7 | accountId: 'abc123', 8 | id: 'an_id', 9 | status: 'AWAITING_UPLOADS', 10 | done: false, 11 | startTimestamp: new Date(), 12 | numEntitiesUploaded: 0, 13 | numEntitiesCreated: 0, 14 | numEntitiesUpdated: 0, 15 | numEntitiesDeleted: 0, 16 | numEntityCreateErrors: 0, 17 | numEntityUpdateErrors: 0, 18 | numEntityDeleteErrors: 0, 19 | numEntityRawDataEntriesUploaded: 0, 20 | numEntityRawDataEntriesCreated: 0, 21 | numEntityRawDataEntriesUpdated: 0, 22 | numEntityRawDataEntriesDeleted: 0, 23 | numEntityRawDataEntryCreateErrors: 0, 24 | numEntityRawDataEntryUpdateErrors: 0, 25 | numEntityRawDataEntryDeleteErrors: 0, 26 | numRelationshipsUploaded: 0, 27 | numRelationshipsCreated: 0, 28 | numRelationshipsUpdated: 0, 29 | numRelationshipsDeleted: 0, 30 | numRelationshipCreateErrors: 0, 31 | numRelationshipUpdateErrors: 0, 32 | numRelationshipDeleteErrors: 0, 33 | numRelationshipRawDataEntriesUploaded: 0, 34 | numRelationshipRawDataEntriesCreated: 0, 35 | numRelationshipRawDataEntriesUpdated: 0, 36 | numRelationshipRawDataEntriesDeleted: 0, 37 | numRelationshipRawDataEntryCreateErrors: 0, 38 | numRelationshipRawDataEntryUpdateErrors: 0, 39 | numRelationshipRawDataEntryDeleteErrors: 0, 40 | numMappedRelationshipsCreated: 0, 41 | numMappedRelationshipsUpdated: 0, 42 | numMappedRelationshipsDeleted: 0, 43 | numMappedRelationshipCreateErrors: 0, 44 | numMappedRelationshipUpdateErrors: 0, 45 | numMappedRelationshipDeleteErrors: 0, 46 | syncMode: SyncJobModes.DIFF, 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/index.new.test.ts: -------------------------------------------------------------------------------- 1 | import { JupiterOneClient, SyncJobModes, SyncJobSources } from '.'; 2 | import { exampleSyncJob } from './example-testing-data/example-sync-job'; 3 | import { exampleEntity } from './example-testing-data/example-entity'; 4 | import { exampleEndResult } from './example-testing-data/example-end-result'; 5 | import exampleDeferredResult from './example-testing-data/example-deferred-result.json'; 6 | import exampleData from './example-testing-data/example-data.json'; 7 | import exampleDefinition from './example-testing-data/example-definition.json'; 8 | import exampleIntegrationInstance from './example-testing-data/example-integration-instance.json'; 9 | 10 | jest.mock('./networkRequest', () => ({ 11 | networkRequest: jest 12 | .fn() 13 | .mockImplementationOnce(() => { 14 | return exampleDeferredResult; 15 | }) 16 | .mockImplementationOnce(() => { 17 | return exampleEndResult; 18 | }), 19 | })); 20 | 21 | describe('Core Index Tests', () => { 22 | let j1; 23 | 24 | beforeAll(async () => { 25 | const jupiterOneClient = new JupiterOneClient({ 26 | account: '', 27 | }); 28 | 29 | const baseQuery = (): Record => { 30 | return exampleData; 31 | }; 32 | 33 | jupiterOneClient.init = jest.fn(() => { 34 | (jupiterOneClient.graphClient as unknown) = { 35 | query: jest.fn().mockImplementationOnce(baseQuery), 36 | }; 37 | return Promise.resolve(jupiterOneClient); 38 | }); 39 | 40 | j1 = await jupiterOneClient.init(); 41 | }); 42 | 43 | describe('Ensure JupiterOneClient Has Correct Props', () => { 44 | test('queryV1', () => { 45 | expect(j1).toHaveProperty('queryV1'); 46 | }); 47 | test('queryGraphQL', () => { 48 | expect(j1).toHaveProperty('queryGraphQL'); 49 | }); 50 | test('mutateAlertRule', () => { 51 | expect(j1).toHaveProperty('mutateAlertRule'); 52 | }); 53 | test('createEntity', () => { 54 | expect(j1).toHaveProperty('createEntity'); 55 | }); 56 | test('updateEntity', () => { 57 | expect(j1).toHaveProperty('updateEntity'); 58 | }); 59 | test('deleteEntity', () => { 60 | expect(j1).toHaveProperty('deleteEntity'); 61 | }); 62 | test('createRelationship', () => { 63 | expect(j1).toHaveProperty('createRelationship'); 64 | }); 65 | test('upsertEntityRawData', () => { 66 | expect(j1).toHaveProperty('upsertEntityRawData'); 67 | }); 68 | test('createQuestion', () => { 69 | expect(j1).toHaveProperty('createQuestion'); 70 | }); 71 | test('updateQuestion', () => { 72 | expect(j1).toHaveProperty('updateQuestion'); 73 | }); 74 | test('deleteQuestion', () => { 75 | expect(j1).toHaveProperty('deleteQuestion'); 76 | }); 77 | test('integrationInstances', () => { 78 | expect(j1).toHaveProperty('integrationInstances'); 79 | }); 80 | test('integrationInstances props', () => { 81 | expect(j1.integrationInstances).toHaveProperty('list'); 82 | expect(j1.integrationInstances).toHaveProperty('get'); 83 | expect(j1.integrationInstances).toHaveProperty('create'); 84 | expect(j1.integrationInstances).toHaveProperty('update'); 85 | expect(j1.integrationInstances).toHaveProperty('delete'); 86 | }); 87 | 88 | test('startSyncJob', () => { 89 | expect(j1).toHaveProperty('startSyncJob'); 90 | }); 91 | test('uploadGraphObjectsForDeleteSyncJob', () => { 92 | expect(j1).toHaveProperty('uploadGraphObjectsForDeleteSyncJob'); 93 | }); 94 | test('uploadGraphObjectsForSyncJob', () => { 95 | expect(j1).toHaveProperty('uploadGraphObjectsForSyncJob'); 96 | }); 97 | test('finalizeSyncJob', () => { 98 | expect(j1).toHaveProperty('finalizeSyncJob'); 99 | }); 100 | test('fetchSyncJobStatus', () => { 101 | expect(j1).toHaveProperty('fetchSyncJobStatus'); 102 | }); 103 | test('bulkUpload', () => { 104 | expect(j1).toHaveProperty('bulkUpload'); 105 | }); 106 | test('bulkDelete', () => { 107 | expect(j1).toHaveProperty('bulkDelete'); 108 | }); 109 | 110 | test('--api-base-url properly sets URLs', () => { 111 | const jupiterOneCustomURLClient = new JupiterOneClient({ 112 | account: '', 113 | apiBaseUrl: 'https://api.test.jupiterone.io', 114 | }); 115 | expect(jupiterOneCustomURLClient).toHaveProperty( 116 | 'queryEndpoint', 117 | 'https://api.test.jupiterone.io/graphql', 118 | ); 119 | expect(jupiterOneCustomURLClient).toHaveProperty( 120 | 'rulesEndpoint', 121 | 'https://api.test.jupiterone.io/rules/graphql', 122 | ); 123 | }); 124 | }); 125 | 126 | describe('queryV1', () => { 127 | test('Happy Test', async () => { 128 | const res = await j1.queryV1('Find github_repo'); 129 | expect(res).toEqual(exampleEndResult.data); 130 | }); 131 | }); 132 | 133 | describe('listIntegrationInstances', () => { 134 | const setup = (): void => { 135 | j1.graphClient.query = jest.fn().mockImplementation(() => { 136 | return Promise.resolve({ 137 | data: { 138 | integrationInstances: { 139 | instances: [exampleIntegrationInstance], 140 | }, 141 | }, 142 | }); 143 | }); 144 | }; 145 | 146 | beforeEach(() => { 147 | setup(); 148 | }); 149 | 150 | test('Sad Test - Query Fails', async () => { 151 | j1.graphClient.query = jest.fn().mockImplementation(() => { 152 | return { 153 | errors: [{ message: 'A Problem' }], 154 | }; 155 | }); 156 | 157 | const expectedValue = { data: [], errors: [{ message: 'A Problem' }] }; 158 | const test = await j1.integrationInstances.list(); 159 | await expect(test).toEqual(expectedValue); 160 | }); 161 | 162 | test('Happy Test - Returns Instances', async () => { 163 | const res = await j1.integrationInstances.list({ 164 | definitionId: 'abc', 165 | }); 166 | expect(res.data).toEqual([exampleIntegrationInstance]); 167 | }); 168 | }); 169 | 170 | describe('listIntegrationDefinitions', () => { 171 | const setup = (): void => { 172 | j1.graphClient.query = jest.fn().mockImplementation(() => { 173 | return Promise.resolve({ 174 | data: { 175 | integrationDefinitions: { 176 | definitions: [exampleDefinition], 177 | }, 178 | }, 179 | }); 180 | }); 181 | }; 182 | 183 | beforeEach(() => { 184 | setup(); 185 | }); 186 | 187 | test('Sad Test - Query Fails', async () => { 188 | j1.graphClient.query = jest.fn().mockImplementation(() => { 189 | return { 190 | errors: [{ message: 'A Problem' }], 191 | }; 192 | }); 193 | 194 | const expectedValue = { data: [], errors: [{ message: 'A Problem' }] }; 195 | const test = await j1.integrationDefinitions.list(); 196 | await expect(test).toEqual(expectedValue); 197 | }); 198 | 199 | test('Happy Test - Returns Definitions', async () => { 200 | const test = await j1.integrationDefinitions.list(); 201 | expect(test).toEqual({ data: [exampleDefinition], errors: [] }); 202 | }); 203 | }); 204 | 205 | describe('bulkUpload', () => { 206 | const setup = (): void => { 207 | j1.startSyncJob = jest.fn().mockImplementation(() => { 208 | return Promise.resolve(exampleSyncJob); 209 | }); 210 | j1.uploadGraphObjectsForSyncJob = jest.fn().mockImplementation(() => { 211 | return Promise.resolve(exampleSyncJob); 212 | }); 213 | j1.finalizeSyncJob = jest.fn().mockImplementation(() => { 214 | return Promise.resolve(exampleSyncJob); 215 | }); 216 | }; 217 | 218 | beforeEach(() => { 219 | setup(); 220 | }); 221 | 222 | test('Sad Test - Using Sync Mode `DIFF` and Source `API` Without Scope Returns Early', async () => { 223 | const areValidSyncJobOptionsSpy = jest.spyOn( 224 | j1, 225 | 'areValidSyncJobOptions', 226 | ); 227 | const argumentOne = { 228 | syncJobOptions: { 229 | source: SyncJobSources.API, 230 | syncMode: SyncJobModes.DIFF, 231 | }, 232 | entities: [exampleEntity], 233 | }; 234 | 235 | const res = await j1.bulkUpload(argumentOne); 236 | expect(res).toEqual(undefined); 237 | expect(areValidSyncJobOptionsSpy).toReturnWith(false); 238 | }); 239 | 240 | test('Happy Test - No Entities/Relationships Should Return Early', async () => { 241 | const argumentOne = {}; 242 | 243 | const res = await j1.bulkUpload(argumentOne); 244 | expect(res).toEqual(undefined); 245 | }); 246 | 247 | test('Happy Test - Default Options', async () => { 248 | const targetScope = 'test-scope'; 249 | 250 | const argumentOne = { 251 | syncJobOptions: { 252 | scope: targetScope, 253 | }, 254 | entities: [exampleEntity], 255 | }; 256 | 257 | const res = await j1.bulkUpload(argumentOne); 258 | expect(res).toEqual({ 259 | syncJobId: exampleSyncJob.job.id, 260 | finalizeResult: exampleSyncJob, 261 | }); 262 | 263 | const expectedArgument = { 264 | source: SyncJobSources.API, 265 | syncMode: SyncJobModes.DIFF, 266 | scope: targetScope, 267 | }; 268 | 269 | expect(j1.startSyncJob).toHaveBeenCalledWith(expectedArgument); 270 | }); 271 | 272 | test('Happy Test - User Provided Options', async () => { 273 | const argumentOne = { 274 | syncJobOptions: { 275 | scope: 'an_example_scope', 276 | syncMode: SyncJobModes.CREATE_OR_UPDATE, 277 | }, 278 | entities: [exampleEntity], 279 | }; 280 | 281 | const res = await j1.bulkUpload(argumentOne); 282 | expect(res).toEqual({ 283 | syncJobId: exampleSyncJob.job.id, 284 | finalizeResult: exampleSyncJob, 285 | }); 286 | 287 | const expectedArgument = { 288 | source: SyncJobSources.API, 289 | scope: 'an_example_scope', 290 | syncMode: SyncJobModes.CREATE_OR_UPDATE, 291 | }; 292 | 293 | expect(j1.startSyncJob).toHaveBeenCalledWith(expectedArgument); 294 | }); 295 | }); 296 | }); 297 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { JupiterOneClient } from '.'; 2 | const { Polly } = require('@pollyjs/core'); 3 | const NodeHTTPAdapter = require('@pollyjs/adapter-node-http'); 4 | 5 | Polly.register(NodeHTTPAdapter); 6 | 7 | const FAKE_ACCOUNT = 'johndoe'; 8 | const FAKE_KEY = 'abc123'; 9 | 10 | const j1qlString = 'Find jupiterone_account'; 11 | const mockDeferredResponse = { 12 | data: { 13 | queryV1: { 14 | data: null, 15 | type: 'deferred', 16 | url: 'https://jqs-deferred.s3.amazonaws.com/deferred/1', 17 | __typename: 'QueryV1Response', 18 | }, 19 | }, 20 | }; 21 | 22 | const mockQueryV1Response = { 23 | data: [], 24 | }; 25 | 26 | const mockS3ResponsePass = { 27 | status: 'COMPLETED', 28 | error: null, 29 | url: 'https://jqs-deferred.s3.amazonaws.com/s3-deferred-pass/1', 30 | }; 31 | 32 | let polly; 33 | let j1Client; 34 | let attempts = 0; 35 | let attemptsToFail = 0; 36 | 37 | beforeEach(async () => { 38 | attempts = 0; 39 | 40 | j1Client = await new JupiterOneClient({ 41 | account: FAKE_ACCOUNT, 42 | accessToken: FAKE_KEY, 43 | }).init(); 44 | 45 | polly = new Polly('JupiterOneClient tests', { 46 | adapters: ['node-http'], 47 | }); 48 | 49 | polly.server 50 | .any('https://jqs-deferred.s3.amazonaws.com/s3-deferred-pass/1') 51 | .intercept((req, res) => { 52 | res.status(200); 53 | res.json(mockQueryV1Response); 54 | }); 55 | 56 | polly.server 57 | .any('https://jqs-deferred.s3.amazonaws.com/deferred/1') 58 | .intercept((req, res) => { 59 | // assume deferred response passes. 60 | res.status(200); 61 | res.json(mockS3ResponsePass); 62 | }); 63 | 64 | polly.server 65 | .post('https://api.us.jupiterone.io/graphql') 66 | .intercept((req, res) => { 67 | attempts++; 68 | const shouldFailThisTime = attempts <= attemptsToFail; 69 | 70 | res.status(shouldFailThisTime ? 401 : 200); 71 | res.json(shouldFailThisTime ? {} : mockDeferredResponse); 72 | }); 73 | }); 74 | 75 | afterEach(() => { 76 | return polly.stop(); // returns a Promise 77 | }); 78 | 79 | describe('failing 4 times', () => { 80 | beforeEach(() => { 81 | attemptsToFail = 4; 82 | }); 83 | 84 | test('client retries failed requests and returns successful response', async () => { 85 | const results = await j1Client.queryV1(j1qlString); 86 | expect(results.length).toBe(0); 87 | }, 100000); 88 | }); 89 | 90 | describe('failing 5 times', () => { 91 | beforeEach(() => { 92 | attemptsToFail = 5; 93 | }); 94 | 95 | test('client retries 5 times and throws error', async () => { 96 | await expect(j1Client.queryV1(j1qlString)).rejects.toThrow( 97 | /Network error: Response not successful: Received status code 401/, 98 | ); 99 | }, 100000); 100 | }); 101 | 102 | describe('sad path', () => { 103 | beforeEach(() => { 104 | attemptsToFail = 0; 105 | }); 106 | 107 | test('receives undefined for options', async () => { 108 | const res = await j1Client.queryV1(j1qlString, undefined); 109 | expect(res).toEqual([]); 110 | }); 111 | test('receives null for options', async () => { 112 | const res = await j1Client.queryV1(j1qlString, null); 113 | expect(res).toEqual([]); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, ApolloError, QueryOptions } from 'apollo-client'; 2 | import { InMemoryCache } from 'apollo-cache-inmemory'; 3 | import { ApolloLink } from 'apollo-link'; 4 | import { RetryLink } from 'apollo-link-retry'; 5 | import { BatchHttpLink } from 'apollo-link-batch-http'; 6 | import fetch, { RequestInit, Response as FetchResponse } from 'node-fetch'; 7 | import { retry } from '@lifeomic/attempt'; 8 | import gql from 'graphql-tag'; 9 | import cliProgress from 'cli-progress'; 10 | 11 | import Logger, { createLogger } from 'bunyan-category'; 12 | 13 | import { networkRequest } from './networkRequest'; 14 | 15 | import { 16 | Entity, 17 | EntityForSync, 18 | Relationship, 19 | RelationshipForSync, 20 | IntegrationDefinition, 21 | ListIntegrationDefinitions, 22 | IntegrationInstance, 23 | ListIntegrationInstances, 24 | ListIntegrationInstancesOptions, 25 | EntitySource, 26 | } from './types'; 27 | 28 | import { 29 | CREATE_ENTITY, 30 | UPDATE_ENTITY, 31 | DELETE_ENTITY, 32 | CREATE_RELATIONSHIP, 33 | UPSERT_ENTITY_RAW_DATA, 34 | QUERY_V1, 35 | CREATE_INLINE_ALERT_RULE, 36 | CREATE_REFERENCED_ALERT_RULE, 37 | UPDATE_INLINE_ALERT_RULE, 38 | UPDATE_REFERENCED_ALERT_RULE, 39 | CREATE_QUESTION, 40 | UPDATE_QUESTION, 41 | DELETE_QUESTION, 42 | LIST_INTEGRATION_INSTANCES, 43 | LIST_INTEGRATION_DEFINITIONS, 44 | } from './queries'; 45 | import { query, QueryTypes } from './util/query'; 46 | 47 | const QUERY_RESULTS_TIMEOUT = 1000 * 60 * 5; // Poll s3 location for 5 minutes before timeout. 48 | 49 | const JobStatus = { 50 | IN_PROGRESS: 'IN_PROGRESS', 51 | COMPLETED: 'COMPLETED', 52 | FAILED: 'FAILED', 53 | }; 54 | 55 | function sleep(ms: number): Promise { 56 | return new Promise(function (resolve) { 57 | return setTimeout(resolve, ms); 58 | }); 59 | } 60 | 61 | export class FetchError extends Error { 62 | httpStatusCode: number; 63 | responseBody: string; 64 | response: FetchResponse; 65 | method: string; 66 | url: string; 67 | nameForLogging?: string; 68 | 69 | constructor(options: { 70 | responseBody: string; 71 | response: FetchResponse; 72 | method: string; 73 | url: string; 74 | nameForLogging?: string; 75 | }) { 76 | super( 77 | `JupiterOne API error. Response not OK (requestName=${options.nameForLogging || '(none)' 78 | }, status=${options.response.status}, url=${options.url}, method=${options.method 79 | }). Response: ${options.responseBody}`, 80 | ); 81 | this.httpStatusCode = options.response.status; 82 | this.responseBody = options.responseBody; 83 | this.response = options.response; 84 | this.method = options.method; 85 | this.url = options.url; 86 | this.nameForLogging = options.nameForLogging; 87 | } 88 | } 89 | 90 | async function makeFetchRequest( 91 | url: string, 92 | options: RequestInit, 93 | nameForLogging?: string, 94 | ): Promise { 95 | return retry( 96 | async () => { 97 | const response = await fetch(url, options); 98 | const { status } = response; 99 | if (status < 200 || status >= 300) { 100 | const responseBody = await response.text(); 101 | throw new FetchError({ 102 | method: options?.method, 103 | response, 104 | responseBody, 105 | url, 106 | nameForLogging, 107 | }); 108 | } 109 | return response; 110 | }, 111 | { 112 | maxAttempts: 5, 113 | delay: 1000, 114 | handleError(err, context, options) { 115 | const possibleFetchError = err as Partial; 116 | const { httpStatusCode } = possibleFetchError; 117 | if (httpStatusCode !== undefined) { 118 | if (httpStatusCode < 500) { 119 | context.abort(); 120 | } 121 | } 122 | }, 123 | }, 124 | ); 125 | } 126 | 127 | async function validateSyncJobResponse( 128 | response: FetchResponse, 129 | ): Promise { 130 | const rawBody = await response.json(); 131 | const body = rawBody as Partial; 132 | if (!body.job) { 133 | throw new Error( 134 | `JupiterOne API error. Sync job response did not return job. Response: ${JSON.stringify( 135 | rawBody, 136 | null, 137 | 2, 138 | )}`, 139 | ); 140 | } 141 | return body as SyncJobResponse; 142 | } 143 | 144 | export interface JupiterOneEntityMetadata { 145 | _rawDataHashes?: string; 146 | _integrationDefinitionId?: string; 147 | _integrationInstanceId?: string; 148 | _integrationClass?: string | string[]; 149 | _integrationType?: string; 150 | _integrationName?: string; 151 | _createdOn: number; 152 | _beginOn: number; 153 | _version: number; 154 | _accountId: string; 155 | _deleted: boolean; 156 | _source: EntitySource; 157 | _id: string; 158 | _key: string; 159 | _class: string[]; 160 | _type: string | string[]; 161 | displayName?: string; 162 | } 163 | 164 | export interface JupiterOneEntity { 165 | entity: JupiterOneEntityMetadata; 166 | properties: any; 167 | } 168 | 169 | export interface QueryResult { 170 | id: string; 171 | entity: object; 172 | properties: object; 173 | } 174 | 175 | export interface JupiterOneClientOptions { 176 | account: string; 177 | accessToken?: string; 178 | dev?: boolean; 179 | useRulesEndpoint?: boolean; 180 | apiBaseUrl?: string; 181 | logger?: Logger; 182 | } 183 | 184 | export enum SyncJobStatus { 185 | AWAITING_UPLOADS = 'AWAITING_UPLOADS', 186 | FINALIZE_PENDING = 'FINALIZE_PENDING', 187 | FINALIZING_ENTITIES = 'FINALIZING_ENTITIES', 188 | FINALIZING_RELATIONSHIPS = 'FINALIZING_RELATIONSHIPS', 189 | ABORTED = 'ABORTED', 190 | FINISHED = 'FINISHED', 191 | UNKNOWN = 'UNKNOWN', 192 | ERROR_BAD_DATA = 'ERROR_BAD_DATA', 193 | ERROR_UNEXPECTED_FAILURE = 'ERROR_UNEXPECTED_FAILURE', 194 | } 195 | 196 | export type SyncJob = { 197 | source: SyncJobSources; 198 | scope: string; 199 | accountId: string; 200 | id: string; 201 | status: SyncJobStatus; 202 | done: boolean; 203 | startTimestamp: number; 204 | numEntitiesUploaded: number; 205 | numEntitiesCreated: number; 206 | numEntitiesUpdated: number; 207 | numEntitiesDeleted: number; 208 | numEntityCreateErrors: number; 209 | numEntityUpdateErrors: number; 210 | numEntityDeleteErrors: number; 211 | numEntityRawDataEntriesUploaded: number; 212 | numEntityRawDataEntriesCreated: number; 213 | numEntityRawDataEntriesUpdated: number; 214 | numEntityRawDataEntriesDeleted: number; 215 | numEntityRawDataEntryCreateErrors: number; 216 | numEntityRawDataEntryUpdateErrors: number; 217 | numEntityRawDataEntryDeleteErrors: number; 218 | numRelationshipsUploaded: number; 219 | numRelationshipsCreated: number; 220 | numRelationshipsUpdated: number; 221 | numRelationshipsDeleted: number; 222 | numRelationshipCreateErrors: number; 223 | numRelationshipUpdateErrors: number; 224 | numRelationshipDeleteErrors: number; 225 | numRelationshipRawDataEntriesUploaded: number; 226 | numRelationshipRawDataEntriesCreated: number; 227 | numRelationshipRawDataEntriesUpdated: number; 228 | numRelationshipRawDataEntriesDeleted: number; 229 | numRelationshipRawDataEntryCreateErrors: number; 230 | numRelationshipRawDataEntryUpdateErrors: number; 231 | numRelationshipRawDataEntryDeleteErrors: number; 232 | numMappedRelationshipsCreated: number; 233 | numMappedRelationshipsUpdated: number; 234 | numMappedRelationshipsDeleted: number; 235 | numMappedRelationshipCreateErrors: number; 236 | numMappedRelationshipUpdateErrors: number; 237 | numMappedRelationshipDeleteErrors: number; 238 | syncMode: SyncJobModes; 239 | }; 240 | 241 | export type SyncJobOptions = { 242 | source?: SyncJobSources; 243 | scope?: string; 244 | syncMode?: string; 245 | integrationInstanceId?: string; 246 | }; 247 | 248 | export enum SyncJobSources { 249 | API = 'api', 250 | INTEGRATION_MANAGED = 'integration-managed', 251 | } 252 | 253 | export enum SyncJobModes { 254 | DIFF = 'DIFF', 255 | CREATE_OR_UPDATE = 'CREATE_OR_UPDATE', 256 | } 257 | 258 | export type SyncJobResponse = { 259 | job: SyncJob; 260 | }; 261 | 262 | export type PublishEventsResponse = { 263 | events: Array<{ 264 | id: string; 265 | name: string; 266 | description: string; 267 | createDate: number; 268 | }>; 269 | }; 270 | 271 | export type ObjectDeletion = { 272 | _id: string; 273 | }; 274 | 275 | export type GraphObjectDeletionPayload = { 276 | deleteEntities: ObjectDeletion[]; 277 | deleteRelationships: ObjectDeletion[]; 278 | }; 279 | 280 | export type SyncJobResult = { 281 | syncJobId: string; 282 | finalizeResult: SyncJobResponse; 283 | }; 284 | 285 | export class JupiterOneClient { 286 | graphClient: ApolloClient; 287 | headers?: Record; 288 | account: string; 289 | accessToken: string; 290 | useRulesEndpoint: boolean; 291 | apiUrl: string; 292 | queryEndpoint: string; 293 | rulesEndpoint: string; 294 | logger: Logger; 295 | 296 | constructor({ 297 | account, 298 | accessToken, 299 | dev = false, 300 | useRulesEndpoint = false, 301 | apiBaseUrl = undefined, 302 | logger = undefined, 303 | }: JupiterOneClientOptions) { 304 | this.account = account; 305 | this.accessToken = accessToken; 306 | this.useRulesEndpoint = useRulesEndpoint; 307 | 308 | this.apiUrl = dev 309 | ? 'https://api.dev.jupiterone.io' 310 | : 'https://api.us.jupiterone.io'; 311 | this.apiUrl = apiBaseUrl || this.apiUrl; 312 | this.queryEndpoint = this.apiUrl + '/graphql'; 313 | this.rulesEndpoint = this.apiUrl + '/rules/graphql'; 314 | 315 | this.logger = 316 | logger || 317 | createLogger({ 318 | name: 'jupiterone-client-nodejs', 319 | level: 'info', 320 | }); 321 | } 322 | 323 | async init(): Promise { 324 | const token = this.accessToken; 325 | this.headers = { 326 | Authorization: `Bearer ${token}`, 327 | 'JupiterOne-Account': this.account, 328 | 'content-type': 'application/json', 329 | }; 330 | 331 | const uri = this.useRulesEndpoint ? this.rulesEndpoint : this.queryEndpoint; 332 | const link = ApolloLink.from([ 333 | new RetryLink({ 334 | delay: { 335 | initial: 2000, 336 | max: 5000, 337 | jitter: true, 338 | }, 339 | }), 340 | new BatchHttpLink({ uri, headers: this.headers, fetch }), 341 | ]); 342 | const cache = new InMemoryCache(); 343 | this.graphClient = new ApolloClient({ link, cache }); 344 | 345 | return this; 346 | } 347 | 348 | async queryV1( 349 | j1ql: string, 350 | options: QueryOptions | Record = {}, 351 | /** 352 | * include a progress bar to show progress of getting data. 353 | */ 354 | showProgress = false, 355 | /** because this method queries repeatedly with its own LIMIT, 356 | * this limits the looping to stop after at least {stopAfter} results are found 357 | * @deprecated This property is no longer supported. 358 | */ 359 | stopAfter = Number.MAX_SAFE_INTEGER, 360 | /** same as above, this gives more fine-grained control over the starting point of the query, 361 | * since this method controls the `SKIP` clause in the query 362 | * @deprecated This property is no longer supported. 363 | */ 364 | startPage = 0, 365 | ) { 366 | 367 | let cursor: string; 368 | let complete = false; 369 | let results: any[] = []; 370 | 371 | const limitCheck = j1ql.match(/limit (\d+)/i); 372 | 373 | let progress: any; 374 | 375 | do { 376 | this.logger.debug({j1ql}, "Sending query"); 377 | const res = await this.graphClient.query({ 378 | query: QUERY_V1, 379 | variables: { 380 | query: j1ql, 381 | deferredResponse: 'FORCE', 382 | flags: { 383 | variableResultSize: true 384 | }, 385 | cursor 386 | }, 387 | ...options, 388 | }); 389 | if (res.errors) { 390 | throw new Error(`JupiterOne returned error(s) for query: '${j1ql}'`); 391 | } 392 | 393 | this.logger.debug(res.data, "Retrieved response"); 394 | const deferredUrl = res.data.queryV1.url; 395 | let status = JobStatus.IN_PROGRESS; 396 | let statusFile: any; 397 | const startTimeInMs = Date.now(); 398 | do { 399 | if (Date.now() - startTimeInMs > QUERY_RESULTS_TIMEOUT) { 400 | throw new Error( 401 | `Exceeded request timeout of ${QUERY_RESULTS_TIMEOUT / 1000 402 | } seconds.`, 403 | ); 404 | } 405 | this.logger.debug('Sleeping to wait for JobCompletion'); 406 | await sleep(100); 407 | statusFile = await networkRequest(deferredUrl); 408 | status = statusFile.status; 409 | cursor = statusFile.cursor; 410 | } while (status === JobStatus.IN_PROGRESS); 411 | 412 | if (status === JobStatus.FAILED) { 413 | throw new Error(`JupiterOne returned error(s) for query: '${statusFile.error}'`); 414 | } 415 | 416 | this.logger.info("Retrieving query data"); 417 | const result = statusFile.data; 418 | 419 | if (showProgress && !limitCheck) { 420 | if (results.length === 0) { 421 | progress = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); 422 | progress.start(Number(statusFile.totalCount), 0); 423 | } 424 | progress.update(results.length); 425 | } 426 | 427 | if (result) { 428 | results = results.concat(result) 429 | } 430 | 431 | if (status === JobStatus.COMPLETED && (cursor == null || limitCheck)) { 432 | complete = true; 433 | } 434 | 435 | } while (complete === false); 436 | return results; 437 | } 438 | 439 | async queryGraphQL(query: any) { 440 | const res = await this.graphClient.query({ query }); 441 | if (res.errors) { 442 | this.logger.info(res.errors); 443 | throw new Error(`JupiterOne returned error(s) for query: '${query}'`); 444 | } 445 | return res; 446 | } 447 | 448 | async mutateAlertRule(rule: any, update: any) { 449 | const inlineQuestion = !!rule.instance?.question; 450 | let mutation; 451 | if (inlineQuestion) { 452 | mutation = update ? UPDATE_INLINE_ALERT_RULE : CREATE_INLINE_ALERT_RULE; 453 | } else { 454 | mutation = update 455 | ? UPDATE_REFERENCED_ALERT_RULE 456 | : CREATE_REFERENCED_ALERT_RULE; 457 | } 458 | const res = await this.graphClient.mutate({ 459 | mutation, 460 | variables: { 461 | instance: rule.instance, 462 | }, 463 | }); 464 | if (res.errors) { 465 | throw new Error( 466 | `JupiterOne returned error(s) mutating alert rule: '${rule}'`, 467 | ); 468 | } 469 | return update 470 | ? res.data.updateQuestionRuleInstance 471 | : res.data.createQuestionRuleInstance; 472 | } 473 | 474 | async createEntity( 475 | key: string, 476 | type: string, 477 | classLabels: string[], 478 | properties: any, 479 | ): Promise { 480 | const res = await this.graphClient.mutate({ 481 | mutation: CREATE_ENTITY, 482 | variables: { 483 | entityKey: key, 484 | entityType: type, 485 | entityClass: classLabels, 486 | properties, 487 | }, 488 | }); 489 | if (res.errors) { 490 | throw new Error( 491 | `JupiterOne returned error(s) creating entity with key: '${key}'`, 492 | ); 493 | } 494 | return res.data.createEntity; 495 | } 496 | 497 | async updateEntity(entityId: string, properties: any): Promise { 498 | let res; 499 | try { 500 | res = await this.graphClient.mutate({ 501 | mutation: UPDATE_ENTITY, 502 | variables: { 503 | entityId, 504 | properties, 505 | }, 506 | }); 507 | if (res.errors) { 508 | throw new Error( 509 | `JupiterOne returned error(s) updating entity with id: '${entityId}'`, 510 | ); 511 | } 512 | } catch (err) { 513 | this.logger.info( 514 | { err: err.stack || err.toString(), entityId, properties }, 515 | 'error updating entity', 516 | ); 517 | throw err; 518 | } 519 | return res.data.updateEntity; 520 | } 521 | 522 | async deleteEntity(entityId: string, hardDelete?: boolean): Promise { 523 | let res; 524 | try { 525 | res = await this.graphClient.mutate({ 526 | mutation: DELETE_ENTITY, 527 | variables: { entityId, hardDelete }, 528 | }); 529 | if (res.errors) { 530 | throw new Error( 531 | `JupiterOne returned error(s) deleting entity with id: '${entityId}'`, 532 | ); 533 | } 534 | } catch (err) { 535 | this.logger.info({ err, entityId, res }, 'error deleting entity'); 536 | throw err; 537 | } 538 | return res.data.deleteEntity; 539 | } 540 | 541 | async createRelationship( 542 | relationshipKey: string, 543 | relationshipType: string, 544 | relationshipClass: string, 545 | fromEntityId: string, 546 | toEntityId: string, 547 | properties: any, 548 | ): Promise { 549 | const res = await this.graphClient.mutate({ 550 | mutation: CREATE_RELATIONSHIP, 551 | variables: { 552 | relationshipKey, 553 | relationshipType, 554 | relationshipClass, 555 | fromEntityId, 556 | toEntityId, 557 | properties, 558 | }, 559 | }); 560 | if (res.errors) { 561 | throw new Error( 562 | `JupiterOne returned error(s) creating relationship with key: '${relationshipKey}'`, 563 | ); 564 | } 565 | return res.data.createRelationship; 566 | } 567 | 568 | async upsertEntityRawData( 569 | entityId: string, 570 | name: string, 571 | contentType: string, 572 | data: any, 573 | ): Promise { 574 | const operation = { 575 | mutation: UPSERT_ENTITY_RAW_DATA, 576 | variables: { 577 | source: 'api', 578 | entityId, 579 | rawData: [ 580 | { 581 | name, 582 | contentType, 583 | data, 584 | }, 585 | ], 586 | }, 587 | }; 588 | let res; 589 | try { 590 | res = await this.graphClient.mutate(operation); 591 | if (res.errors) { 592 | throw new Error( 593 | `JupiterOne returned error(s) upserting rawData for entity with id: '${entityId}'`, 594 | ); 595 | } 596 | } catch (exception) { 597 | throw new Error( 598 | `Unable to store raw template data for ${name}: ` + exception.message, 599 | ); 600 | } 601 | return res.data.upsertEntityRawData.status; 602 | } 603 | 604 | async createQuestion(question: any) { 605 | const res = await this.graphClient.mutate({ 606 | mutation: CREATE_QUESTION, 607 | variables: { question }, 608 | }); 609 | if (res.errors) { 610 | throw new Error( 611 | `JupiterOne returned error(s) creating question: '${question}'`, 612 | ); 613 | } 614 | return res.data.createQuestion; 615 | } 616 | 617 | async updateQuestion(question: any) { 618 | const { id, ...update } = question; 619 | const res = await this.graphClient.mutate({ 620 | mutation: UPDATE_QUESTION, 621 | variables: { 622 | id, 623 | update, 624 | }, 625 | }); 626 | if (res.errors) { 627 | throw new Error( 628 | `JupiterOne returned error(s) updating question: '${question}'`, 629 | ); 630 | } 631 | return res.data.updateQuestion; 632 | } 633 | 634 | async deleteQuestion(questionId: string) { 635 | const res = await this.graphClient.mutate({ 636 | mutation: DELETE_QUESTION, 637 | variables: { id: questionId }, 638 | }); 639 | if (res.errors) { 640 | throw new Error( 641 | `JupiterOne returned error(s) updating question with ID: '${questionId}'`, 642 | ); 643 | } 644 | return res.data.deleteQuestion; 645 | } 646 | 647 | integrationDefinitions = { 648 | list: async (): Promise> => { 649 | const fn: QueryTypes.QueryFunction = ( 650 | optionsOverride, 651 | ) => 652 | this.graphClient.query({ 653 | errorPolicy: 'all', 654 | query: LIST_INTEGRATION_DEFINITIONS, 655 | variables: { ...optionsOverride }, 656 | }); 657 | 658 | return query(fn, { 659 | dataPath: 'data.integrationDefinitions.definitions', 660 | cursorPath: 'data.integrationDefinitions.pageInfo', 661 | }); 662 | }, 663 | }; 664 | 665 | integrationInstances = { 666 | list: async ( 667 | options?: ListIntegrationInstancesOptions, 668 | ): Promise> => { 669 | const fn: QueryTypes.QueryFunction = ( 670 | optionsOverride, 671 | ) => 672 | this.graphClient.query({ 673 | errorPolicy: 'all', 674 | query: LIST_INTEGRATION_INSTANCES, 675 | variables: { ...(options ?? {}), ...optionsOverride }, 676 | }); 677 | 678 | return query>(fn, { 679 | dataPath: 'data.integrationInstances.instances', 680 | cursorPath: 'data.integrationInstances.pageInfo', 681 | }); 682 | }, 683 | 684 | get: async (id: string) => { 685 | const res = await this.graphClient.query<{ 686 | integrationInstance: IntegrationInstance; 687 | }>({ 688 | query: gql` 689 | query GetIntegrationInstance($id: String!) { 690 | integrationInstance(id: $id) { 691 | accountId 692 | config 693 | description 694 | id 695 | integrationDefinitionId 696 | name 697 | pollingInterval 698 | pollingIntervalCronExpression { 699 | hour 700 | dayOfWeek 701 | } 702 | } 703 | } 704 | `, 705 | variables: { 706 | id, 707 | }, 708 | }); 709 | 710 | if (res.errors) { 711 | throw new ApolloError({ graphQLErrors: res.errors }); 712 | } 713 | 714 | return res.data.integrationInstance; 715 | }, 716 | 717 | create: async ( 718 | instance: Omit, 'id' | 'accountId'>, 719 | ) => { 720 | const res = await this.graphClient.mutate<{ 721 | createIntegrationInstance: IntegrationInstance; 722 | }>({ 723 | mutation: gql` 724 | mutation CreateIntegrationInstance( 725 | $config: JSON 726 | $description: String 727 | $integrationDefinitionId: String! 728 | $name: String! 729 | $pollingInterval: IntegrationPollingInterval 730 | $pollingIntervalCronExpression: IntegrationPollingIntervalCronExpressionInput 731 | ) { 732 | createIntegrationInstance( 733 | instance: { 734 | config: $config 735 | description: $description 736 | integrationDefinitionId: $integrationDefinitionId 737 | name: $name 738 | pollingInterval: $pollingInterval 739 | pollingIntervalCronExpression: $pollingIntervalCronExpression 740 | } 741 | ) { 742 | accountId 743 | config 744 | description 745 | id 746 | integrationDefinitionId 747 | name 748 | pollingInterval 749 | pollingIntervalCronExpression { 750 | hour 751 | dayOfWeek 752 | } 753 | } 754 | } 755 | `, 756 | variables: { 757 | config: instance.config, 758 | description: instance.description, 759 | integrationDefinitionId: instance.integrationDefinitionId, 760 | name: instance.name, 761 | pollingInterval: instance.pollingInterval, 762 | pollingIntervalCronExpression: instance.pollingIntervalCronExpression, 763 | }, 764 | }); 765 | 766 | if (res.errors) { 767 | throw new ApolloError({ graphQLErrors: res.errors }); 768 | } 769 | 770 | return res.data.createIntegrationInstance; 771 | }, 772 | 773 | update: async ( 774 | id: string, 775 | update: Partial< 776 | Omit< 777 | IntegrationInstance, 778 | 'id' | 'accountId' | 'integrationDefinitionId' 779 | > 780 | >, 781 | ) => { 782 | const res = await this.graphClient.mutate<{ 783 | updateIntegrationInstance: IntegrationInstance; 784 | }>({ 785 | mutation: gql` 786 | query UpdateIntegrationInstance( 787 | $id: String! 788 | $config: JSON 789 | $description: String 790 | $name: String 791 | $pollingInterval: IntegrationPollingInterval 792 | $pollingIntervalCronExpression: IntegrationPollingIntervalCronExpression 793 | ) { 794 | updateIntegrationInstance( 795 | id: $id 796 | update: { 797 | config: $config 798 | description: $description 799 | name: $name 800 | pollingInterval: $pollingInterval 801 | pollingIntervalCronExpression: $pollingIntervalCronExpression 802 | } 803 | ) { 804 | accountId 805 | config 806 | description 807 | id 808 | integrationDefinitionId 809 | name 810 | pollingInterval 811 | pollingIntervalCronExpression { 812 | hour 813 | dayOfWeek 814 | } 815 | } 816 | } 817 | `, 818 | variables: { 819 | id, 820 | config: update.config, 821 | description: update.description, 822 | name: update.name, 823 | pollingInterval: update.pollingInterval, 824 | pollingIntervalCronExpression: update.pollingIntervalCronExpression, 825 | }, 826 | }); 827 | 828 | if (res.errors) { 829 | throw new ApolloError({ graphQLErrors: res.errors }); 830 | } 831 | 832 | return res.data.updateIntegrationInstance; 833 | }, 834 | 835 | delete: async (id: string) => { 836 | const res = await this.graphClient.mutate<{ 837 | deleteIntegrationInstance: { success?: boolean }; 838 | }>({ 839 | mutation: gql` 840 | query DeleteIntegrationInstance($id: String!) { 841 | deleteIntegrationInstance(id: $id) { 842 | success 843 | } 844 | } 845 | `, 846 | variables: { 847 | id, 848 | }, 849 | }); 850 | 851 | if (res.errors) { 852 | throw new ApolloError({ graphQLErrors: res.errors }); 853 | } 854 | 855 | return res.data.deleteIntegrationInstance; 856 | }, 857 | }; 858 | 859 | async startSyncJob(options: SyncJobOptions): Promise { 860 | const headers = this.headers; 861 | const response = await makeFetchRequest( 862 | this.apiUrl + `/persister/synchronization/jobs`, 863 | { 864 | method: 'POST', 865 | headers, 866 | body: JSON.stringify(options), 867 | }, 868 | ); 869 | return validateSyncJobResponse(response); 870 | } 871 | 872 | async uploadGraphObjectsForDeleteSyncJob(options: { 873 | syncJobId: string; 874 | entities?: Entity[]; 875 | relationships?: Relationship[]; 876 | }): Promise { 877 | const upload: GraphObjectDeletionPayload = { 878 | deleteEntities: [], 879 | deleteRelationships: [], 880 | }; 881 | for (const e of options.entities || []) { 882 | upload.deleteEntities.push({ _id: e?.['_id'] }); 883 | } 884 | 885 | for (const r of options.relationships || []) { 886 | upload.deleteRelationships.push({ _id: r?.['_id'] }); 887 | } 888 | 889 | this.logger.trace(upload, 'Full upload of deletion sync job'); 890 | this.logger.info('uploading deletion sync job'); 891 | const headers = this.headers; 892 | const response = await makeFetchRequest( 893 | this.apiUrl + 894 | `/persister/synchronization/jobs/${options.syncJobId}/upload`, 895 | { 896 | method: 'POST', 897 | headers, 898 | body: JSON.stringify(upload), 899 | }, 900 | ); 901 | return validateSyncJobResponse(response); 902 | } 903 | 904 | async uploadGraphObjectsForSyncJob(options: { 905 | syncJobId: string; 906 | entities?: EntityForSync[]; 907 | relationships?: RelationshipForSync[]; 908 | }): Promise { 909 | const { syncJobId, entities, relationships } = options; 910 | const headers = this.headers; 911 | const response = await makeFetchRequest( 912 | this.apiUrl + `/persister/synchronization/jobs/${syncJobId}/upload`, 913 | { 914 | method: 'POST', 915 | headers, 916 | body: JSON.stringify({ 917 | entities, 918 | relationships, 919 | }), 920 | }, 921 | ); 922 | return validateSyncJobResponse(response); 923 | } 924 | 925 | async finalizeSyncJob(options: { 926 | syncJobId: string; 927 | }): Promise { 928 | const { syncJobId } = options; 929 | const headers = this.headers; 930 | const response = await makeFetchRequest( 931 | this.apiUrl + `/persister/synchronization/jobs/${syncJobId}/finalize`, 932 | { 933 | method: 'POST', 934 | headers, 935 | body: JSON.stringify({}), 936 | }, 937 | ); 938 | return validateSyncJobResponse(response); 939 | } 940 | 941 | async abortSyncJob(options: { 942 | syncJobId: string; 943 | reason: string; 944 | }): Promise { 945 | const { syncJobId, reason } = options; 946 | const headers = this.headers; 947 | const response = await makeFetchRequest( 948 | this.apiUrl + `/persister/synchronization/jobs/${syncJobId}/abort`, 949 | { 950 | method: 'POST', 951 | headers, 952 | body: JSON.stringify({ reason }), 953 | }, 954 | ); 955 | return validateSyncJobResponse(response); 956 | } 957 | 958 | async publishEvents(options: { 959 | syncJobId: string; 960 | events: Array<{ name: string; description: string }>; 961 | }): Promise { 962 | const { syncJobId, events } = options; 963 | const headers = this.headers; 964 | const response = await makeFetchRequest( 965 | this.apiUrl + `/persister/synchronization/jobs/${syncJobId}/events`, 966 | { 967 | method: 'POST', 968 | headers, 969 | body: JSON.stringify({ events }), 970 | }, 971 | ); 972 | return response.json(); 973 | } 974 | 975 | async fetchSyncJobStatus(options: { 976 | syncJobId: string; 977 | }): Promise { 978 | const { syncJobId } = options; 979 | const headers = this.headers; 980 | const response = await makeFetchRequest( 981 | this.apiUrl + `/persister/synchronization/jobs/${syncJobId}`, 982 | { 983 | method: 'GET', 984 | headers, 985 | }, 986 | ); 987 | return validateSyncJobResponse(response); 988 | } 989 | 990 | private areValidSyncJobOptions(options: SyncJobOptions): boolean { 991 | if ( 992 | options.source === SyncJobSources.API && 993 | options.syncMode === SyncJobModes.DIFF && 994 | !options.scope 995 | ) { 996 | this.logger.error( 997 | 'You must specify a scope when starting a sync job in DIFF mode.', 998 | ); 999 | return false; 1000 | } 1001 | 1002 | return true; 1003 | } 1004 | 1005 | async bulkUpload(data: { 1006 | syncJobOptions: SyncJobOptions; 1007 | entities?: EntityForSync[]; 1008 | relationships?: RelationshipForSync[]; 1009 | }): Promise { 1010 | if (!data?.entities && !data?.relationships) { 1011 | this.logger.info('No entities or relationships to upload.'); 1012 | return; 1013 | } 1014 | 1015 | const defaultOptions = { 1016 | source: SyncJobSources.API, 1017 | syncMode: SyncJobModes.DIFF, 1018 | }; 1019 | 1020 | const options = { ...defaultOptions, ...(data?.syncJobOptions ?? {}) }; 1021 | 1022 | if (this.areValidSyncJobOptions(options) === false) return; 1023 | 1024 | const { job: syncJob } = await this.startSyncJob(options); 1025 | const syncJobId = syncJob.id; 1026 | await this.uploadGraphObjectsForSyncJob({ 1027 | syncJobId, 1028 | entities: data.entities, 1029 | relationships: data.relationships, 1030 | }); 1031 | const finalizeResult = await this.finalizeSyncJob({ syncJobId }); 1032 | return { 1033 | syncJobId, 1034 | finalizeResult, 1035 | }; 1036 | } 1037 | 1038 | async bulkDelete(data: { 1039 | entities?: Entity[]; 1040 | relationships?: Relationship[]; 1041 | }): Promise { 1042 | if (data.entities || data.relationships) { 1043 | const { job: syncJob } = await this.startSyncJob({ 1044 | source: SyncJobSources.API, 1045 | syncMode: SyncJobModes.CREATE_OR_UPDATE, 1046 | }); 1047 | const syncJobId = syncJob.id; 1048 | await this.uploadGraphObjectsForDeleteSyncJob({ 1049 | syncJobId, 1050 | entities: data.entities, 1051 | relationships: data.relationships, 1052 | }); 1053 | const finalizeResult = await this.finalizeSyncJob({ syncJobId }); 1054 | return { 1055 | syncJobId, 1056 | finalizeResult, 1057 | }; 1058 | } else { 1059 | this.logger.info('No entities or relationships to upload.'); 1060 | } 1061 | } 1062 | } 1063 | -------------------------------------------------------------------------------- /src/j1cli.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import util from 'util'; 4 | 5 | import yaml from 'js-yaml'; 6 | import pAll from 'p-all'; 7 | import inquirer from 'inquirer'; 8 | 9 | import { defaultAlertSettings } from '@jupiterone/jupiterone-alert-rules'; 10 | 11 | import { JupiterOneClient } from '.'; 12 | import * as error from './util/error'; 13 | 14 | import { program } from 'commander'; 15 | 16 | const writeFile = util.promisify(fs.writeFile); 17 | 18 | const J1_API_TOKEN = process.env.J1_API_TOKEN; 19 | const J1_DEV_ENABLED = process.env.J1_DEV_ENABLED; 20 | const EUSAGEERROR = 126; 21 | const CONCURRENCY = 2; 22 | 23 | const SUPPORTED_OPERATIONS = [ 24 | 'create', 25 | 'update', 26 | 'upsert', // only works on entities 27 | 'delete', 28 | 'bulk-delete', 29 | 'provision-alert-rule-pack', 30 | ]; 31 | 32 | async function main() { 33 | program 34 | .version(require('../package').version, '-v, --version') 35 | .usage('[options]') 36 | .option('-a, --account ', 'JupiterOne account name.') 37 | .option('-u, --user ', 'JupiterOne user email.') 38 | .option('-k, --key ', 'JupiterOne API access token.') 39 | .option('-q, --query ', 'Execute a query.') 40 | .option( 41 | '-o, --operation ', 42 | `Supported operations: ${SUPPORTED_OPERATIONS}`, 43 | ) 44 | .option('-e, --entity', 'Specifies entity operations.') 45 | .option('-r, --relationship', 'Specifies relationship operations.') 46 | .option('-l, --alert', 'Specifies alert rule operations.') 47 | .option( 48 | '-w, --question', 49 | 'Specifies question operations. A question is answered by one or more queries.', 50 | ) 51 | .option( 52 | '-f, --input-file ', 53 | 'Input JSON file. Or the filename of the alert rule pack.', 54 | ) 55 | .option( 56 | '--hard-delete', 57 | 'Optionally force hard deletion of entities (default is soft delete).', 58 | ) 59 | .option( 60 | '--delete-duplicates', 61 | 'Optionally force deletion of duplicate entities with identical keys.', 62 | ) 63 | .option( 64 | '--output-file ', 65 | 'Writes query result to specified output file, or results.json by default', 66 | 'results.json', 67 | ) 68 | .option( 69 | '--api-base-url ', 70 | 'Optionally specify base URL to use during execution. (defaults to `https://api.us.jupiterone.io`)', 71 | ) 72 | .parse(process.argv); 73 | 74 | try { 75 | const data = await validateInputs(); 76 | const j1Client = await initializeJ1Client(); 77 | if (program.query) { 78 | const res = await j1Client.queryV1(program.query); 79 | const result = JSON.stringify(res, null, 2); 80 | console.log(result); 81 | if (program.outputFile) { 82 | await writeFile(program.outputFile, result); 83 | } 84 | } else { 85 | const update = program.operation === 'update'; 86 | if (program.entity) { 87 | if (program.operation === 'bulk-delete') { 88 | await bulkDelete({ 89 | client: j1Client, 90 | entities: data, 91 | }); 92 | } else { 93 | await mutateEntities( 94 | j1Client, 95 | Array.isArray(data) ? data : [data], 96 | program.operation, 97 | ); 98 | } 99 | } else if (program.relationship) { 100 | if (program.operation === 'bulk-delete') { 101 | await bulkDelete({ 102 | client: j1Client, 103 | relationships: data, 104 | }); 105 | } else { 106 | await mutateRelationships( 107 | j1Client, 108 | Array.isArray(data) ? data : [data], 109 | update, 110 | ); 111 | } 112 | } else if (program.alert) { 113 | if (program.operation === 'provision-alert-rule-pack') { 114 | await provisionRulePackAlerts(j1Client, data, defaultAlertSettings); 115 | } else { 116 | await mutateAlertRules( 117 | j1Client, 118 | Array.isArray(data) ? data : [data], 119 | update, 120 | ); 121 | } 122 | } else if (program.question) { 123 | await mutateQuestions( 124 | j1Client, 125 | Array.isArray(data) ? data : [data], 126 | program.operation, 127 | ); 128 | } 129 | } 130 | } catch (err) { 131 | error.fatal(`Unexpected error: ${err.stack || err.toString()}`); 132 | } 133 | console.log('Done!'); 134 | } 135 | 136 | // ensure user supplied necessary params 137 | async function validateInputs() { 138 | process.stdout.write('Validating inputs... '); 139 | let data; 140 | if (!program.account || program.account === '') { 141 | error.fatal('Missing -a|--account flag!', EUSAGEERROR); 142 | } 143 | if (!program.query || program.query === '') { 144 | if (!program.operation || program.operation === '') { 145 | error.fatal( 146 | 'Must specify a query (using -q|--query) or operation action (-o|--operation)', 147 | EUSAGEERROR, 148 | ); 149 | } else if (SUPPORTED_OPERATIONS.indexOf(program.operation) < 0) { 150 | error.fatal(`Unsupported operation: ${program.operation}`); 151 | } else if ( 152 | !program.entity && 153 | !program.relationship && 154 | !program.alert && 155 | !program.question 156 | ) { 157 | error.fatal( 158 | 'Must specify an operation target type (--entity, --relationship, --alert, or --question)', 159 | EUSAGEERROR, 160 | ); 161 | } else if (!program.inputFile || program.inputFile === '') { 162 | error.fatal('Must specify input JSON file with -f|--file)', EUSAGEERROR); 163 | } else { 164 | let filePath = program.inputFile; 165 | if (!fs.existsSync(filePath)) { 166 | if (program.operation === 'provision-alert-rule-pack') { 167 | filePath = `node_modules/@jupiterone/jupiterone-alert-rules/rule-packs/${program.inputFile}.json`; 168 | if (!fs.existsSync(filePath)) { 169 | error.fatal( 170 | `Could not find input JSON file (${filePath}). Specify the correct file path or alert-rule-pack name with '-f|--file'.`, 171 | ); 172 | } 173 | } else { 174 | error.fatal( 175 | `Could not find input JSON file (${filePath}). Specify the correct file path or alert-rule-pack name with '-f|--file'.`, 176 | ); 177 | } 178 | } 179 | 180 | data = jParse(filePath); 181 | if (!data) { 182 | error.fatal(`Could not parse input JSON file (${filePath}).`); 183 | } 184 | } 185 | } 186 | 187 | if ((!program.key || program.key === '') && !J1_API_TOKEN) { 188 | if (!program.user || program.user === '') { 189 | error.fatal( 190 | 'Must authenticate with either the API key (using -k|--key) or username/password (using -u|--user)', 191 | EUSAGEERROR, 192 | ); 193 | } else { 194 | await gatherPassword(); 195 | } 196 | } 197 | 198 | console.log('OK'); 199 | return data; 200 | } 201 | 202 | function jParse(filePath) { 203 | try { 204 | const data = fs.readFileSync(filePath, 'utf8'); 205 | const fileExtension = path.extname(filePath); 206 | if (fileExtension === '.yml' || fileExtension === '.yaml') { 207 | return yaml.safeLoad(data); 208 | } else { 209 | return JSON.parse(data); 210 | } 211 | } catch (err) { 212 | console.warn(err); 213 | return null; 214 | } 215 | } 216 | 217 | // Note: this will happily read from STDIN if data is piped in... 218 | // e.g. if lastpass is installed: 219 | // lpass show MyJ1Password | psp publish -u my.user@domain.tld -a myaccount 220 | async function gatherPassword() { 221 | const answer = await inquirer.prompt([ 222 | { 223 | type: 'password', 224 | name: 'password', 225 | message: 'JupiterOne password:', 226 | }, 227 | ]); 228 | program.password = answer.password; 229 | } 230 | 231 | async function initializeJ1Client() { 232 | process.stdout.write('Authenticating with JupiterOne... '); 233 | const j1Client = await new JupiterOneClient({ 234 | account: program.account, 235 | accessToken: program.key || J1_API_TOKEN, 236 | dev: J1_DEV_ENABLED === 'true', 237 | apiBaseUrl: program.apiBaseUrl, 238 | }).init(); 239 | console.log('OK'); 240 | return j1Client; 241 | } 242 | 243 | async function createEntity(j1Client, e) { 244 | const classLabels = Array.isArray(e.entityClass) 245 | ? e.entityClass 246 | : [e.entityClass]; 247 | 248 | if (e.properties) { 249 | e.properties.createdOn = 250 | e.properties.createdOn && new Date(e.properties.createdOn).getTime(); 251 | } 252 | 253 | const res = await j1Client.createEntity( 254 | e.entityKey, 255 | e.entityType, 256 | classLabels, 257 | e.properties, 258 | ); 259 | return res.vertex.entity._id; 260 | } 261 | 262 | async function updateEntity(j1Client, entityId, properties) { 263 | if (properties) { 264 | properties.updatedOn = 265 | properties.updatedOn && new Date(properties.updatedOn).getTime(); 266 | 267 | await j1Client.updateEntity(entityId, properties); 268 | } else { 269 | console.log( 270 | `Skipping entity update with _id='${entityId}' - No properties provided.`, 271 | ); 272 | } 273 | return entityId; 274 | } 275 | 276 | async function deleteEntity(j1Client, entityId, hardDelete) { 277 | await j1Client.deleteEntity(entityId, hardDelete); 278 | return entityId; 279 | } 280 | 281 | async function bulkDelete(options) { 282 | return options.client.bulkDelete({ 283 | entities: options.entities, 284 | relationships: options.relationships, 285 | }); 286 | } 287 | 288 | async function mutateEntities(j1Client, entities, operation) { 289 | const work = []; 290 | if (operation === 'create') { 291 | for (const e of entities) { 292 | work.push(() => { 293 | return createEntity(j1Client, e); 294 | }); 295 | } 296 | } else { 297 | for (const e of entities) { 298 | let entityId; 299 | let entityIds; 300 | 301 | if (e.entityId) { 302 | entityId = e.entityId; 303 | } else if (e.entityKey) { 304 | const query = `Find * with _key='${e.entityKey}'`; 305 | const res = await j1Client.queryV1(query); 306 | if (res.length === 1) { 307 | entityId = res[0].entity._id; 308 | } else if (res.length === 0 && operation != 'upsert') { 309 | console.log(`Skipping entity with _key='${e.entityKey}' - NOT FOUND`); 310 | continue; 311 | } else if (res.length > 0) { 312 | if (operation !== 'delete' && !program.deleteDuplicates) { 313 | console.log( 314 | `Skipping entity with _key='${e.entityKey}' - KEY NOT UNIQUE`, 315 | ); 316 | continue; 317 | } 318 | entityIds = res.map((r) => r.entity._id); 319 | } 320 | } 321 | 322 | if (entityId) { 323 | if (operation === 'update' || operation === 'upsert') { 324 | work.push(() => { 325 | return updateEntity(j1Client, entityId, e.properties); 326 | }); 327 | } else if (operation === 'delete') { 328 | work.push(() => { 329 | return deleteEntity(j1Client, entityId, program.hardDelete); 330 | }); 331 | } 332 | } else if (entityIds) { 333 | // deletes duplicate entities with identical key 334 | if (operation === 'delete' && program.deleteDuplicates) { 335 | for (const id of entityIds) { 336 | work.push(() => { 337 | return deleteEntity(j1Client, id, program.hardDelete); 338 | }); 339 | } 340 | } 341 | } else if (operation === 'upsert') { 342 | work.push(() => { 343 | return createEntity(j1Client, e); 344 | }); 345 | } else { 346 | console.log( 347 | `Skipping entity: '${JSON.stringify( 348 | e, 349 | )}' - undefined entityId or entityKey`, 350 | ); 351 | } 352 | } 353 | } 354 | const entityIds = await pAll(work, { 355 | concurrency: CONCURRENCY, 356 | }); 357 | console.log( 358 | `${operation}d ${entityIds.length} entities:\n${JSON.stringify( 359 | entityIds, 360 | null, 361 | 2, 362 | )}`, 363 | ); 364 | } 365 | 366 | async function createRelationship(j1Client, r) { 367 | const res = await j1Client.createRelationship( 368 | r.relationshipKey, 369 | r.relationshipType, 370 | r.relationshipClass, 371 | r.fromEntityId, 372 | r.toEntityId, 373 | r.properties, 374 | ); 375 | return res.edge.relationship._id; 376 | } 377 | 378 | async function mutateRelationships(j1Client, relationships, update) { 379 | const work = []; 380 | if (update) { 381 | console.log( 382 | 'Updating relationships is not currently supported via the CLI.', 383 | ); 384 | } else { 385 | for (const r of relationships) { 386 | work.push(() => { 387 | return createRelationship(j1Client, r); 388 | }); 389 | } 390 | } 391 | const relationshipIds = await pAll(work, { 392 | concurrency: CONCURRENCY, 393 | }); 394 | console.log( 395 | `Created ${relationshipIds.length} relationships:\n${JSON.stringify( 396 | relationshipIds, 397 | null, 398 | 2, 399 | )}`, 400 | ); 401 | } 402 | 403 | async function mutateAlertRules(j1Client, rules, update) { 404 | const created = []; 405 | const updated = []; 406 | const skipped = []; 407 | const results = []; 408 | for (const r of rules) { 409 | try { 410 | if (update) { 411 | // Check if the alert rule instance has an id, which is required for update 412 | if (r.instance && r.instance.id && r.instance.id !== '') { 413 | const res = await j1Client.mutateAlertRule(r, update); 414 | results.push(res); 415 | updated.push({ id: res.id, name: r.instance.name }); 416 | } else { 417 | console.log( 418 | `Skipped updating the following alert rule instance because it has no 'id' property:\n ${JSON.stringify( 419 | r, 420 | null, 421 | 2, 422 | )}`, 423 | ); 424 | skipped.push({ id: r.instance.id, name: r.instance.name }); 425 | } 426 | } else { 427 | // If it is a 'create' operation, skip existing alert rule instance to avoid duplicate 428 | if (r.instance && r.instance.id && r.instance.id !== '') { 429 | console.log( 430 | `Skipped creating the following alert rule instance because it already exists:\n ${JSON.stringify( 431 | r, 432 | null, 433 | 2, 434 | )}`, 435 | ); 436 | skipped.push({ id: r.instance.id, name: r.instance.name }); 437 | } else { 438 | const res = await j1Client.mutateAlertRule(r, update); 439 | results.push(res); 440 | created.push({ id: res.id, name: r.instance.name }); 441 | } 442 | } 443 | } catch (err) { 444 | console.warn(`Error mutating alert rule ${r}.\n${err}\n Skipped.`); 445 | skipped.push({ id: r.instance.id, name: r.instance.name }); 446 | } 447 | } 448 | 449 | if (created.length > 0) { 450 | console.log( 451 | `Created ${created.length} alert rules:\n${JSON.stringify( 452 | created, 453 | null, 454 | 2, 455 | )}.`, 456 | ); 457 | } 458 | if (updated.length > 0) { 459 | console.log( 460 | `Updated ${updated.length} alert rules:\n${JSON.stringify( 461 | updated, 462 | null, 463 | 2, 464 | )}.`, 465 | ); 466 | } 467 | if (skipped.length > 0) { 468 | console.log( 469 | `Skipped ${skipped.length} alert rules:\n${JSON.stringify( 470 | skipped, 471 | null, 472 | 2, 473 | )}.`, 474 | ); 475 | } 476 | } 477 | 478 | async function mutateQuestions(j1Client, questions, operation) { 479 | const created = []; 480 | const updated = []; 481 | const deleted = []; 482 | const skipped = []; 483 | const results = []; 484 | let newFile = false; 485 | for (const q of questions) { 486 | try { 487 | if (operation === 'create') { 488 | // Update if there is an ID 489 | if (q.id) { 490 | const res = await j1Client.updateQuestion(q); 491 | results.push(res); 492 | updated.push({ id: q.id, title: q.title }); 493 | } else { 494 | const res = await j1Client.createQuestion(q); 495 | results.push(res); 496 | created.push({ id: res.id, title: q.title }); 497 | q.id = res.id; 498 | } 499 | } else if (operation === 'update') { 500 | if (q.id && q.id.length > 0) { 501 | const res = await j1Client.updateQuestion(q); 502 | results.push(res); 503 | updated.push({ id: q.id, title: q.title }); 504 | } else { 505 | // Skip if there is no ID 506 | skipped.push({ id: q.id, title: q.title }); 507 | } 508 | } else if (operation === 'delete') { 509 | if (q.id && q.id.length > 0) { 510 | const res = await j1Client.deleteQuestion(q.id); 511 | results.push(res); 512 | deleted.push({ id: q.id, title: q.title }); 513 | } else { 514 | // Skip if there is no ID 515 | skipped.push({ id: q.id, title: q.title }); 516 | } 517 | } 518 | } catch (err) { 519 | console.warn(`Error mutating question ${q}.\n${err}\n Skipped.`); 520 | skipped.push({ id: q.id, title: q.title }); 521 | } 522 | } 523 | 524 | if (created.length > 0) { 525 | console.log( 526 | `Created ${created.length} questions:\n${JSON.stringify( 527 | created, 528 | null, 529 | 2, 530 | )}.`, 531 | ); 532 | newFile = true; 533 | } 534 | if (updated.length > 0) { 535 | console.log( 536 | `Updated ${updated.length} questions:\n${JSON.stringify( 537 | updated, 538 | null, 539 | 2, 540 | )}.`, 541 | ); 542 | } 543 | if (deleted.length > 0) { 544 | console.log( 545 | `Deleted ${deleted.length} questions:\n${JSON.stringify( 546 | deleted, 547 | null, 548 | 2, 549 | )}.`, 550 | ); 551 | } 552 | if (skipped.length > 0) { 553 | console.log( 554 | `Skipped ${skipped.length} questions:\n${JSON.stringify( 555 | skipped, 556 | null, 557 | 2, 558 | )}.`, 559 | ); 560 | } 561 | if (newFile) { 562 | const jsonString = JSON.stringify(questions, null, 2); 563 | 564 | await writeFile('modified_questions.json', jsonString); 565 | console.log( 566 | 'A modified version of your JSON ("modified_questions.json") with your new IDs has been added to your current directory.', 567 | ); 568 | } 569 | } 570 | 571 | async function provisionRulePackAlerts(j1Client, rules, defaultSettings) { 572 | const work = []; 573 | for (const r of rules) { 574 | if (r.instance) { 575 | const update = r.instance.id !== undefined; 576 | work.push(() => { 577 | return j1Client.mutateAlertRule(r, update); 578 | }); 579 | } else { 580 | const instance = { 581 | ...defaultSettings, 582 | name: r.name, 583 | description: r.description, 584 | question: { 585 | queries: r.queries, 586 | }, 587 | operations: r.alertLevel 588 | ? [ 589 | { 590 | when: defaultSettings.operations[0].when, 591 | actions: [ 592 | { 593 | type: 'SET_PROPERTY', 594 | targetProperty: 'alertLevel', 595 | targetValue: r.alertLevel, 596 | }, 597 | { 598 | type: 'CREATE_ALERT', 599 | }, 600 | ], 601 | }, 602 | ] 603 | : defaultSettings.operations, 604 | }; 605 | work.push(() => { 606 | return j1Client.mutateAlertRule({ instance }, false); 607 | }); 608 | } 609 | } 610 | const res = await pAll(work, { 611 | concurrency: CONCURRENCY, 612 | }); 613 | process.stdout.write( 614 | `Provisioned ${res.length} rules:\n${JSON.stringify(res, null, 2)}\n`, 615 | ); 616 | } 617 | 618 | main(); 619 | -------------------------------------------------------------------------------- /src/networkRequest.ts: -------------------------------------------------------------------------------- 1 | // Temporary helper file because it's difficult to mock in the current architecture 2 | 3 | import fetch from 'node-fetch'; 4 | import { retry } from "@lifeomic/attempt"; 5 | 6 | export const networkRequest = async ( 7 | url: string, 8 | ): Promise> => { 9 | const result = await retry(async () => { 10 | const result = await fetch(url); 11 | const { status } = result; 12 | 13 | if (status < 200 || status >= 300) { 14 | const body = await result.text(); 15 | throw new Error(`HTTP request failed (${status}): ${body}`); 16 | } 17 | 18 | return result; 19 | }); 20 | 21 | const contentType = result.headers.get('content-type'); 22 | if (contentType?.includes('application/json') === false) { 23 | const body = await result.text(); 24 | throw new Error(`HTTP response is not JSON but ${contentType}: ${body}`); 25 | } 26 | 27 | return result.json(); 28 | }; 29 | -------------------------------------------------------------------------------- /src/queries.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const CREATE_ENTITY = gql` 4 | mutation CreateEntity( 5 | $entityKey: String! 6 | $entityType: String! 7 | $entityClass: [String!]! 8 | $properties: JSON 9 | ) { 10 | createEntity( 11 | entityKey: $entityKey 12 | entityType: $entityType 13 | entityClass: $entityClass 14 | properties: $properties 15 | ) { 16 | entity { 17 | _id 18 | } 19 | vertex { 20 | id 21 | entity { 22 | _id 23 | } 24 | } 25 | } 26 | } 27 | `; 28 | 29 | export const UPDATE_ENTITY = gql` 30 | mutation UpdateEntity($entityId: String!, $properties: JSON) { 31 | updateEntity(entityId: $entityId, properties: $properties) { 32 | entity { 33 | _id 34 | } 35 | vertex { 36 | id 37 | } 38 | } 39 | } 40 | `; 41 | 42 | export const DELETE_ENTITY = gql` 43 | mutation DeleteEntity( 44 | $entityId: String! 45 | $timestamp: Long 46 | $hardDelete: Boolean 47 | ) { 48 | deleteEntityV2( 49 | entityId: $entityId 50 | timestamp: $timestamp 51 | hardDelete: $hardDelete 52 | ) { 53 | entity 54 | } 55 | } 56 | `; 57 | 58 | export const CREATE_RELATIONSHIP = gql` 59 | mutation CreateRelationship( 60 | $relationshipKey: String! 61 | $relationshipType: String! 62 | $relationshipClass: String! 63 | $fromEntityId: String! 64 | $toEntityId: String! 65 | $properties: JSON 66 | ) { 67 | createRelationship( 68 | relationshipKey: $relationshipKey 69 | relationshipType: $relationshipType 70 | relationshipClass: $relationshipClass 71 | fromEntityId: $fromEntityId 72 | toEntityId: $toEntityId 73 | properties: $properties 74 | ) { 75 | relationship { 76 | _id 77 | } 78 | edge { 79 | id 80 | toVertexId 81 | fromVertexId 82 | relationship { 83 | _id 84 | } 85 | properties 86 | } 87 | } 88 | } 89 | `; 90 | 91 | export const UPSERT_ENTITY_RAW_DATA = gql` 92 | mutation UpsertEntityRawData( 93 | $entityId: String! 94 | $source: String! 95 | $rawData: [JSON!]! 96 | ) { 97 | upsertEntityRawData( 98 | entityId: $entityId 99 | source: $source 100 | rawData: $rawData 101 | ) { 102 | status 103 | } 104 | } 105 | `; 106 | 107 | export const QUERY_V1 = gql` 108 | query QueryLanguageV1( 109 | $query: String! 110 | $variables: JSON 111 | $includeDeleted: Boolean 112 | $deferredResponse: DeferredResponseOption 113 | $deferredFormat: DeferredResponseFormat 114 | $cursor: String 115 | $flags: QueryV1Flags 116 | ) { 117 | queryV1( 118 | query: $query 119 | variables: $variables 120 | includeDeleted: $includeDeleted 121 | deferredResponse: $deferredResponse 122 | deferredFormat: $deferredFormat 123 | cursor: $cursor 124 | flags: $flags 125 | ) { 126 | type 127 | data 128 | url 129 | } 130 | } 131 | `; 132 | 133 | export const CREATE_INLINE_ALERT_RULE = gql` 134 | mutation CreateQuestionRuleInstance( 135 | $instance: CreateInlineQuestionRuleInstanceInput! 136 | ) { 137 | createQuestionRuleInstance: createInlineQuestionRuleInstance( 138 | instance: $instance 139 | ) { 140 | id 141 | name 142 | } 143 | } 144 | `; 145 | 146 | export const CREATE_REFERENCED_ALERT_RULE = gql` 147 | mutation CreateQuestionRuleInstance( 148 | $instance: CreateReferencedQuestionRuleInstanceInput! 149 | ) { 150 | createQuestionRuleInstance: createReferencedQuestionRuleInstance( 151 | instance: $instance 152 | ) { 153 | id 154 | name 155 | } 156 | } 157 | `; 158 | 159 | export const UPDATE_INLINE_ALERT_RULE = gql` 160 | mutation UpdateQuestionRuleInstance( 161 | $instance: UpdateInlineQuestionRuleInstanceInput! 162 | ) { 163 | updateQuestionRuleInstance: updateInlineQuestionRuleInstance( 164 | instance: $instance 165 | ) { 166 | id 167 | name 168 | description 169 | version 170 | specVersion 171 | latest 172 | deleted 173 | pollingInterval 174 | templates 175 | question { 176 | queries { 177 | name 178 | query 179 | version 180 | } 181 | } 182 | operations { 183 | when 184 | actions 185 | } 186 | outputs 187 | } 188 | } 189 | `; 190 | 191 | export const UPDATE_REFERENCED_ALERT_RULE = gql` 192 | mutation UpdateQuestionRuleInstance( 193 | $instance: UpdateReferencedQuestionRuleInstanceInput! 194 | ) { 195 | updateQuestionRuleInstance: updateReferencedQuestionRuleInstance( 196 | instance: $instance 197 | ) { 198 | id 199 | name 200 | description 201 | version 202 | specVersion 203 | latest 204 | deleted 205 | pollingInterval 206 | templates 207 | questionId 208 | questionName 209 | operations { 210 | when 211 | actions 212 | } 213 | outputs 214 | } 215 | } 216 | `; 217 | 218 | export const CREATE_QUESTION = gql` 219 | mutation CreateQuestion($question: CreateQuestionInput!) { 220 | createQuestion(question: $question) { 221 | id 222 | title 223 | description 224 | queries { 225 | query 226 | version 227 | } 228 | variables { 229 | name 230 | required 231 | default 232 | } 233 | compliance { 234 | standard 235 | requirements 236 | } 237 | tags 238 | accountId 239 | integrationDefinitionId 240 | } 241 | } 242 | `; 243 | 244 | export const UPDATE_QUESTION = gql` 245 | mutation UpdateQuestion($id: ID!, $update: QuestionUpdate!) { 246 | updateQuestion(id: $id, update: $update) { 247 | id 248 | title 249 | description 250 | queries { 251 | query 252 | version 253 | } 254 | variables { 255 | name 256 | required 257 | default 258 | } 259 | compliance { 260 | standard 261 | requirements 262 | } 263 | tags 264 | accountId 265 | integrationDefinitionId 266 | } 267 | } 268 | `; 269 | 270 | export const DELETE_QUESTION = gql` 271 | mutation DeleteQuestion($id: ID!) { 272 | deleteQuestion(id: $id) { 273 | id 274 | title 275 | description 276 | queries { 277 | query 278 | version 279 | } 280 | variables { 281 | name 282 | required 283 | default 284 | } 285 | compliance { 286 | standard 287 | requirements 288 | } 289 | tags 290 | accountId 291 | integrationDefinitionId 292 | } 293 | } 294 | `; 295 | 296 | export const LIST_INTEGRATION_DEFINITIONS = gql` 297 | query ListIntegrationDefinitions($cursor: String) { 298 | integrationDefinitions(cursor: $cursor) { 299 | definitions { 300 | id 301 | name 302 | type 303 | title 304 | offsiteUrl 305 | offsiteButtonTitle 306 | offsiteStatusQuery 307 | integrationType 308 | integrationClass 309 | beta 310 | repoWebLink 311 | invocationPaused 312 | } 313 | pageInfo { 314 | endCursor 315 | hasNextPage 316 | } 317 | } 318 | } 319 | `; 320 | 321 | export const LIST_INTEGRATION_INSTANCES = gql` 322 | query ListIntegrationInstances($definitionId: String, $cursor: String) { 323 | integrationInstances(definitionId: $definitionId, cursor: $cursor) { 324 | instances { 325 | accountId 326 | config 327 | description 328 | id 329 | integrationDefinitionId 330 | name 331 | pollingInterval 332 | pollingIntervalCronExpression { 333 | hour 334 | dayOfWeek 335 | } 336 | } 337 | pageInfo { 338 | endCursor 339 | hasNextPage 340 | } 341 | } 342 | } 343 | `; 344 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type EntityPropertyValuePrimitive = string | number | boolean; 2 | 3 | export type EntityPropertyValue = 4 | | EntityPropertyValuePrimitive 5 | | EntityPropertyValuePrimitive[] 6 | | undefined 7 | | null; 8 | 9 | export type EntityAdditionalProperties = Record; 10 | 11 | export type Entity = EntityAdditionalProperties & { 12 | _id: string; 13 | _type: string; 14 | _class?: string | string[]; 15 | displayName: string; 16 | }; 17 | 18 | export type RelationshipPropertyValuePrimitive = string | number | boolean; 19 | 20 | export type RelationshipPropertyValue = 21 | | RelationshipPropertyValuePrimitive 22 | | undefined 23 | | null; 24 | 25 | export type RelationshipAdditionalProperties = Record< 26 | string, 27 | RelationshipPropertyValue 28 | >; 29 | 30 | export type Relationship = RelationshipAdditionalProperties & { 31 | _id: string; 32 | _type: string; 33 | _class?: string; 34 | displayName: string; 35 | }; 36 | 37 | export type GraphObject = Entity | Relationship; 38 | 39 | type RawData = { 40 | _rawData?: Record< 41 | string, 42 | { 43 | body: string; 44 | contentType: 'application/json'; 45 | } 46 | >; 47 | }; 48 | 49 | export type EntitySource = 50 | | 'api' 51 | | 'system-internal' 52 | | 'system-mapper' 53 | | 'integration-managed' 54 | | 'integration-external' 55 | | 'sample-data' 56 | | undefined; 57 | 58 | export type EntityForSync = EntityAdditionalProperties & { 59 | _key: string; 60 | _class: string | string[]; 61 | _type: string; 62 | _rawData?: RawData | undefined; 63 | }; 64 | 65 | export type RelationshipForSync = RelationshipAdditionalProperties & { 66 | _key: string; 67 | _class: string | string[]; 68 | _type: string; 69 | _fromEntityId?: string; 70 | _toEntityId?: string; 71 | _fromEntityKey?: string; 72 | _toEntityKey?: string; 73 | _fromEntitySource?: EntitySource; 74 | _toEntitySource?: EntitySource; 75 | _fromEntityScope?: string | undefined; 76 | _toEntityScope?: string | undefined; 77 | _rawData?: RawData | undefined; 78 | }; 79 | 80 | export enum IntegrationPollingInterval { 81 | DISABLED = 'DISABLED', 82 | THIRTY_MINUTES = 'THIRTY_MINUTES', 83 | ONE_HOUR = 'ONE_HOUR', 84 | FOUR_HOURS = 'FOUR_HOURS', 85 | EIGHT_HOURS = 'EIGHT_HOURS', 86 | TWELVE_HOURS = 'TWELVE_HOURS', 87 | ONE_DAY = 'ONE_DAY', 88 | ONE_WEEK = 'ONE_WEEK', 89 | } 90 | 91 | export interface IntegrationPollingIntervalCronExpression { 92 | /** 93 | * UTC day of week. 0-6 (sun-sat) 94 | */ 95 | dayOfWeek?: number; 96 | /** 97 | * UTC hour, 0-23 98 | */ 99 | hour?: number; 100 | } 101 | 102 | interface IntegrationInstanceTag { 103 | AccountName: string; 104 | } 105 | 106 | export interface IntegrationInstanceConfig { 107 | '@tag': IntegrationInstanceTag; 108 | apiUser: string; 109 | apiToken: string; 110 | } 111 | 112 | export interface IntegrationDefinition { 113 | id: string; 114 | name: string; 115 | type: string; 116 | title: string; 117 | offsiteUrl: string | null; 118 | offsiteButtonTitle: string | null; 119 | offsiteStatusQuery: string | null; 120 | integrationType: string | null; 121 | integrationClass: string[]; 122 | beta: boolean; 123 | repoWebLink: string | null; 124 | invocationPaused: boolean | null; 125 | __typename: string; 126 | } 127 | 128 | export interface ListIntegrationDefinitions { 129 | instances: IntegrationDefinition[]; 130 | pageInfo: PageInfo; 131 | } 132 | 133 | export interface IntegrationInstance { 134 | id: string; 135 | accountId: string; 136 | 137 | config?: TConfig; 138 | description?: string; 139 | integrationDefinitionId: string; 140 | name: string; 141 | offsiteComplete?: boolean; 142 | pollingInterval?: IntegrationPollingInterval; 143 | pollingIntervalCronExpression?: IntegrationPollingIntervalCronExpression; 144 | } 145 | 146 | export interface ListIntegrationInstancesOptions { 147 | definitionId?: string; 148 | cursor?: string; 149 | } 150 | 151 | export interface PageInfo { 152 | endCursor?: string; 153 | hasNextPage: boolean; 154 | } 155 | 156 | export interface ListIntegrationInstances { 157 | instances: IntegrationInstance[]; 158 | pageInfo: PageInfo; 159 | } 160 | -------------------------------------------------------------------------------- /src/util/error.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export function fatal(message: string, code = 1) { 4 | console.log(chalk.red(message)); 5 | process.exit(code); 6 | } 7 | -------------------------------------------------------------------------------- /src/util/getProp.ts: -------------------------------------------------------------------------------- 1 | export const getProp = ( 2 | object: T, 3 | keys: string[] | string, 4 | defaultVal?: V, 5 | ): V | undefined => { 6 | keys = Array.isArray(keys) ? keys : keys.split('.'); 7 | const result = object[keys[0]]; 8 | if (result && keys.length > 1) { 9 | return getProp(result, keys.slice(1)); 10 | } 11 | return result === undefined ? defaultVal : result; 12 | }; 13 | -------------------------------------------------------------------------------- /src/util/query/index.ts: -------------------------------------------------------------------------------- 1 | export * as QueryTypes from './types'; 2 | export { query } from './query'; 3 | -------------------------------------------------------------------------------- /src/util/query/query.ts: -------------------------------------------------------------------------------- 1 | import { ApolloQueryResult } from 'apollo-client'; 2 | import { QueryTypes } from './'; 3 | import { PageInfo } from '../../types'; 4 | import { getProp } from '../getProp'; 5 | 6 | export const query = async ( 7 | fn: QueryTypes.QueryFunction, 8 | options: QueryTypes.QueryOptions, 9 | ): Promise> => { 10 | const result: QueryTypes.QueryResults = { 11 | data: [], 12 | errors: [], 13 | }; 14 | 15 | let cursor: string | undefined; 16 | 17 | do { 18 | const res = await fn({ cursor }); 19 | 20 | if (res.errors) { 21 | result.errors = [...result.errors, ...res.errors]; 22 | } 23 | 24 | const data = getProp, Array>( 25 | res, 26 | options.dataPath, 27 | [], 28 | ); 29 | 30 | if (data) { 31 | result.data = [...result.data, ...data]; 32 | } 33 | 34 | const cursorInfo = getProp, PageInfo>( 35 | res, 36 | options.cursorPath, 37 | ); 38 | cursor = cursorInfo?.endCursor; 39 | } while (cursor); 40 | 41 | return result; 42 | }; 43 | -------------------------------------------------------------------------------- /src/util/query/types.ts: -------------------------------------------------------------------------------- 1 | import { ApolloQueryResult } from 'apollo-client'; 2 | import { GraphQLError } from 'graphql'; 3 | 4 | export interface QueryResults { 5 | data: Array; 6 | errors: ReadonlyArray; 7 | } 8 | 9 | export interface QueryOptions { 10 | dataPath: string; 11 | cursorPath: string; 12 | } 13 | 14 | export interface QueryFunctionProps { 15 | cursor: string | undefined; 16 | } 17 | 18 | export interface QueryFunction { 19 | (options: QueryFunctionProps): Promise>; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["dist", "examples", "**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es2018", 5 | "lib": ["es2018", "dom"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noUnusedLocals": true, 9 | "pretty": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true 12 | }, 13 | "exclude": ["dist", "examples"] 14 | } 15 | --------------------------------------------------------------------------------