@cloud-copilot/iam-shrink 0.1.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 (85) hide show
  1. package/LICENSE.txt +674 -0
  2. package/README.md +187 -0
  3. package/dist/cjs/cli.d.ts +2 -0
  4. package/dist/cjs/cli.d.ts.map +1 -0
  5. package/dist/cjs/cli.js +78 -0
  6. package/dist/cjs/cli.js.map +1 -0
  7. package/dist/cjs/cli_utils.d.ts +30 -0
  8. package/dist/cjs/cli_utils.d.ts.map +1 -0
  9. package/dist/cjs/cli_utils.js +75 -0
  10. package/dist/cjs/cli_utils.js.map +1 -0
  11. package/dist/cjs/errors.d.ts +13 -0
  12. package/dist/cjs/errors.d.ts.map +1 -0
  13. package/dist/cjs/errors.js +56 -0
  14. package/dist/cjs/errors.js.map +1 -0
  15. package/dist/cjs/index.d.ts +3 -0
  16. package/dist/cjs/index.d.ts.map +1 -0
  17. package/dist/cjs/index.js +8 -0
  18. package/dist/cjs/index.js.map +1 -0
  19. package/dist/cjs/package.json +3 -0
  20. package/dist/cjs/shrink.d.ts +131 -0
  21. package/dist/cjs/shrink.d.ts.map +1 -0
  22. package/dist/cjs/shrink.js +358 -0
  23. package/dist/cjs/shrink.js.map +1 -0
  24. package/dist/cjs/shrink_file.d.ts +12 -0
  25. package/dist/cjs/shrink_file.d.ts.map +1 -0
  26. package/dist/cjs/shrink_file.js +38 -0
  27. package/dist/cjs/shrink_file.js.map +1 -0
  28. package/dist/cjs/stdin.d.ts +7 -0
  29. package/dist/cjs/stdin.d.ts.map +1 -0
  30. package/dist/cjs/stdin.js +34 -0
  31. package/dist/cjs/stdin.js.map +1 -0
  32. package/dist/cjs/validate.d.ts +11 -0
  33. package/dist/cjs/validate.d.ts.map +1 -0
  34. package/dist/cjs/validate.js +30 -0
  35. package/dist/cjs/validate.js.map +1 -0
  36. package/dist/esm/cli.d.ts +2 -0
  37. package/dist/esm/cli.d.ts.map +1 -0
  38. package/dist/esm/cli.js +76 -0
  39. package/dist/esm/cli.js.map +1 -0
  40. package/dist/esm/cli_utils.d.ts +30 -0
  41. package/dist/esm/cli_utils.d.ts.map +1 -0
  42. package/dist/esm/cli_utils.js +69 -0
  43. package/dist/esm/cli_utils.js.map +1 -0
  44. package/dist/esm/errors.d.ts +13 -0
  45. package/dist/esm/errors.d.ts.map +1 -0
  46. package/dist/esm/errors.js +50 -0
  47. package/dist/esm/errors.js.map +1 -0
  48. package/dist/esm/index.d.ts +3 -0
  49. package/dist/esm/index.d.ts.map +1 -0
  50. package/dist/esm/index.js +3 -0
  51. package/dist/esm/index.js.map +1 -0
  52. package/dist/esm/package.json +3 -0
  53. package/dist/esm/shrink.d.ts +131 -0
  54. package/dist/esm/shrink.d.ts.map +1 -0
  55. package/dist/esm/shrink.js +343 -0
  56. package/dist/esm/shrink.js.map +1 -0
  57. package/dist/esm/shrink_file.d.ts +12 -0
  58. package/dist/esm/shrink_file.d.ts.map +1 -0
  59. package/dist/esm/shrink_file.js +35 -0
  60. package/dist/esm/shrink_file.js.map +1 -0
  61. package/dist/esm/stdin.d.ts +7 -0
  62. package/dist/esm/stdin.d.ts.map +1 -0
  63. package/dist/esm/stdin.js +31 -0
  64. package/dist/esm/stdin.js.map +1 -0
  65. package/dist/esm/validate.d.ts +11 -0
  66. package/dist/esm/validate.d.ts.map +1 -0
  67. package/dist/esm/validate.js +27 -0
  68. package/dist/esm/validate.js.map +1 -0
  69. package/package.json +43 -0
  70. package/postbuild.sh +13 -0
  71. package/src/cli.ts +83 -0
  72. package/src/cli_utils.test.ts +117 -0
  73. package/src/cli_utils.ts +82 -0
  74. package/src/errors.ts +52 -0
  75. package/src/index.ts +3 -0
  76. package/src/shrink.test.ts +594 -0
  77. package/src/shrink.ts +385 -0
  78. package/src/shrink_file.test.ts +72 -0
  79. package/src/shrink_file.ts +38 -0
  80. package/src/stdin.ts +34 -0
  81. package/src/validate.test.ts +55 -0
  82. package/src/validate.ts +29 -0
  83. package/tsconfig.cjs.json +12 -0
  84. package/tsconfig.esm.json +15 -0
  85. package/tsconfig.json +23 -0
