p6-cdk-s3-protector 0.0.1

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.
@@ -0,0 +1,293 @@
1
+ import type { GetBucketAclOutput, Grant } from '@aws-sdk/client-s3'
2
+ import type { Context } from 'aws-lambda'
3
+ import type { Logger } from 'winston'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { S3 } from '@aws-sdk/client-s3'
7
+ import { PutPublicAccessBlockCommand, S3ControlClient } from '@aws-sdk/client-s3-control'
8
+ import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'
9
+ import winston from 'winston'
10
+
11
+ // Configure the logger
12
+ const logger: Logger = winston.createLogger({
13
+ level: 'info',
14
+ format: winston.format.json(),
15
+ defaultMeta: { service: 'user-service' },
16
+ transports: [
17
+ new winston.transports.Console(),
18
+ ],
19
+ })
20
+
21
+ const s3Client = new S3({})
22
+ const s3ControlClient = new S3ControlClient({})
23
+ const stsClient = new STSClient({})
24
+
25
+ interface S3Event {
26
+ detail: {
27
+ requestParameters: {
28
+ [key: string]: any
29
+ }
30
+ eventName: string
31
+ errorCode?: string
32
+ errorMessage?: string
33
+ }
34
+ }
35
+
36
+ function p6ShortCircuitShould(event: S3Event): boolean {
37
+ if (event.detail.requestParameters['x-amz-acl']) {
38
+ logger.info('ACL is currently %s', event.detail.requestParameters['x-amz-acl'][0])
39
+ if (event.detail.requestParameters['x-amz-acl'][0] === 'private') {
40
+ logger.info('ACL is already private. Ending.')
41
+ return true
42
+ }
43
+ }
44
+ return false
45
+ }
46
+
47
+ function p6LoopPrevent(event: S3Event): boolean {
48
+ if (event.detail.errorCode || event.detail.errorMessage) {
49
+ logger.info('Previous API call resulted in an error. Ending')
50
+ return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ async function p6S3BucketAclGet(event: S3Event): Promise<GetBucketAclOutput | false> {
56
+ try {
57
+ const bucketName = event.detail.requestParameters.bucketName
58
+ logger.info('Describing the current ACL: s3://%s', bucketName)
59
+ const bucketAcl = await s3Client.getBucketAcl({ Bucket: bucketName })
60
+ logger.info(JSON.stringify(bucketAcl))
61
+ return bucketAcl
62
+ }
63
+ catch (err) {
64
+ logger.error('Error was: {%s} Manual followup recommended', err)
65
+ return false
66
+ }
67
+ }
68
+
69
+ function p6LogDeliveryPreserve(bucketAcl: GetBucketAclOutput): [string, Grant[]] {
70
+ let uriList = ''
71
+ const preserveLogDelivery: Grant[] = []
72
+
73
+ for (const grant of bucketAcl.Grants || []) {
74
+ if (grant.Grantee?.URI) {
75
+ logger.info('Found Grant: %s', JSON.stringify(grant))
76
+ uriList += grant.Grantee.URI
77
+ if (grant.Grantee.URI.includes('LogDelivery')) {
78
+ preserveLogDelivery.push(grant)
79
+ }
80
+ }
81
+ }
82
+
83
+ return [uriList, preserveLogDelivery]
84
+ }
85
+
86
+ function p6S3BucketAclViolation(uriList: string): boolean {
87
+ if (uriList.includes('AllUsers') || uriList.includes('AuthenticatedUsers')) {
88
+ logger.info('Violation found. Grant ACL greater than Private')
89
+ return true
90
+ }
91
+ logger.info('ACL is correctly already private')
92
+ return false
93
+ }
94
+
95
+ async function p6S3BucketAclCorrect(bucketAcl: GetBucketAclOutput, preserveLogDelivery: Array<Grant> | false): Promise<void> {
96
+ logger.info('Attempting Automatic Resolution')
97
+ try {
98
+ if (preserveLogDelivery) {
99
+ logger.info('ACL resetting ACL to LogDelivery')
100
+ logger.info('Preserve was: %s', JSON.stringify(preserveLogDelivery))
101
+
102
+ const aclString = {
103
+ Grants: preserveLogDelivery,
104
+ Owner: bucketAcl.Owner,
105
+ }
106
+
107
+ const response = await s3Client.putBucketAcl({
108
+ Bucket: bucketAcl?.Owner?.ID,
109
+ AccessControlPolicy: aclString,
110
+ })
111
+
112
+ logger.info(JSON.stringify(response))
113
+ if (response.$metadata.httpStatusCode === 200) {
114
+ logger.info('Reverted to only contain LogDelivery')
115
+ }
116
+ else {
117
+ logger.error('PutBucketACL failed. Manual followup')
118
+ }
119
+ }
120
+ else {
121
+ logger.info('ACL resetting ACL to Private')
122
+ const response = await s3Client.putBucketAcl({
123
+ Bucket: bucketAcl?.Owner?.ID,
124
+ ACL: 'private',
125
+ })
126
+
127
+ logger.info(JSON.stringify(response))
128
+ if (response.$metadata.httpStatusCode === 200) {
129
+ logger.info('Bucket ACL has been changed to Private')
130
+ }
131
+ else {
132
+ logger.error('PutBucketACL failed. Manual followup')
133
+ }
134
+ }
135
+ }
136
+ catch (err) {
137
+ logger.info('Unable to resolve violation automatically')
138
+ logger.info('Error was: %s', err)
139
+ }
140
+ }
141
+
142
+ async function p6S3PublicBucketAcl(event: S3Event): Promise<boolean> {
143
+ if (p6ShortCircuitShould(event)) {
144
+ return true
145
+ }
146
+
147
+ if (p6LoopPrevent(event)) {
148
+ return true
149
+ }
150
+
151
+ const bucketAcl = await p6S3BucketAclGet(event)
152
+ if (!bucketAcl) {
153
+ return false
154
+ }
155
+
156
+ const [uriList, preserveLogDelivery] = p6LogDeliveryPreserve(bucketAcl)
157
+
158
+ if (p6S3BucketAclViolation(uriList)) {
159
+ await p6S3BucketAclCorrect(bucketAcl, preserveLogDelivery)
160
+ return true
161
+ }
162
+
163
+ return false
164
+ }
165
+
166
+ async function awsIsPrivate(bucket: string, key: string): Promise<boolean> {
167
+ logger.info('Describing the ACL: s3://%s/%s', bucket, key)
168
+ const acl = await s3Client.getObjectAcl({ Bucket: bucket, Key: key })
169
+
170
+ if (acl.Grants!.length > 1) {
171
+ logger.info('Greater than one Grant')
172
+ return false
173
+ }
174
+
175
+ const ownerId = acl.Owner?.ID
176
+ const granteeId = acl.Grants![0].Grantee?.ID
177
+ if (ownerId !== granteeId) {
178
+ logger.info('owner:[%s], grantee[%s] do not match', ownerId, granteeId)
179
+ return false
180
+ }
181
+
182
+ return true
183
+ }
184
+
185
+ async function awsMakePrivate(bucket: string, key: string): Promise<void> {
186
+ logger.info('Making s3://%s/%s private', bucket, key)
187
+ await s3Client.putObjectAcl({ Bucket: bucket, Key: key, ACL: 'private' })
188
+ }
189
+
190
+ async function p6S3PublicBucketObjectAcl(event: S3Event): Promise<void> {
191
+ const key = event.detail.requestParameters.key
192
+ const bucket = event.detail.requestParameters.bucketName
193
+
194
+ if (!(await awsIsPrivate(bucket, key))) {
195
+ await awsMakePrivate(bucket, key)
196
+ }
197
+ }
198
+
199
+ async function p6S3PublicBucketAccessBlock(event: S3Event): Promise<void> {
200
+ const pbc = event.detail.requestParameters.PublicAccessBlockConfiguration
201
+ logger.info(JSON.stringify(pbc))
202
+
203
+ if (!pbc.RestrictPublicBuckets || !pbc.BlockPublicPolicy || !pbc.BlockPublicAcls || !pbc.IgnorePublicAcls) {
204
+ const bucket = event.detail.requestParameters.bucketName
205
+ logger.info('s3://%s now not private, fixing...', bucket)
206
+
207
+ const response = await s3Client.putPublicAccessBlock({
208
+ Bucket: bucket,
209
+ PublicAccessBlockConfiguration: {
210
+ BlockPublicAcls: true,
211
+ IgnorePublicAcls: true,
212
+ BlockPublicPolicy: true,
213
+ RestrictPublicBuckets: true,
214
+ },
215
+ })
216
+
217
+ logger.info(JSON.stringify(response))
218
+ }
219
+ }
220
+
221
+ async function p6S3PublicAccessBlock(event: S3Event): Promise<void> {
222
+ const pbc = event.detail.requestParameters.PublicAccessBlockConfiguration
223
+ logger.info(JSON.stringify(pbc))
224
+
225
+ if (!pbc.RestrictPublicBuckets || !pbc.BlockPublicPolicy || !pbc.BlockPublicAcls || !pbc.IgnorePublicAcls) {
226
+ const command = new GetCallerIdentityCommand({})
227
+ const account = await stsClient.send(command)
228
+ logger.info('%s', account)
229
+
230
+ const response = await s3ControlClient.send(new PutPublicAccessBlockCommand({
231
+ PublicAccessBlockConfiguration: {
232
+ BlockPublicAcls: true,
233
+ IgnorePublicAcls: true,
234
+ BlockPublicPolicy: true,
235
+ RestrictPublicBuckets: true,
236
+ },
237
+ AccountId: account.Account,
238
+ }))
239
+
240
+ logger.info(JSON.stringify(response))
241
+ }
242
+ }
243
+
244
+ async function p6S3PublicFusebox(event: S3Event): Promise<boolean> {
245
+ if (!event.detail || !event.detail.eventName) {
246
+ return false
247
+ }
248
+
249
+ const events = [
250
+ 'PutBucketAcl',
251
+ 'PutObjectAcl',
252
+ 'PutBucketPublicAccessBlock',
253
+ 'PutAccountPublicAccessBlock',
254
+ ]
255
+
256
+ const eventName = event.detail.eventName
257
+ if (events.includes(eventName)) {
258
+ logger.info('======================================================================================')
259
+ logger.info('eventName: %s', eventName)
260
+ }
261
+
262
+ if (eventName === 'PutBucketAcl') {
263
+ await p6S3PublicBucketAcl(event)
264
+ }
265
+ else if (eventName === 'PutObjectAcl') {
266
+ await p6S3PublicBucketObjectAcl(event)
267
+ }
268
+ else if (eventName === 'PutBucketPublicAccessBlock') {
269
+ await p6S3PublicBucketAccessBlock(event)
270
+ }
271
+ else if (eventName === 'PutAccountPublicAccessBlock') {
272
+ await p6S3PublicAccessBlock(event)
273
+ }
274
+
275
+ return true
276
+ }
277
+
278
+ export async function handler(event: S3Event, _context?: Context): Promise<boolean> {
279
+ await p6S3PublicFusebox(event)
280
+ return true
281
+ }
282
+
283
+ export async function main(): Promise<void> {
284
+ logger.debug('Reading fixtures/putBucketAcl.json')
285
+ const data = JSON.parse(fs.readFileSync(path.resolve('fixtures/putBucketAcl.json'), 'utf8'))
286
+
287
+ logger.debug('handler()')
288
+ await handler(data)
289
+ }
290
+
291
+ if (require.main === module) {
292
+ main()
293
+ }
@@ -0,0 +1,34 @@
1
+ import type { Construct } from 'constructs'
2
+ import * as cdk from 'aws-cdk-lib'
3
+ import * as lambda from 'aws-cdk-lib/aws-lambda'
4
+ import * as lambdajs from 'aws-cdk-lib/aws-lambda-nodejs'
5
+ import * as cr from 'aws-cdk-lib/custom-resources'
6
+ import * as floyd from 'cdk-iam-floyd'
7
+
8
+ export class P6CDKS3Protector extends cdk.Resource {
9
+ constructor(scope: Construct, id: string) {
10
+ super(scope, id)
11
+
12
+ const policy = new floyd.Statement.S3().allow().toPutObject().toPutObjectAcl()
13
+
14
+ const onEvent = new lambdajs.NodejsFunction(this, 'p6CDKS3Protector', {
15
+ runtime: lambda.Runtime.NODEJS_20_X,
16
+ timeout: cdk.Duration.seconds(5), // Adjust timeout if necessary
17
+ tracing: lambda.Tracing.ACTIVE,
18
+ bundling: {
19
+ minify: true,
20
+ externalModules: ['aws-sdk'],
21
+ },
22
+ })
23
+
24
+ onEvent.addToRolePolicy(policy)
25
+
26
+ const provider = new cr.Provider(this, 'P6CDKS3Protector/Provider', {
27
+ onEventHandler: onEvent,
28
+ })
29
+
30
+ new cdk.CustomResource(this, 'P6CDKS3Protector/CR', {
31
+ serviceToken: provider.serviceToken,
32
+ })
33
+ }
34
+ }
@@ -0,0 +1,21 @@
1
+ import * as cdk from 'aws-cdk-lib'
2
+ import { Template } from 'aws-cdk-lib/assertions'
3
+
4
+ import { P6CDKS3Protector } from '../src'
5
+
6
+ it('p6CDK3Protector components', () => {
7
+ // GIVEN
8
+ const app = new cdk.App()
9
+ const stack = new cdk.Stack(app, 'MyStack')
10
+
11
+ // WHEN
12
+ new P6CDKS3Protector(stack, 'p6-cdk-s3-protector')
13
+
14
+ // THEN
15
+ const template = Template.fromStack(stack)
16
+ template.hasResourceProperties('AWS::Lambda::Function', {
17
+ Handler: 'index.handler',
18
+ Runtime: 'nodejs20.x',
19
+ })
20
+ template.resourceCountIs('AWS::Lambda::Function', 2) // Custom Resource Handler counts too
21
+ })
@@ -0,0 +1,40 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declarationMap": false,
4
+ "inlineSourceMap": true,
5
+ "inlineSources": true,
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "experimentalDecorators": true,
9
+ "lib": [
10
+ "es2022"
11
+ ],
12
+ "esModuleInterop": true,
13
+ "module": "commonjs",
14
+ "noEmitOnError": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noImplicitAny": true,
17
+ "noImplicitReturns": true,
18
+ "noImplicitThis": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "resolveJsonModule": true,
22
+ "skipLibCheck": true,
23
+ "strict": true,
24
+ "strictNullChecks": true,
25
+ "strictPropertyInitialization": true,
26
+ "stripInternal": false,
27
+ "target": "es2022",
28
+ "composite": false,
29
+ "outDir": "lib",
30
+ "rootDir": "src"
31
+ },
32
+ "include": [
33
+ "src/**/*.ts"
34
+ ],
35
+ "exclude": [
36
+ "tsconfig.json",
37
+ "node_modules",
38
+ ".types-compat"
39
+ ]
40
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "compilerOptions": {
3
+ "rootDir": "src",
4
+ "outDir": "lib",
5
+ "alwaysStrict": true,
6
+ "declaration": true,
7
+ "esModuleInterop": true,
8
+ "experimentalDecorators": true,
9
+ "inlineSourceMap": true,
10
+ "inlineSources": true,
11
+ "lib": [
12
+ "es2019"
13
+ ],
14
+ "module": "CommonJS",
15
+ "noEmitOnError": false,
16
+ "noFallthroughCasesInSwitch": true,
17
+ "noImplicitAny": true,
18
+ "noImplicitReturns": true,
19
+ "noImplicitThis": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "resolveJsonModule": true,
23
+ "strict": true,
24
+ "strictNullChecks": true,
25
+ "strictPropertyInitialization": true,
26
+ "stripInternal": true,
27
+ "target": "ES2019"
28
+ },
29
+ "include": [
30
+ "src/**/*.ts"
31
+ ],
32
+ "exclude": [
33
+ "cdk.out"
34
+ ]
35
+ }