@jupiterone/integration-sdk-cli 6.17.0 → 6.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/src/commands/document.js +31 -2
  2. package/dist/src/commands/document.js.map +1 -1
  3. package/dist/src/commands/index.d.ts +1 -0
  4. package/dist/src/commands/index.js +1 -0
  5. package/dist/src/commands/index.js.map +1 -1
  6. package/dist/src/commands/validate-question-file.d.ts +2 -0
  7. package/dist/src/commands/validate-question-file.js +70 -0
  8. package/dist/src/commands/validate-question-file.js.map +1 -0
  9. package/dist/src/index.js +2 -1
  10. package/dist/src/index.js.map +1 -1
  11. package/dist/src/questions/managedQuestionFileValidator.d.ts +53 -0
  12. package/dist/src/questions/managedQuestionFileValidator.js +140 -0
  13. package/dist/src/questions/managedQuestionFileValidator.js.map +1 -0
  14. package/dist/src/services/queryLanguage.d.ts +21 -0
  15. package/dist/src/services/queryLanguage.js +34 -0
  16. package/dist/src/services/queryLanguage.js.map +1 -0
  17. package/dist/tsconfig.dist.tsbuildinfo +845 -12
  18. package/package.json +7 -4
  19. package/src/__tests__/__snapshots__/cli.test.ts.snap +52 -5
  20. package/src/__tests__/cli/validate-question-file.test.ts +24 -0
  21. package/src/__tests__/cli.test.ts +4 -0
  22. package/src/commands/document.ts +39 -2
  23. package/src/commands/index.ts +1 -0
  24. package/src/commands/validate-question-file.ts +82 -0
  25. package/src/index.ts +3 -1
  26. package/src/questions/__fixtures__/questions/basic.yaml +15 -0
  27. package/src/questions/__fixtures__/questions/compliance.yaml +41 -0
  28. package/src/questions/__fixtures__/questions/empty-questions.yaml +4 -0
  29. package/src/questions/__fixtures__/questions/invalid-yaml.yaml +16 -0
  30. package/src/questions/__fixtures__/questions/multiple-queries.yaml +17 -0
  31. package/src/questions/__fixtures__/questions/multiple-questions.yaml +27 -0
  32. package/src/questions/__fixtures__/questions/non-unique-question-id.yaml +28 -0
  33. package/src/questions/__fixtures__/questions/non-unique-question-name.yaml +18 -0
  34. package/src/questions/__fixtures__/questions/non-unique-question-tag.yaml +17 -0
  35. package/src/questions/__fixtures__/questions/non-unique-question-title.yaml +28 -0
  36. package/src/questions/managedQuestionFileValidator.test.ts +175 -0
  37. package/src/questions/managedQuestionFileValidator.ts +180 -0
  38. package/src/services/queryLanguage.ts +46 -0