package/src/shrink.ts ADDED
@@ -0,0 +1,385 @@
1
+ import { expandIamActions } from '@cloud-copilot/iam-expand';
2
+ import { ShrinkValidationError } from './errors.js';
3
+ import { validateShrinkResults } from './validate.js';
4
+
5
+ export interface ShrinkOptions {
6
+ iterations: number
7
+ }
8
+
9
+ const defaultOptions: ShrinkOptions = {
10
+ iterations: 2
11
+ }
12
+
13
+ /**
14
+ * Shrink the list of desired patterns minus the excluded patterns to the smallest list of patterns
15
+ * that still includes the actions you want and only the actions you want.
16
+ *
17
+ * This will create a Target Set of actions that match the patterns in {@link desiredPatterns}, and do
18
+ * not match any pattern in {@link excludedPatterns}.
19
+ *
20
+ * It will then derive the list of wildcard patterns that match the Target Set and no other actions.
21
+ *
22
+ * @param desiredPatterns the list of patterns you want to include, e.g. ['s3:Get*', 's3:PutObject', 's3:*Tag*']
23
+ * @param iterations the number of iterations to run the shrink operations
24
+ * @returns the smallest list of patterns that will match only the actions specified by desiredPatterns and not match any of the excludedPatterns or any actions not specified by desiredPatterns.
25
+ */
26
+ export async function shrink(desiredPatterns: string[], shrinkOptions?: Partial<ShrinkOptions>): Promise<string[]> {
27
+ //Check for an all actions wildcard
28
+ const wildCard = desiredPatterns.find(pattern => collapseAsterisks(pattern) === '*');
29
+ if(wildCard) {
30
+ return ["*"];
31
+ }
32
+
33
+ const options = {...defaultOptions, ...shrinkOptions}
34
+ const targetActions = await expandIamActions(desiredPatterns, {expandServiceAsterisk: true})
35
+ const expandedActionsByService = groupActionsByService(targetActions);
36
+ const services = Array.from(expandedActionsByService.keys()).sort();
37
+
38
+ const reducedActions: string[] = [];
39
+ for(const service of services) {
40
+ const desiredActions = expandedActionsByService.get(service)!
41
+ const possibleActions = mapActions(await expandIamActions(`${service}:*`, {expandServiceAsterisk: true}))
42
+ const reducedServiceActions = shrinkResolvedList(desiredActions.withoutService, possibleActions, options.iterations)
43
+
44
+ //Validation
45
+ const reducedServiceActionsWithService = reducedServiceActions.map(action => `${service}:${action}`);
46
+ const invalidMatch = await validateShrinkResults(desiredActions.withService, reducedServiceActionsWithService);
47
+ if(invalidMatch) {
48
+ throw new ShrinkValidationError(desiredPatterns, invalidMatch);
49
+ }
50
+ reducedActions.push(...reducedServiceActionsWithService)
51
+ }
52
+
53
+ return reducedActions;
54
+ }
55
+
56
+ /**
57
+ * Map an array of service:action strings to just the action
58
+ *
59
+ * @param actions the array of service:action strings such as ['s3:GetObject', 'ec2:DescribeInstances']
60
+ * @returns an array of just the action strings such as ['GetObject', 'DescribeInstances']
61
+ */
62
+ export function mapActions(actions: string[]): string[] {
63
+ return actions.map(action => action.split(":")[1]);
64
+ }
65
+
66
+ /**
67
+ * Groups an array of service:action strings by service
68
+ *
69
+ * Returns a map of service to an object with two arrays: withService and withoutService
70
+ * * withService contains the full service:action strings
71
+ * * withoutService contains just the action strings
72
+ *
73
+ * @param actions the array of service:action strings such as ['s3:GetObject', 'ec2:DescribeInstances']
74
+ * @returns a map of service to an object with two arrays: withService and withoutService
75
+ */
76
+ export function groupActionsByService(actions: string[]): Map<string, {withService: string[], withoutService: string[]}> {
77
+ const serviceMap = new Map<string, {withService: string[], withoutService: string[]}>();
78
+ actions.forEach(actionString => {
79
+ const [service, action] = actionString.split(":");
80
+ if (!serviceMap.has(service)) {
81
+ serviceMap.set(service, {withService: [], withoutService: []});
82
+ }
83
+ serviceMap.get(service)!.withService.push(actionString);
84
+ serviceMap.get(service)!.withoutService.push(action);
85
+
86
+ });
87
+ return serviceMap;
88
+ }
89
+
90
+ /**
91
+ * Shrink a list of desired actions to the smallest number of patterns that match the desired actions
92
+ * from the possible actions and no other actions.
93
+ *
94
+ * @param desiredActions the list of actions you want to include
95
+ * @param possibleActions the list of actions that are possible
96
+ * @param iterations the number of iterations to run the shrink operations
97
+ * @returns the smallest list of patterns that when compared to possibleActions will match only the desiredActions and no others
98
+ */
99
+ export function shrinkResolvedList(desiredActions: string[], possibleActions: string[], iterations: number): string[] {
100
+ const desiredActionSet = new Set(desiredActions)
101
+ const undesiredActions = possibleActions.filter(action => !desiredActionSet.has(action))
102
+
103
+ if(undesiredActions.length === 0) {
104
+ // If there are no undesired actions, that means we want all actions
105
+ return ["*"];
106
+ }
107
+
108
+ // Iteratively shrink based on the most commmon sequence until we can't shrink anymore
109
+ let previousActionListLength = desiredActions.length;
110
+ let actionList = desiredActions.slice()
111
+
112
+ do {
113
+ previousActionListLength = actionList.length;
114
+ actionList = shrinkIteration(actionList, undesiredActions, false);
115
+ iterations = iterations - 1
116
+ if(iterations <= 0) {
117
+ return actionList
118
+ }
119
+ } while (actionList.length < previousActionListLength);
120
+
121
+
122
+ // Iteratively shrink based on all common sequences until we can't shrink anymore
123
+ do {
124
+ previousActionListLength = actionList.length;
125
+ actionList = shrinkIteration(actionList, undesiredActions, true);
126
+ iterations = iterations - 1
127
+ if(iterations <= 0) {
128
+ return actionList
129
+ }
130
+ } while (actionList.length < previousActionListLength);
131
+
132
+ return actionList;
133
+ }
134
+
135
+ /**
136
+ * Shrink the list of desired actions for while excluding the undesired actions
137
+ *
138
+ * @param desiredActions the list of actions you want to include, can be a mix of full actions and wildcards
139
+ * @param undesiredActions the list of actions you want to exclude no matter what
140
+ * @param deep if true, will shrink based on all common sequences, otherwise will only shrink based on the most common sequence
141
+ * @returns the smallest list of actions that will match only the desiredActions and not match any of the undesiredActions or any actions not specified by desiredActions.
142
+ */
143
+ export function shrinkIteration(desiredActions: string[], undesiredActions: string[], deep: boolean): string[] {
144
+ // Find all common words in the strings in the desiredActions array
145
+ const commonSequences = findCommonSequences(desiredActions).filter(sequence => sequence.sequence != "*");;
146
+ commonSequences.sort((a, b) => {
147
+ return b.frequency - a.frequency;
148
+ });
149
+
150
+ const sequencesToProcess = deep ? commonSequences : commonSequences.slice(0, 1);
151
+
152
+ // Reduce the actions based on the common sequences
153
+ let reducedActions = desiredActions
154
+ for(const sequence of sequencesToProcess) {
155
+ const reducedIteration = Array.from(
156
+ new Set(reducedActions.map(action => reduceAction(action, sequence.sequence, undesiredActions)))
157
+ );
158
+ reducedActions = consolidateWildcardPatterns(reducedIteration);
159
+ }
160
+
161
+ return reducedActions;
162
+ }
163
+
164
+ /**
165
+ * Reduces a singele action into a smaller number of parts by replace one part at a time with an asterisk
166
+ * and validating that there are no undesired actions that match the new action
167
+ *
168
+ * @param desiredAction the action to reduce
169
+ * @param sequence the sequence to reduce the action by
170
+ * @param undesiredActions the list of actions that should not match the reduced action
171
+ * @returns the reduced action with as many parts replaced with asterisks as possible while still matching the desired actions and not matching any of the undesired actions
172
+ */
173
+ export function reduceAction(desiredAction: string, sequence: string, undesiredActions: string[]): string {
174
+ const testArray = splitActionIntoParts(desiredAction);
175
+ if(testArray.length === 1) {
176
+ return desiredAction;
177
+ }
178
+ const indexOfSequence = testArray.indexOf(sequence);
179
+ let shorterValue = desiredAction ;
180
+
181
+ if(indexOfSequence === 0) {
182
+ const tempArray = testArray.slice();
183
+ //Iterate though ever following element and see if replacing the sequence with the first common sequence results in a failure
184
+ for(let i = 1; i < testArray.length; i++) {
185
+ tempArray[i] = "*";
186
+ const tempString = collapseAsterisks(tempArray.join(""));
187
+ const problemMatch = wildcardActionMatchesAnyString(tempString, undesiredActions);
188
+ if(problemMatch) {
189
+ // Stopping here seems to work the best
190
+ break;
191
+ }
192
+ shorterValue = tempString;
193
+ }
194
+
195
+ //its at the beginning
196
+ } else if(indexOfSequence === testArray.length - 1) {
197
+ //its at the end
198
+ const tempArray = testArray.slice();
199
+ //Iterate through the array backwards and see if replace the items with * results in a failure
200
+ for(let i = testArray.length - 2; i >= 0; i--) {
201
+ tempArray[i] = "*";
202
+ const tempString = collapseAsterisks(tempArray.join(""));
203
+ const problemMatch = wildcardActionMatchesAnyString(tempString, undesiredActions);
204
+ if(problemMatch) {
205
+ // Stopping here seems to work the best
206
+ break;
207
+ }
208
+
209
+ shorterValue = tempString;
210
+ }
211
+ } else if(indexOfSequence > 0) {
212
+ //its in the middle
213
+ const tempArray = testArray.slice();
214
+ //Iterate forward through the array and see if replacing the items with * results in a failure
215
+ for(let i = indexOfSequence + 1; i < testArray.length; i++) {
216
+ tempArray[i] = "*";
217
+ const tempString = collapseAsterisks(tempArray.join(""));
218
+ const problemMatch = wildcardActionMatchesAnyString(tempString, undesiredActions);
219
+ if(problemMatch) {
220
+ //This replacement cased a prolem match, so revert it before going backwards in the strings
221
+ tempArray[i] = testArray[i]
222
+ // Stopping here seems to work the best
223
+ break;
224
+ }
225
+ shorterValue = tempString;
226
+ }
227
+ //Iterate through the array backwards and see if replace the items with * results in a failure
228
+ for(let i = indexOfSequence - 1; i >= 0; i--) {
229
+ tempArray[i] = "*";
230
+ const tempString = collapseAsterisks(tempArray.join(""));
231
+ const problemMatch = wildcardActionMatchesAnyString(tempString, undesiredActions);
232
+ if(problemMatch) {
233
+ // Stopping here seems to work the best
234
+ break;
235
+ }
236
+ shorterValue = tempString;
237
+ }
238
+
239
+ }
240
+
241
+ return shorterValue;
242
+ }
243
+
244
+ /**
245
+ * Consolidate multile consecutive asterisks into a single asterisk
246
+ *
247
+ * @param wildcardAction the action to collapse
248
+ * @returns the action with consecutive asterisks collapsed into a single asterisk
249
+ */
250
+ export function collapseAsterisks(wildcardAction: string): string {
251
+ return wildcardAction.replace(/\*+/g, '*')
252
+ }
253
+
254
+ /**
255
+ * Convert a wildcard action into a regular expression
256
+ *
257
+ * @param wildcardAction the wildcard action to convert
258
+ * @returns a regular expression that will match the wildcard action
259
+ */
260
+ export function regexForWildcardAction(wildcardAction: string): RegExp {
261
+ wildcardAction = collapseAsterisks(wildcardAction);
262
+ const pattern = "^" + wildcardAction.replace(/\*/g, '.*?') + "$"
263
+ return new RegExp(pattern, 'i')
264
+ }
265
+
266
+ /**
267
+ * Checks to see if a wildcard action matches any of the strings in a list
268
+ *
269
+ * @param wildcardAction the wildcard action to check
270
+ * @param strings the list of strings to check against
271
+ * @returns true if the wildcard action matches any of the strings
272
+ */
273
+ export function wildcardActionMatchesAnyString(wildcardAction: string, strings: string[]): boolean {
274
+ const regex = regexForWildcardAction(wildcardAction)
275
+ for (const string of strings) {
276
+ if (regex.test(string)) {
277
+ return true;
278
+ }
279
+ }
280
+ return false;
281
+ }
282
+
283
+ /**
284
+ * Split an IAM Action into parts based on capital letters and asterisks
285
+ * For a new part to start there must be a transition from a lowercase letter to an uppercase letter or an asterisk
286
+ * For example :
287
+ * * "CreateAccessPointForObjectLambda" would be split into ["Create", "Access", "Point", "For", "Object", "Lambda"]
288
+ * * "*ObjectTagging*" would be split into ["*", "Object", "Tagging", "*"]
289
+ *
290
+ * @param input the IAM Action to split
291
+ * @returns the parts of the IAM Action
292
+ */
293
+ export function splitActionIntoParts(input: string): string[] {
294
+ // Split the string using a regex that finds transitions from lower to upper case or asterisks
295
+ // and keeps sequences of uppercase letters together
296
+ // return input.split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/);
297
+ return input.split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?=[*])|(?<=[*])/);
298
+ }
299
+
300
+
301
+ /**
302
+ * Given a list of strings and a list of strings those parts are in, count the number of times each part appears in the strings
303
+ *
304
+ * @param substrings the sub strings to count
305
+ * @param actions the list of strings to count the substrings in
306
+ * @returns Returns a map of the substring to the number of times it appears in the actions
307
+ */
308
+ export function countSubstrings(substrings: string[], actions: string[]): Map<string, number> {
309
+ const substringCount = new Map<string, number>();
310
+ substrings.forEach(substring => {
311
+ let count = 0;
312
+ actions.forEach(action => {
313
+ if (action.includes(substring)) {
314
+ count++;
315
+ }
316
+ });
317
+ if (count > 0) {
318
+ substringCount.set(substring, count);
319
+ }
320
+ });
321
+ return substringCount;
322
+ }
323
+
324
+ /**
325
+ * Finds all the the common sequences in a list of actions strings and counts their frequency and length.
326
+ *
327
+ * @param actions the list of actions to find common sequences in
328
+ * @returns an array of objects with the sequence, frequency, and length of the common sequences
329
+ */
330
+ export function findCommonSequences(actions: string[]): { sequence: string, frequency: number, length: number }[] {
331
+ const allSubstrings = new Set<string>();
332
+ actions.forEach(action => {
333
+ splitActionIntoParts(action).forEach(substring => allSubstrings.add(substring));
334
+ });
335
+
336
+ const substringCount = countSubstrings(Array.from(allSubstrings), actions);
337
+
338
+ const result: any[] = [];
339
+ substringCount.forEach((frequency, sequence) => {
340
+ result.push({ sequence, frequency, length: sequence.length });
341
+ });
342
+
343
+ return result;
344
+ }
345
+
346
+ /**
347
+ * Consolidates overlapping wildcards into their most general form
348
+ *
349
+ * For example:
350
+ * ['*Object', 'Object*', '*Object*'] will be consolidated into ['*Object*']
351
+ * ['Get*', '*Get*'] will be consolidated into ['*Get*']
352
+ *
353
+ * @param patterns the list of patterns to consolidate
354
+ * @returns the consolidated list of patterns
355
+ */
356
+ export function consolidateWildcardPatterns(patterns: string[]): string[] {
357
+ // Sort patterns to handle simpler cases first
358
+ patterns.sort((a, b) => b.length - a.length);
359
+
360
+ let consolidatedPatterns: string[] = [];
361
+ for(const pattern of patterns) {
362
+ //If it's already covered, skip it
363
+ const coveredByExistingPattern = consolidatedPatterns.some(consolidated => matchesPattern(consolidated, pattern))
364
+ if(coveredByExistingPattern) {
365
+ continue;
366
+ }
367
+
368
+ //If it subsumes any existing patterns, remove them
369
+ consolidatedPatterns = consolidatedPatterns.filter(consolidated => !matchesPattern(pattern, consolidated));
370
+
371
+ consolidatedPatterns.push(pattern);
372
+ }
373
+ return consolidatedPatterns
374
+ }
375
+
376
+ /**
377
+ * Checks a specific string against a general pattern
378
+ * @param general the general pattern, e.g. 's3:Get*'
379
+ * @param specific the specific string, e.g. 's3:GetObject'
380
+ * @returns true if the specific string matches the general pattern
381
+ */
382
+ function matchesPattern(general: string, specific: string): boolean {
383
+ const regex = new RegExp("^" + general.replace(/\*/g, ".*") + "$");
384
+ return regex.test(specific);
385
+ }
@@ -0,0 +1,72 @@
1
+ import { beforeEach } from "node:test";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { shrink } from './shrink.js';
4
+ import { shrinkJsonDocument } from "./shrink_file.js";
5
+
6
+ vi.mock('./shrink.js')
7
+
8
+ // const mockShrink =
9
+
10
+ beforeEach(() => {
11
+ vi.resetAllMocks()
12
+ })
13
+
14
+ describe('shrinkJsonDocument', () => {
15
+ it('shrinks an array of Action', async () => {
16
+ //Given a JSON document with an array of actions
17
+ const document = {
18
+ Action: ['s3:GetObject', 's3:PutObject']
19
+ }
20
+ //And a new array of actions is returned
21
+ vi.mocked(shrink).mockResolvedValue(['s3:*'])
22
+
23
+ //When shrinkJsonDocument is called
24
+ const result = await shrinkJsonDocument({}, document)
25
+
26
+ expect(result).toEqual({
27
+ Action: ['s3:*']
28
+ })
29
+ })
30
+
31
+ it('shrinks an array of NotAction nested in the document', async () => {
32
+ //Given an object with an array of NotAction nexted in the document
33
+ const document = {
34
+ statements: [
35
+ {
36
+ Resource: 'arn:aws:s3:::my_bucket',
37
+ NotAction: ['s3:GetObject', 's3:PutObject']
38
+ }
39
+ ]
40
+ }
41
+ //And a new array of actions is returned
42
+ vi.mocked(shrink).mockResolvedValue(['s3:*'])
43
+
44
+ //When shrinkJsonDocument is called
45
+ const result = await shrinkJsonDocument({}, document)
46
+
47
+ //Then the NotAction array is replaced with the new array
48
+ expect(result).toEqual({
49
+ statements: [
50
+ {
51
+ Resource: 'arn:aws:s3:::my_bucket',
52
+ NotAction: ['s3:*']
53
+ }
54
+ ]
55
+ })
56
+ })
57
+
58
+ it('does not shrink an array of Action if it is a single string', async () => {
59
+ //Given a JSON document with a string for Action
60
+ const document = {
61
+ Action: 's3:GetObject'
62
+ }
63
+
64
+ //When shrinkJsonDocument is called
65
+ const result = await shrinkJsonDocument({}, document)
66
+
67
+ //Then the document is returned unchanged
68
+ expect(result).toEqual({
69
+ Action: 's3:GetObject'
70
+ })
71
+ })
72
+ })
@@ -0,0 +1,38 @@
1
+ import { ShrinkOptions, shrink } from "./shrink.js";
2
+
3
+ /**
4
+ * Takes any JSON document and shrinks any Action or NotAction array of strings in the document.
5
+ * *MODIFIES THE DOCUMENT IN PLACE*
6
+ *
7
+ * @param options the options to use when shrinking the actions
8
+ * @param document the JSON document to expand
9
+ * @param key the key of the current node in the document
10
+ * @returns the original JSON document with any actions expanded in place
11
+ */
12
+ export async function shrinkJsonDocument(options: Partial<ShrinkOptions>, document: any, key?: string): Promise<any> {
13
+ if (key === 'Action' || key === 'NotAction') {
14
+ // if (typeof document === 'string') {
15
+ // // return shrink([document], options);
16
+ // }
17
+ if (Array.isArray(document) && document.length > 0 && typeof document[0] === 'string') {
18
+ return shrink(document, options);
19
+ }
20
+ }
21
+
22
+ if (Array.isArray(document)) {
23
+ const results = [];
24
+ for (const item of document) {
25
+ results.push(await shrinkJsonDocument(options, item));
26
+ }
27
+ return results;
28
+ }
29
+
30
+ if (typeof document === 'object' && document !== null) {
31
+ for (const key of Object.keys(document)) {
32
+ document[key] = await shrinkJsonDocument(options, document[key], key);
33
+ }
34
+ return document;
35
+ }
36
+
37
+ return document;
38
+ }
package/src/stdin.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { stdin } from 'process';
2
+ /**
3
+ * Read from stdin until the stream ends, timeout, or an error occurs
4
+ *
5
+ * @returns the string input from stdin
6
+ */
7
+ export async function readStdin(readWait: number | undefined): Promise<string> {
8
+ return new Promise((resolve, reject) => {
9
+ // If the input is not a TTY, we are most likely receiving data from a pipe.
10
+ const definitelyReceivingData = !process.stdin.isTTY
11
+ if(!readWait || readWait <= 0) {
12
+ readWait = definitelyReceivingData ? 10_000 : 20
13
+ }
14
+
15
+ let data = '';
16
+ setTimeout(() => {
17
+ if(data.length === 0) {
18
+ resolve(data)
19
+ }
20
+ }, readWait)
21
+
22
+ stdin.on('data', (chunk) => {
23
+ data += chunk;
24
+ });
25
+
26
+ stdin.on('end', () => {
27
+ resolve(data);
28
+ });
29
+
30
+ stdin.on('error', (err) => {
31
+ reject(err);
32
+ });
33
+ });
34
+ }
@@ -0,0 +1,55 @@
1
+ import { expandIamActions } from '@cloud-copilot/iam-expand';
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { validateShrinkResults } from './validate.js';
4
+ vi.mock('@cloud-copilot/iam-expand')
5
+
6
+ const mockExpandIamActions = vi.mocked(expandIamActions);
7
+
8
+
9
+ describe("validate", () => {
10
+ it('should return nothing if the actions are valid', async () => {
11
+ // Given a list of desired actions
12
+ const desiredActions = ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'];
13
+ // And a list of patterns
14
+ const patterns = ['s3:*Object'];
15
+ // And the patterns include an undesired action
16
+ mockExpandIamActions.mockResolvedValue(['s3:GetObject', 's3:PutObject', 's3:DeleteObject']);
17
+
18
+ // When the patterns are validated
19
+ const result = await validateShrinkResults(desiredActions, patterns);
20
+
21
+ // Then the validation should fail
22
+ expect(result).toBeUndefined()
23
+ })
24
+
25
+ it('should find an undesired action', async () => {
26
+ // Given a list of desired actions
27
+ const desiredActions = ['s3:GetObject', 's3:PutObject'];
28
+ // And a list of patterns
29
+ const patterns = ['s3:*Object'];
30
+ // And the patterns include an undesired action
31
+ mockExpandIamActions.mockResolvedValue(['s3:GetObject', 's3:PutObject', 's3:DeleteObject']);
32
+
33
+ // When the patterns are validated
34
+ const result = await validateShrinkResults(desiredActions, patterns);
35
+
36
+ // Then the validation should fail
37
+ expect(result).toEqual('Undesired action: s3:DeleteObject');
38
+ })
39
+
40
+ it('should find a missing action', async () => {
41
+ // Given a list of desired actions
42
+ const desiredActions = ['s3:GetObject', 's3:PutObject'];
43
+ // And a list of patterns
44
+ const patterns = ['s3:Get*'];
45
+ // And the patterns are missing an action
46
+ mockExpandIamActions.mockResolvedValue(['s3:GetObject']);
47
+
48
+ // When the patterns are validated
49
+ const result = await validateShrinkResults(desiredActions, patterns);
50
+
51
+ // Then the validation should fail
52
+ expect(result).toEqual('Missing action s3:PutObject');
53
+ })
54
+
55
+ })
@@ -0,0 +1,29 @@
1
+ import { expandIamActions } from "@cloud-copilot/iam-expand";
2
+
3
+ /**
4
+ * Checks a list of patterns against a list of desired actions to validate:
5
+ * * All desired actions are matched by the patterns
6
+ * * No undesired actions are matched by the patterns
7
+ *
8
+ * @param desiredActions The actions that should be in the list
9
+ * @param patterns The list of patterns that the algorithm has derived
10
+ * @returns the first match error if any, otherwise undefined
11
+ */
12
+ export async function validateShrinkResults(desiredActions: string[], patterns: string[]): Promise<string | undefined> {
13
+ const desiredActionSet = new Set(desiredActions);
14
+ const expandedAfterActions = await expandIamActions(patterns, {expandServiceAsterisk: true});
15
+ const expandedAfterActionSet = new Set(expandedAfterActions);
16
+ for(const afterAction of expandedAfterActions) {
17
+ if(!desiredActionSet.has(afterAction)) {
18
+ return `Undesired action: ${afterAction}`;
19
+ }
20
+ }
21
+
22
+ for(const desiredAction of desiredActions) {
23
+ if(!expandedAfterActionSet.has(desiredAction)) {
24
+ return `Missing action ${desiredAction}`
25
+ }
26
+ }
27
+
28
+ return undefined;
29
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+
4
+ "include": ["src/**/*"],
5
+ "exclude": ["**/*.test.ts"],
6
+
7
+ "compilerOptions": {
8
+ "rootDir": "src",
9
+ "outDir": "dist/cjs",
10
+ "skipLibCheck": false
11
+ }
12
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+
4
+ "include": ["src/**/*"],
5
+ "exclude": ["**/*.test.ts"],
6
+
7
+ "compilerOptions": {
8
+ "target": "ES2020",
9
+ "module": "ES2020",
10
+ "moduleResolution": "node",
11
+ "rootDir": "src",
12
+ "outDir": "dist/esm",
13
+ "skipLibCheck": false
14
+ }
15
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "target": "es2022",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "sourceMap": true,
8
+ "strict": true,
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "lib": ["es2023", "DOM"],
12
+ "noUnusedLocals": false,
13
+ "noUnusedParameters": false,
14
+ "noImplicitReturns": true,
15
+ "noFallthroughCasesInSwitch": false,
16
+ "experimentalDecorators": true,
17
+ "emitDecoratorMetadata": true,
18
+ "esModuleInterop": false,
19
+ "forceConsistentCasingInFileNames": true,
20
+ "skipLibCheck": true,
21
+ },
22
+ "exclude": ["tests", "test", "dist", "bin", "**/bin", "**/dist", "node_modules", "cdk.out"],
23
+ }