@intentius/chant-lexicon-aws 0.0.8 → 0.0.9
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/dist/integrity.json +25 -10
- package/dist/manifest.json +1 -1
- package/dist/meta.json +5743 -896
- package/dist/rules/cf-refs.ts +99 -0
- package/dist/rules/ext001.ts +30 -21
- package/dist/rules/hardcoded-region.ts +1 -0
- package/dist/rules/iam-wildcard.ts +1 -0
- package/dist/rules/s3-encryption.ts +1 -0
- package/dist/rules/waw016.ts +86 -0
- package/dist/rules/waw017.ts +53 -0
- package/dist/rules/waw018.ts +71 -0
- package/dist/rules/waw019.ts +82 -0
- package/dist/rules/waw020.ts +64 -0
- package/dist/rules/waw021.ts +53 -0
- package/dist/rules/waw022.ts +43 -0
- package/dist/rules/waw023.ts +47 -0
- package/dist/rules/waw024.ts +54 -0
- package/dist/rules/waw025.ts +43 -0
- package/dist/rules/waw026.ts +46 -0
- package/dist/rules/waw027.ts +50 -0
- package/dist/rules/waw028.ts +47 -0
- package/dist/rules/waw029.ts +62 -0
- package/dist/rules/waw030.ts +246 -0
- package/dist/skills/chant-aws.md +388 -30
- package/dist/types/index.d.ts +1552 -1528
- package/package.json +2 -2
- package/src/actions/actions.test.ts +75 -0
- package/src/actions/dynamodb.ts +36 -0
- package/src/actions/ecr.ts +9 -0
- package/src/actions/ecs.ts +5 -0
- package/src/actions/iam.ts +3 -0
- package/src/actions/index.ts +9 -0
- package/src/actions/lambda.ts +11 -0
- package/src/actions/logs.ts +4 -0
- package/src/actions/s3.ts +34 -0
- package/src/actions/sns.ts +5 -0
- package/src/actions/sqs.ts +15 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +2 -2
- package/src/codegen/docs-links.test.ts +143 -0
- package/src/codegen/docs.ts +247 -132
- package/src/codegen/generate-lexicon.ts +8 -0
- package/src/codegen/generate-typescript.ts +25 -1
- package/src/composites/composites.test.ts +442 -0
- package/src/composites/fargate-alb.ts +253 -0
- package/src/composites/index.ts +20 -0
- package/src/composites/lambda-api.ts +20 -0
- package/src/composites/lambda-dynamodb.ts +64 -0
- package/src/composites/lambda-eventbridge.ts +36 -0
- package/src/composites/lambda-function.ts +76 -0
- package/src/composites/lambda-s3.ts +72 -0
- package/src/composites/lambda-sns.ts +30 -0
- package/src/composites/lambda-sqs.ts +44 -0
- package/src/composites/scheduled-lambda.ts +37 -0
- package/src/composites/vpc-default.ts +148 -0
- package/src/default-tags.test.ts +38 -0
- package/src/default-tags.ts +77 -0
- package/src/generated/index.d.ts +1552 -1528
- package/src/generated/lexicon-aws.json +5743 -896
- package/src/import/roundtrip-fixtures.test.ts +1 -1
- package/src/index.ts +21 -0
- package/src/integration.test.ts +71 -0
- package/src/intrinsics.ts +24 -13
- package/src/lint/post-synth/cf-refs.ts +99 -0
- package/src/lint/post-synth/ext001.test.ts +214 -31
- package/src/lint/post-synth/ext001.ts +30 -21
- package/src/lint/post-synth/waw013.test.ts +120 -0
- package/src/lint/post-synth/waw014.test.ts +121 -0
- package/src/lint/post-synth/waw015.test.ts +147 -0
- package/src/lint/post-synth/waw016.test.ts +141 -0
- package/src/lint/post-synth/waw016.ts +86 -0
- package/src/lint/post-synth/waw017.test.ts +130 -0
- package/src/lint/post-synth/waw017.ts +53 -0
- package/src/lint/post-synth/waw018.test.ts +109 -0
- package/src/lint/post-synth/waw018.ts +71 -0
- package/src/lint/post-synth/waw019.test.ts +138 -0
- package/src/lint/post-synth/waw019.ts +82 -0
- package/src/lint/post-synth/waw020.test.ts +125 -0
- package/src/lint/post-synth/waw020.ts +64 -0
- package/src/lint/post-synth/waw021.test.ts +81 -0
- package/src/lint/post-synth/waw021.ts +53 -0
- package/src/lint/post-synth/waw022.test.ts +54 -0
- package/src/lint/post-synth/waw022.ts +43 -0
- package/src/lint/post-synth/waw023.test.ts +53 -0
- package/src/lint/post-synth/waw023.ts +47 -0
- package/src/lint/post-synth/waw024.test.ts +64 -0
- package/src/lint/post-synth/waw024.ts +54 -0
- package/src/lint/post-synth/waw025.test.ts +42 -0
- package/src/lint/post-synth/waw025.ts +43 -0
- package/src/lint/post-synth/waw026.test.ts +54 -0
- package/src/lint/post-synth/waw026.ts +46 -0
- package/src/lint/post-synth/waw027.test.ts +63 -0
- package/src/lint/post-synth/waw027.ts +50 -0
- package/src/lint/post-synth/waw028.test.ts +68 -0
- package/src/lint/post-synth/waw028.ts +47 -0
- package/src/lint/post-synth/waw029.test.ts +179 -0
- package/src/lint/post-synth/waw029.ts +62 -0
- package/src/lint/post-synth/waw030.test.ts +800 -0
- package/src/lint/post-synth/waw030.ts +246 -0
- package/src/lint/rules/hardcoded-region.ts +1 -0
- package/src/lint/rules/iam-wildcard.ts +1 -0
- package/src/lint/rules/s3-encryption.ts +1 -0
- package/src/lsp/hover.ts +15 -0
- package/src/nested-stack-integration.test.ts +100 -0
- package/src/nested-stack.ts +1 -1
- package/src/plugin.ts +468 -36
- package/src/serializer.test.ts +330 -2
- package/src/serializer.ts +62 -1
- package/src/spec/fetch.ts +10 -0
- package/src/spec/parse.test.ts +141 -0
- package/src/spec/parse.ts +40 -0
- package/src/taggable.ts +44 -0
- package/src/testdata/nested-stacks/app.ts +26 -0
- package/src/testdata/nested-stacks/network/outputs.ts +17 -0
- package/src/testdata/nested-stacks/network/security.ts +17 -0
- package/src/testdata/nested-stacks/network/vpc.ts +54 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw017, checkMissingTags } from "./waw017";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Synthetic taggable set — no disk dependency. */
|
|
10
|
+
const taggable = new Set(["AWS::S3::Bucket", "AWS::Lambda::Function"]);
|
|
11
|
+
|
|
12
|
+
describe("WAW017: Missing Tags on Taggable Resource", () => {
|
|
13
|
+
test("check metadata", () => {
|
|
14
|
+
expect(waw017.id).toBe("WAW017");
|
|
15
|
+
expect(waw017.description).toContain("tags");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("emits warning for taggable resource without Tags", () => {
|
|
19
|
+
const ctx = makeCtx({
|
|
20
|
+
Resources: {
|
|
21
|
+
MyBucket: {
|
|
22
|
+
Type: "AWS::S3::Bucket",
|
|
23
|
+
Properties: { BucketName: "my-bucket" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WAW017");
|
|
30
|
+
expect(diags[0].severity).toBe("warning");
|
|
31
|
+
expect(diags[0].message).toContain("tagging");
|
|
32
|
+
expect(diags[0].message).toContain("MyBucket");
|
|
33
|
+
expect(diags[0].entity).toBe("MyBucket");
|
|
34
|
+
expect(diags[0].lexicon).toBe("aws");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("no diagnostic when Tags are present", () => {
|
|
38
|
+
const ctx = makeCtx({
|
|
39
|
+
Resources: {
|
|
40
|
+
MyBucket: {
|
|
41
|
+
Type: "AWS::S3::Bucket",
|
|
42
|
+
Properties: {
|
|
43
|
+
BucketName: "my-bucket",
|
|
44
|
+
Tags: [{ Key: "Env", Value: "prod" }],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
50
|
+
expect(diags).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no diagnostic for non-taggable resource type", () => {
|
|
54
|
+
const ctx = makeCtx({
|
|
55
|
+
Resources: {
|
|
56
|
+
MyCustom: {
|
|
57
|
+
Type: "Custom::MyResource",
|
|
58
|
+
Properties: { Foo: "bar" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
63
|
+
expect(diags).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("no diagnostic on empty template", () => {
|
|
67
|
+
const ctx = makeCtx({ Resources: {} });
|
|
68
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
69
|
+
expect(diags).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("handles resource with no Properties (still flags missing Tags)", () => {
|
|
73
|
+
const ctx = makeCtx({
|
|
74
|
+
Resources: {
|
|
75
|
+
MyBucket: { Type: "AWS::S3::Bucket" },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
79
|
+
expect(diags).toHaveLength(1);
|
|
80
|
+
expect(diags[0].message).toContain("MyBucket");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns empty when taggable set is empty", () => {
|
|
84
|
+
const ctx = makeCtx({
|
|
85
|
+
Resources: {
|
|
86
|
+
MyBucket: {
|
|
87
|
+
Type: "AWS::S3::Bucket",
|
|
88
|
+
Properties: { BucketName: "test" },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const diags = checkMissingTags(ctx, new Set());
|
|
93
|
+
expect(diags).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("flags multiple taggable resources missing Tags", () => {
|
|
97
|
+
const ctx = makeCtx({
|
|
98
|
+
Resources: {
|
|
99
|
+
Bucket: {
|
|
100
|
+
Type: "AWS::S3::Bucket",
|
|
101
|
+
Properties: { BucketName: "b" },
|
|
102
|
+
},
|
|
103
|
+
Func: {
|
|
104
|
+
Type: "AWS::Lambda::Function",
|
|
105
|
+
Properties: { FunctionName: "f" },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
110
|
+
expect(diags).toHaveLength(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("only flags resources without Tags, not those with", () => {
|
|
114
|
+
const ctx = makeCtx({
|
|
115
|
+
Resources: {
|
|
116
|
+
Tagged: {
|
|
117
|
+
Type: "AWS::S3::Bucket",
|
|
118
|
+
Properties: { Tags: [{ Key: "k", Value: "v" }] },
|
|
119
|
+
},
|
|
120
|
+
Untagged: {
|
|
121
|
+
Type: "AWS::S3::Bucket",
|
|
122
|
+
Properties: { BucketName: "b" },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
const diags = checkMissingTags(ctx, taggable);
|
|
127
|
+
expect(diags).toHaveLength(1);
|
|
128
|
+
expect(diags[0].entity).toBe("Untagged");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW017: Missing Tags on Taggable Resource
|
|
3
|
+
*
|
|
4
|
+
* Flags taggable resources that have no Tags property set.
|
|
5
|
+
* Encourages adding tags for cost allocation and compliance.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { parseCFTemplate } from "./cf-refs";
|
|
10
|
+
import { loadTaggableResources } from "../../taggable";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Core detection logic — exported for direct testing with synthetic data.
|
|
14
|
+
*/
|
|
15
|
+
export function checkMissingTags(
|
|
16
|
+
ctx: PostSynthContext,
|
|
17
|
+
taggable: Set<string>,
|
|
18
|
+
): PostSynthDiagnostic[] {
|
|
19
|
+
if (taggable.size === 0) return [];
|
|
20
|
+
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
24
|
+
const template = parseCFTemplate(output);
|
|
25
|
+
if (!template?.Resources) continue;
|
|
26
|
+
|
|
27
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
28
|
+
if (!taggable.has(resource.Type)) continue;
|
|
29
|
+
|
|
30
|
+
const props = resource.Properties ?? {};
|
|
31
|
+
if (!("Tags" in props)) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "WAW017",
|
|
34
|
+
severity: "warning",
|
|
35
|
+
message: `Resource "${logicalId}" (${resource.Type}) supports tagging but has no Tags — consider adding tags for cost allocation and compliance`,
|
|
36
|
+
entity: logicalId,
|
|
37
|
+
lexicon: "aws",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return diagnostics;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const waw017: PostSynthCheck = {
|
|
47
|
+
id: "WAW017",
|
|
48
|
+
description: "Missing tags on taggable resource — suggests adding tags for cost allocation and compliance",
|
|
49
|
+
|
|
50
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
51
|
+
return checkMissingTags(ctx, loadTaggableResources());
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw018, checkS3PublicAccess } from "./waw018";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW018: S3 Public Access Not Blocked", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw018.id).toBe("WAW018");
|
|
12
|
+
expect(waw018.description).toContain("public access");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("flags bucket missing PublicAccessBlockConfiguration", () => {
|
|
16
|
+
const ctx = makeCtx({
|
|
17
|
+
Resources: {
|
|
18
|
+
MyBucket: {
|
|
19
|
+
Type: "AWS::S3::Bucket",
|
|
20
|
+
Properties: { BucketName: "test" },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const diags = checkS3PublicAccess(ctx);
|
|
25
|
+
expect(diags).toHaveLength(1);
|
|
26
|
+
expect(diags[0].checkId).toBe("WAW018");
|
|
27
|
+
expect(diags[0].severity).toBe("error");
|
|
28
|
+
expect(diags[0].entity).toBe("MyBucket");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("flags bucket with a flag set to false", () => {
|
|
32
|
+
const ctx = makeCtx({
|
|
33
|
+
Resources: {
|
|
34
|
+
MyBucket: {
|
|
35
|
+
Type: "AWS::S3::Bucket",
|
|
36
|
+
Properties: {
|
|
37
|
+
PublicAccessBlockConfiguration: {
|
|
38
|
+
BlockPublicAcls: true,
|
|
39
|
+
BlockPublicPolicy: false,
|
|
40
|
+
IgnorePublicAcls: true,
|
|
41
|
+
RestrictPublicBuckets: true,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const diags = checkS3PublicAccess(ctx);
|
|
48
|
+
expect(diags).toHaveLength(1);
|
|
49
|
+
expect(diags[0].message).toContain("BlockPublicPolicy");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("no diagnostic when all flags are true", () => {
|
|
53
|
+
const ctx = makeCtx({
|
|
54
|
+
Resources: {
|
|
55
|
+
MyBucket: {
|
|
56
|
+
Type: "AWS::S3::Bucket",
|
|
57
|
+
Properties: {
|
|
58
|
+
PublicAccessBlockConfiguration: {
|
|
59
|
+
BlockPublicAcls: true,
|
|
60
|
+
BlockPublicPolicy: true,
|
|
61
|
+
IgnorePublicAcls: true,
|
|
62
|
+
RestrictPublicBuckets: true,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const diags = checkS3PublicAccess(ctx);
|
|
69
|
+
expect(diags).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("no diagnostic for non-S3 resources", () => {
|
|
73
|
+
const ctx = makeCtx({
|
|
74
|
+
Resources: {
|
|
75
|
+
MyFunc: {
|
|
76
|
+
Type: "AWS::Lambda::Function",
|
|
77
|
+
Properties: { FunctionName: "test" },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const diags = checkS3PublicAccess(ctx);
|
|
82
|
+
expect(diags).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("skips intrinsic value in PublicAccessBlockConfiguration", () => {
|
|
86
|
+
const ctx = makeCtx({
|
|
87
|
+
Resources: {
|
|
88
|
+
MyBucket: {
|
|
89
|
+
Type: "AWS::S3::Bucket",
|
|
90
|
+
Properties: {
|
|
91
|
+
PublicAccessBlockConfiguration: { Ref: "PublicAccessParam" },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const diags = checkS3PublicAccess(ctx);
|
|
97
|
+
expect(diags).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("handles missing Properties", () => {
|
|
101
|
+
const ctx = makeCtx({
|
|
102
|
+
Resources: {
|
|
103
|
+
MyBucket: { Type: "AWS::S3::Bucket" },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const diags = checkS3PublicAccess(ctx);
|
|
107
|
+
expect(diags).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW018: S3 Public Access Not Blocked
|
|
3
|
+
*
|
|
4
|
+
* Flags S3 buckets missing PublicAccessBlockConfiguration or with any flag set to false.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { parseCFTemplate, isIntrinsic } from "./cf-refs";
|
|
9
|
+
|
|
10
|
+
const REQUIRED_FLAGS = [
|
|
11
|
+
"BlockPublicAcls",
|
|
12
|
+
"BlockPublicPolicy",
|
|
13
|
+
"IgnorePublicAcls",
|
|
14
|
+
"RestrictPublicBuckets",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export function checkS3PublicAccess(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
21
|
+
const template = parseCFTemplate(output);
|
|
22
|
+
if (!template?.Resources) continue;
|
|
23
|
+
|
|
24
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
25
|
+
if (resource.Type !== "AWS::S3::Bucket") continue;
|
|
26
|
+
|
|
27
|
+
const props = resource.Properties ?? {};
|
|
28
|
+
const pab = props.PublicAccessBlockConfiguration;
|
|
29
|
+
|
|
30
|
+
if (!pab) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WAW018",
|
|
33
|
+
severity: "error",
|
|
34
|
+
message: `S3 bucket "${logicalId}" is missing PublicAccessBlockConfiguration — all public access should be blocked`,
|
|
35
|
+
entity: logicalId,
|
|
36
|
+
lexicon: "aws",
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isIntrinsic(pab)) continue;
|
|
42
|
+
|
|
43
|
+
if (typeof pab === "object" && pab !== null) {
|
|
44
|
+
const config = pab as Record<string, unknown>;
|
|
45
|
+
for (const flag of REQUIRED_FLAGS) {
|
|
46
|
+
const value = config[flag];
|
|
47
|
+
if (value === false) {
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
checkId: "WAW018",
|
|
50
|
+
severity: "error",
|
|
51
|
+
message: `S3 bucket "${logicalId}" has ${flag} set to false — all public access should be blocked`,
|
|
52
|
+
entity: logicalId,
|
|
53
|
+
lexicon: "aws",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return diagnostics;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const waw018: PostSynthCheck = {
|
|
65
|
+
id: "WAW018",
|
|
66
|
+
description: "S3 bucket missing public access block — all public access should be blocked",
|
|
67
|
+
|
|
68
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
69
|
+
return checkS3PublicAccess(ctx);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw019, checkUnrestrictedIngress } from "./waw019";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW019: Security Group Unrestricted Ingress", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw019.id).toBe("WAW019");
|
|
12
|
+
expect(waw019.description).toContain("ingress");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("flags SG with 0.0.0.0/0 on port 22", () => {
|
|
16
|
+
const ctx = makeCtx({
|
|
17
|
+
Resources: {
|
|
18
|
+
MySG: {
|
|
19
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
20
|
+
Properties: {
|
|
21
|
+
SecurityGroupIngress: [
|
|
22
|
+
{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].checkId).toBe("WAW019");
|
|
31
|
+
expect(diags[0].severity).toBe("error");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("flags SG with ::/0 on port 3389", () => {
|
|
35
|
+
const ctx = makeCtx({
|
|
36
|
+
Resources: {
|
|
37
|
+
MySG: {
|
|
38
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
39
|
+
Properties: {
|
|
40
|
+
SecurityGroupIngress: [
|
|
41
|
+
{ IpProtocol: "tcp", FromPort: 3389, ToPort: 3389, CidrIpv6: "::/0" },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
48
|
+
expect(diags).toHaveLength(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("flags wide port range containing sensitive port", () => {
|
|
52
|
+
const ctx = makeCtx({
|
|
53
|
+
Resources: {
|
|
54
|
+
MySG: {
|
|
55
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
56
|
+
Properties: {
|
|
57
|
+
SecurityGroupIngress: [
|
|
58
|
+
{ IpProtocol: "tcp", FromPort: 0, ToPort: 65535, CidrIp: "0.0.0.0/0" },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
65
|
+
expect(diags).toHaveLength(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no diagnostic for restricted CIDR", () => {
|
|
69
|
+
const ctx = makeCtx({
|
|
70
|
+
Resources: {
|
|
71
|
+
MySG: {
|
|
72
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
73
|
+
Properties: {
|
|
74
|
+
SecurityGroupIngress: [
|
|
75
|
+
{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "10.0.0.0/8" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
82
|
+
expect(diags).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("no diagnostic for non-sensitive port with open CIDR", () => {
|
|
86
|
+
const ctx = makeCtx({
|
|
87
|
+
Resources: {
|
|
88
|
+
MySG: {
|
|
89
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
90
|
+
Properties: {
|
|
91
|
+
SecurityGroupIngress: [
|
|
92
|
+
{ IpProtocol: "tcp", FromPort: 443, ToPort: 443, CidrIp: "0.0.0.0/0" },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
99
|
+
expect(diags).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("checks standalone SecurityGroupIngress resources", () => {
|
|
103
|
+
const ctx = makeCtx({
|
|
104
|
+
Resources: {
|
|
105
|
+
MyIngress: {
|
|
106
|
+
Type: "AWS::EC2::SecurityGroupIngress",
|
|
107
|
+
Properties: {
|
|
108
|
+
GroupId: { Ref: "MySG" },
|
|
109
|
+
IpProtocol: "tcp",
|
|
110
|
+
FromPort: 5432,
|
|
111
|
+
ToPort: 5432,
|
|
112
|
+
CidrIp: "0.0.0.0/0",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
118
|
+
expect(diags).toHaveLength(1);
|
|
119
|
+
expect(diags[0].entity).toBe("MyIngress");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("handles missing port range (all ports)", () => {
|
|
123
|
+
const ctx = makeCtx({
|
|
124
|
+
Resources: {
|
|
125
|
+
MySG: {
|
|
126
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
127
|
+
Properties: {
|
|
128
|
+
SecurityGroupIngress: [
|
|
129
|
+
{ IpProtocol: "-1", CidrIp: "0.0.0.0/0" },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const diags = checkUnrestrictedIngress(ctx);
|
|
136
|
+
expect(diags).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW019: Security Group Unrestricted Ingress
|
|
3
|
+
*
|
|
4
|
+
* Flags security groups with 0.0.0.0/0 or ::/0 ingress on sensitive ports
|
|
5
|
+
* (SSH 22, RDP 3389, MySQL 3306, PostgreSQL 5432).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { parseCFTemplate, getSecurityGroupIngress, portRangeContainsSensitive, isIntrinsic } from "./cf-refs";
|
|
10
|
+
|
|
11
|
+
const SENSITIVE_PORTS = [22, 3389, 3306, 5432];
|
|
12
|
+
const OPEN_CIDRS = new Set(["0.0.0.0/0", "::/0"]);
|
|
13
|
+
|
|
14
|
+
function checkIngressRule(
|
|
15
|
+
rule: Record<string, unknown>,
|
|
16
|
+
logicalId: string,
|
|
17
|
+
diagnostics: PostSynthDiagnostic[],
|
|
18
|
+
): void {
|
|
19
|
+
const cidrIp = rule.CidrIp;
|
|
20
|
+
const cidrIpv6 = rule.CidrIpv6;
|
|
21
|
+
|
|
22
|
+
const hasOpenCidr =
|
|
23
|
+
(typeof cidrIp === "string" && OPEN_CIDRS.has(cidrIp)) ||
|
|
24
|
+
(typeof cidrIpv6 === "string" && OPEN_CIDRS.has(cidrIpv6));
|
|
25
|
+
|
|
26
|
+
if (!hasOpenCidr) return;
|
|
27
|
+
|
|
28
|
+
if (portRangeContainsSensitive(rule.FromPort, rule.ToPort, SENSITIVE_PORTS)) {
|
|
29
|
+
const cidr = typeof cidrIp === "string" && OPEN_CIDRS.has(cidrIp) ? cidrIp : cidrIpv6;
|
|
30
|
+
const fromPort = rule.FromPort;
|
|
31
|
+
const toPort = rule.ToPort;
|
|
32
|
+
const portDesc = fromPort !== undefined && toPort !== undefined
|
|
33
|
+
? ` on ports ${fromPort}-${toPort}`
|
|
34
|
+
: " on all ports";
|
|
35
|
+
|
|
36
|
+
diagnostics.push({
|
|
37
|
+
checkId: "WAW019",
|
|
38
|
+
severity: "error",
|
|
39
|
+
message: `Security group "${logicalId}" allows unrestricted ingress from ${cidr}${portDesc} — restrict to specific CIDR ranges`,
|
|
40
|
+
entity: logicalId,
|
|
41
|
+
lexicon: "aws",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function checkUnrestrictedIngress(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
47
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
48
|
+
|
|
49
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
50
|
+
const template = parseCFTemplate(output);
|
|
51
|
+
if (!template?.Resources) continue;
|
|
52
|
+
|
|
53
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
54
|
+
// Check inline SecurityGroupIngress on EC2::SecurityGroup
|
|
55
|
+
if (resource.Type === "AWS::EC2::SecurityGroup") {
|
|
56
|
+
const rules = getSecurityGroupIngress(resource);
|
|
57
|
+
for (const rule of rules) {
|
|
58
|
+
checkIngressRule(rule, logicalId, diagnostics);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check standalone SecurityGroupIngress resources
|
|
63
|
+
if (resource.Type === "AWS::EC2::SecurityGroupIngress") {
|
|
64
|
+
const props = resource.Properties ?? {};
|
|
65
|
+
if (!isIntrinsic(props)) {
|
|
66
|
+
checkIngressRule(props as Record<string, unknown>, logicalId, diagnostics);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return diagnostics;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const waw019: PostSynthCheck = {
|
|
76
|
+
id: "WAW019",
|
|
77
|
+
description: "Security group allows unrestricted ingress on sensitive ports (SSH, RDP, database)",
|
|
78
|
+
|
|
79
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
80
|
+
return checkUnrestrictedIngress(ctx);
|
|
81
|
+
},
|
|
82
|
+
};
|