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.
- package/.jsii +3956 -0
- package/API.md +203 -0
- package/LICENSE +202 -0
- package/README.md +27 -0
- package/bin/clean_tgz.bash +23 -0
- package/bin/release.bash +12 -0
- package/bin/version.bash +43 -0
- package/bin/visualize.ts +15 -0
- package/cdk.json +3 -0
- package/jest.config.js +11 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +18 -0
- package/lib/p6cdks3protector.d.ts +5 -0
- package/lib/p6cdks3protector.js +57 -0
- package/lib/p6cdks3protector.p6CDKS3Protector.d.ts +14 -0
- package/lib/p6cdks3protector.p6CDKS3Protector.js +243 -0
- package/package.json +141 -0
- package/src/index.ts +1 -0
- package/src/p6cdks3protector.p6CDKS3Protector.ts +293 -0
- package/src/p6cdks3protector.ts +34 -0
- package/test/p6-cdk-s3-protector.test.ts +21 -0
- package/tsconfig.dev.json +40 -0
- package/tsconfig.json +35 -0
|
@@ -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
|
+
}
|