@@ -0,0 +1,175 @@
1
+ import {
2
+ createApiClient,
3
+ getApiBaseUrl,
4
+ } from '@jupiterone/integration-sdk-runtime';
5
+ import path from 'path';
6
+ import { validateManagedQuestionFile } from './managedQuestionFileValidator';
7
+
8
+ function getFixturePath(fixtureName: string) {
9
+ return path.join(
10
+ __dirname,
11
+ './__fixtures__/questions/',
12
+ `${fixtureName}.yaml`,
13
+ );
14
+ }
15
+
16
+ async function dryRunTest(fixtureName: string) {
17
+ await validateManagedQuestionFile({
18
+ filePath: getFixturePath(fixtureName),
19
+ });
20
+ }
21
+
22
+ describe('#validateManagedQuestionFile dryRun - Valid', () => {
23
+ test('should validate empty questions file', async () => {
24
+ await dryRunTest('empty-questions');
25
+ });
26
+
27
+ test('should validate basic question file', async () => {
28
+ await dryRunTest('basic');
29
+ });
30
+
31
+ test('should validate multiple queries in question', async () => {
32
+ await dryRunTest('multiple-queries');
33
+ });
34
+
35
+ test('should validate multiple questions', async () => {
36
+ await dryRunTest('multiple-questions');
37
+ });
38
+
39
+ test('should validate multiple questions', async () => {
40
+ await dryRunTest('multiple-questions');
41
+ });
42
+
43
+ test('should validate compliance question', async () => {
44
+ await dryRunTest('compliance');
45
+ });
46
+ });
47
+
48
+ describe('#validateManagedQuestionFile dryRun - Invalid', () => {
49
+ test('should throw if duplicate question id', async () => {
50
+ await expect(() =>
51
+ dryRunTest('non-unique-question-id'),
52
+ ).rejects.toThrowError(
53
+ `Non-unique question ID found in file (questionId=integration-question-google-cloud-disabled-project-services)`,
54
+ );
55
+ });
56
+
57
+ test('should throw if duplicate question title', async () => {
58
+ await expect(() =>
59
+ dryRunTest('non-unique-question-title'),
60
+ ).rejects.toThrowError(
61
+ `Non-unique question title found in file (questionId=integration-question-google-cloud-corporate-login-credentials, questionTitle=Which Google Cloud API services are disabled for my project?)`,
62
+ );
63
+ });
64
+
65
+ test('should throw if duplicate question tag', async () => {
66
+ await expect(() =>
67
+ dryRunTest('non-unique-question-tag'),
68
+ ).rejects.toThrowError(
69
+ `Non-unique question tag found (questionId=integration-question-google-cloud-disabled-project-services, tag=google-cloud)`,
70
+ );
71
+ });
72
+
73
+ test('should throw if duplicate query name in question', async () => {
74
+ await expect(() =>
75
+ dryRunTest('non-unique-question-name'),
76
+ ).rejects.toThrowError(
77
+ `Duplicate query name in question detected (questionId=integration-question-google-cloud-corporate-login-credentials, queryName=good)`,
78
+ );
79
+ });
80
+ });
81
+
82
+ describe('#validateManagedQuestionFile input validation', () => {
83
+ test('should throw if file does not exist', async () => {
84
+ await expect(() => dryRunTest('invalid-file-path')).rejects.toThrowError(
85
+ `Question file not found (filePath=${getFixturePath(
86
+ 'invalid-file-path',
87
+ )})`,
88
+ );
89
+ });
90
+
91
+ test('should throw if file invalid YAML', async () => {
92
+ await expect(() => dryRunTest('invalid-yaml')).rejects.toThrowError(
93
+ `can not read a block mapping entry; a multiline key may not be an implicit key`,
94
+ );
95
+ });
96
+ });
97
+
98
+ describe('#validateManagedQuestionFile non-dryRun', () => {
99
+ test('should validate non-dry run', async () => {
100
+ const apiClient = createApiClient({
101
+ apiBaseUrl: getApiBaseUrl(),
102
+ account: 'test-account',
103
+ });
104
+
105
+ const postSpy = jest.spyOn(apiClient, 'post').mockResolvedValue({
106
+ data: [
107
+ {
108
+ query: 'find google_user with email $="@{{domain}}"',
109
+ valid: true,
110
+ },
111
+ {
112
+ query: 'find google_user with email !$="@{{domain}}"',
113
+ valid: true,
114
+ },
115
+ ],
116
+ });
117
+
118
+ await validateManagedQuestionFile({
119
+ apiClient,
120
+ filePath: getFixturePath('multiple-queries'),
121
+ });
122
+
123
+ expect(postSpy).toHaveBeenCalledTimes(1);
124
+ expect(postSpy).toHaveBeenCalledWith('/j1ql/validate', {
125
+ queries: [
126
+ 'find google_user with email $="@{{domain}}"',
127
+ 'find google_user with email !$="@{{domain}}"',
128
+ ],
129
+ });
130
+ });
131
+
132
+ test('should throw if invalid query provided', async () => {
133
+ const apiClient = createApiClient({
134
+ apiBaseUrl: getApiBaseUrl(),
135
+ account: 'test-account',
136
+ });
137
+
138
+ const invalidQueryResult = 'find google_user with email $="@{{domain}}"';
139
+
140
+ const postSpy = jest.spyOn(apiClient, 'post').mockResolvedValue({
141
+ data: [
142
+ {
143
+ query: invalidQueryResult,
144
+ // NOTE: Mock invalid query response!
145
+ valid: false,
146
+ },
147
+ {
148
+ query: 'find google_user with email !$="@{{domain}}"',
149
+ valid: true,
150
+ },
151
+ ],
152
+ });
153
+
154
+ await expect(() =>
155
+ validateManagedQuestionFile({
156
+ apiClient,
157
+ filePath: getFixturePath('multiple-queries'),
158
+ }),
159
+ ).rejects.toThrowError(
160
+ `Queries failed to validate (queries=${JSON.stringify(
161
+ [invalidQueryResult],
162
+ null,
163
+ 2,
164
+ )})`,
165
+ );
166
+
167
+ expect(postSpy).toHaveBeenCalledTimes(1);
168
+ expect(postSpy).toHaveBeenCalledWith('/j1ql/validate', {
169
+ queries: [
170
+ 'find google_user with email $="@{{domain}}"',
171
+ 'find google_user with email !$="@{{domain}}"',
172
+ ],
173
+ });
174
+ });
175
+ });
@@ -0,0 +1,180 @@
1
+ import { ApiClient } from '@jupiterone/integration-sdk-runtime';
2
+ import { promises as fs } from 'fs';
3
+ import * as yaml from 'js-yaml';
4
+ import * as Runtypes from 'runtypes';
5
+ import * as queryLanguage from '../services/queryLanguage';
6
+
7
+ export const ManagedQuestionQueryRecord = Runtypes.Record({
8
+ name: Runtypes.String.Or(Runtypes.Undefined),
9
+ query: Runtypes.String,
10
+ });
11
+
12
+ export const ManagedQuestionComplianceDetailsRecord = Runtypes.Record({
13
+ standard: Runtypes.String,
14
+ requirements: Runtypes.Array(Runtypes.String).Or(Runtypes.Undefined),
15
+ controls: Runtypes.Array(Runtypes.String).Or(Runtypes.Undefined),
16
+ });
17
+
18
+ export const ManagedQuestionRecord = Runtypes.Record({
19
+ id: Runtypes.String,
20
+ title: Runtypes.String,
21
+ description: Runtypes.String,
22
+ queries: Runtypes.Array(ManagedQuestionQueryRecord),
23
+ tags: Runtypes.Array(Runtypes.String),
24
+ compliance: Runtypes.Array(ManagedQuestionComplianceDetailsRecord).Or(
25
+ Runtypes.Undefined,
26
+ ),
27
+ });
28
+
29
+ export const QuestionUploadFileDataRecord = Runtypes.Record({
30
+ integrationDefinitionId: Runtypes.String,
31
+ sourceId: Runtypes.String,
32
+ questions: Runtypes.Array(ManagedQuestionRecord),
33
+ });
34
+
35
+ export type ManagedQuestion = Runtypes.Static<typeof ManagedQuestionRecord>;
36
+ export type QuestionUploadFileData = Runtypes.Static<
37
+ typeof QuestionUploadFileDataRecord
38
+ >;
39
+
40
+ function validateQuestionIdUniqueness(questions: ManagedQuestion[]) {
41
+ const questionsIdSet = new Set<string>();
42
+
43
+ for (const question of questions) {
44
+ if (questionsIdSet.has(question.id)) {
45
+ throw new Error(
46
+ `Non-unique question ID found in file (questionId=${question.id})`,
47
+ );
48
+ }
49
+
50
+ questionsIdSet.add(question.id);
51
+ }
52
+ }
53
+
54
+ function validateQuestionTitleUniqueness(questions: ManagedQuestion[]) {
55
+ const questionTitleSet = new Set<string>();
56
+
57
+ for (const question of questions) {
58
+ if (questionTitleSet.has(question.title)) {
59
+ throw new Error(
60
+ `Non-unique question title found in file (questionId=${question.id}, questionTitle=${question.title})`,
61
+ );
62
+ }
63
+
64
+ questionTitleSet.add(question.title);
65
+ }
66
+ }
67
+
68
+ function validateQuestionQueryNameUniqueness(questions: ManagedQuestion[]) {
69
+ for (const question of questions) {
70
+ const questionQueryNameSet = new Set<string>();
71
+
72
+ for (const query of question.queries) {
73
+ if (query.name) {
74
+ if (questionQueryNameSet.has(query.name)) {
75
+ throw new Error(
76
+ `Duplicate query name in question detected (questionId=${question.id}, queryName=${query.name})`,
77
+ );
78
+ }
79
+
80
+ questionQueryNameSet.add(query.name);
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ async function validateQuestionQueries(
87
+ apiClient: ApiClient,
88
+ questions: ManagedQuestion[],
89
+ ) {
90
+ const queries: string[] = [];
91
+
92
+ for (const question of questions) {
93
+ for (const query of question.queries) {
94
+ queries.push(query.query);
95
+ }
96
+ }
97
+
98
+ const queryValidationResults = await queryLanguage.validateQueries(
99
+ apiClient,
100
+ queries,
101
+ );
102
+
103
+ const failedQueries = queryValidationResults
104
+ .filter((r) => r.valid === false)
105
+ .map((r) => r.query);
106
+
107
+ if (failedQueries.length) {
108
+ throw new Error(
109
+ `Queries failed to validate (queries=${JSON.stringify(
110
+ failedQueries,
111
+ null,
112
+ 2,
113
+ )})`,
114
+ );
115
+ }
116
+ }
117
+
118
+ function validateQuestionTagUniqueness(questions: ManagedQuestion[]) {
119
+ for (const question of questions) {
120
+ const questionTagSet = new Set<string>();
121
+
122
+ for (const tag of question.tags) {
123
+ if (questionTagSet.has(tag)) {
124
+ throw new Error(
125
+ `Non-unique question tag found (questionId=${question.id}, tag=${tag})`,
126
+ );
127
+ }
128
+
129
+ questionTagSet.add(tag);
130
+ }
131
+ }
132
+ }
133
+
134
+ function validateQuestionFileData(questionFile: QuestionUploadFileData) {
135
+ QuestionUploadFileDataRecord.check(questionFile);
136
+
137
+ validateQuestionIdUniqueness(questionFile.questions);
138
+ validateQuestionTitleUniqueness(questionFile.questions);
139
+ validateQuestionTagUniqueness(questionFile.questions);
140
+ validateQuestionQueryNameUniqueness(questionFile.questions);
141
+ }
142
+
143
+ async function loadFile(filePath: string) {
144
+ try {
145
+ const file = await fs.readFile(filePath, {
146
+ encoding: 'utf-8',
147
+ });
148
+
149
+ return file;
150
+ } catch (err) {
151
+ if (err.code === 'ENOENT') {
152
+ throw new Error(`Question file not found (filePath=${filePath})`);
153
+ }
154
+
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ async function loadQuestionFile(filePath: string) {
160
+ const rawQuestionFile = await loadFile(filePath);
161
+ return yaml.load(rawQuestionFile) as QuestionUploadFileData;
162
+ }
163
+
164
+ type ValidateManagedQuestionFileParams = {
165
+ filePath: string;
166
+ apiClient?: ApiClient;
167
+ };
168
+
169
+ export async function validateManagedQuestionFile(
170
+ params: ValidateManagedQuestionFileParams,
171
+ ) {
172
+ const { filePath, apiClient } = params;
173
+
174
+ const parsedQuestionFile = await loadQuestionFile(filePath);
175
+ validateQuestionFileData(parsedQuestionFile);
176
+
177
+ if (apiClient) {
178
+ await validateQuestionQueries(apiClient, parsedQuestionFile.questions);
179
+ }
180
+ }
@@ -0,0 +1,46 @@
1
+ import chunk from 'lodash/chunk';
2
+ import { ApiClient } from '@jupiterone/integration-sdk-runtime';
3
+
4
+ enum QueryLanguageErrorCode {
5
+ PARSER_ERROR = 'PARSER_ERROR',
6
+ VALIDATION_ERROR = 'VALIDATION_ERROR',
7
+ INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
8
+ FORBIDDEN_ERROR = 'FORBIDDEN_ERROR',
9
+ }
10
+
11
+ type ParseAndValidateRawQueriesResult = {
12
+ valid: boolean;
13
+ query: string;
14
+ error?: {
15
+ message: string;
16
+ code: QueryLanguageErrorCode;
17
+ };
18
+ };
19
+
20
+ type ParseAndValidateRawQueriesResults = ParseAndValidateRawQueriesResult[];
21
+
22
+ /**
23
+ * Maximum number of queries that can be supplied to `/j1ql/validate` endpoint
24
+ */
25
+ const MAX_QUERIES_TO_VALIDATE = 250;
26
+
27
+ /**
28
+ * Sends queries to `/j1ql/validate` in batches to be validated
29
+ */
30
+ export async function validateQueries(
31
+ apiClient: ApiClient,
32
+ queries: string[],
33
+ ): Promise<ParseAndValidateRawQueriesResults> {
34
+ const queryBatches = chunk(queries, MAX_QUERIES_TO_VALIDATE);
35
+ let overallResults: ParseAndValidateRawQueriesResults = [];
36
+
37
+ for (const queryBatch of queryBatches) {
38
+ const result = await apiClient.post('/j1ql/validate', {
39
+ queries: queryBatch,
40
+ });
41
+
42
+ overallResults = overallResults.concat(result.data);
43
+ }
44
+
45
+ return overallResults;
46
+ }