Skip to content

Commit 96e4121

Browse files
initial commit
0 parents  commit 96e4121

12 files changed

+707
-0
lines changed

.gitignore

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Local .terraform directories
2+
**/.terraform/*
3+
4+
# .tfstate files
5+
*.tfstate
6+
*.tfstate.*
7+
8+
*.zip
9+
10+
# .tfvars files
11+
*.tfvars
12+
13+
# IDE
14+
.idea/

.pre-commit-config.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
repos:
2+
- repo: git://github.com/antonbabenko/pre-commit-terraform
3+
rev: v1.7.3
4+
hooks:
5+
- id: terraform_fmt
6+
- repo: git://github.com/pre-commit/pre-commit-hooks
7+
rev: v1.3.0
8+
hooks:
9+
- id: check-merge-conflict

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2019 Telia Company
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# AWS ECR Vulnerability Scaning Terraform module
2+
================================================
3+
4+
The module will trigger vulnerability scanning on all images in account ECR and will report the results to Slack channel.
5+
6+
## Architecture
7+
8+
The module will deploy CloudWatch rule to shcedule the scan, Step Function to orchestrate the lamgdas, and two lambda functions. One for triggering the scan and second to read the results and report the outcame.
9+
10+
## Basic usage
11+
12+
```
13+
module "ecr-regular-scanning" {
14+
source = "<module path>"
15+
16+
slack_channel = "<slack_channel_name>"
17+
slack_webhook_url = "<slack_webhook>"
18+
19+
}
20+
```
21+
22+
You can set the levels of vulnerability, you want to get notified by changing risk_levels variable.
23+
24+
Default value is: "HIGH, CRITICAL"
25+
26+
You can get alarms for the following levels: HIGH, MEDIUM, INFORMATIONAL, LOW, CRITICAL, UNDEFINED.
27+
28+

lambdas_code/scan_notify/__init__.py

Whitespace-only changes.
+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import os, boto3, json
2+
import urllib.request, urllib.parse
3+
from urllib.error import HTTPError
4+
import logging
5+
6+
from botocore.exceptions import ClientError
7+
8+
client = boto3.client('ecr')
9+
logger = logging.getLogger()
10+
11+
12+
def get_all_image_data(repository):
13+
"""
14+
Gets a list of dicts with Image tags and latest scan results.
15+
16+
:param repository:
17+
:return: [{'imageTags': ['1.0.70'], 'imageScanStatus': {'HIGH': 21, 'MEDIUM': 127, 'INFORMATIONAL': 115, }}]
18+
"""
19+
images = []
20+
levels = os.environ['RISK_LEVELS']
21+
22+
try:
23+
response = client.describe_images(repositoryName=repository, filter={
24+
'tagStatus': 'TAGGED'
25+
})['imageDetails']
26+
except ClientError as c:
27+
logger.error("Failed to get result from describe images with, client error: {}".format(c))
28+
return []
29+
30+
for image in response:
31+
image_data = {}
32+
try:
33+
image_data['imageTags'] = image['imageTags']
34+
except KeyError:
35+
image_data['imageTags'] = 'IMAGE UNTAGGED'
36+
try:
37+
image_data['imageScanStatus'] = image['imageScanFindingsSummary']['findingSeverityCounts']
38+
except KeyError:
39+
logger.error('FAILED TO RETRIEVE LATEST SCAN STATUS for image {}'.format(image_data['imageTags']))
40+
continue
41+
42+
if len(levels) > 0:
43+
image_data['imageScanStatus'] = {key: value for key, value in image_data['imageScanStatus'].items() if key in levels}
44+
45+
if len(image_data['imageScanStatus']) > 0:
46+
images.append(image_data)
47+
48+
return images
49+
50+
51+
def get_all_repositories():
52+
"""
53+
Gets a list of ECR repository string names.
54+
:return: ['repository1', 'repository2', 'repository3']
55+
"""
56+
repositories = []
57+
response = client.describe_repositories()['repositories']
58+
for repository in response:
59+
repositories.append(repository['repositoryName'])
60+
return repositories
61+
62+
63+
def get_scan_results():
64+
repositories = get_all_repositories()
65+
all_images = {}
66+
for repository in repositories:
67+
all_images[repository] = get_all_image_data(repository)
68+
return all_images
69+
70+
71+
def convert_scan_dict_to_string(scan_dict):
72+
"""
73+
converts parsed ImageScanStatus dictionary to string.
74+
:param scan_dict: {'HIGH': 64, 'MEDIUM': 269, 'INFORMATIONAL': 157, 'LOW': 127, 'CRITICAL': 17, 'UNDEFINED': 6}
75+
:return: HIGH 64, MEDIUM 269, INFORMATIONAL 157, LOW 127, CRITICAL 17, UNDEFINED 6
76+
"""
77+
result = ''
78+
79+
if not scan_dict:
80+
return result
81+
try:
82+
for key, value in scan_dict.items():
83+
result = result + key + " " + str(value) + ", "
84+
except AttributeError:
85+
return "Failed to retrieve repository scan results"
86+
87+
return result[:len(result)-2]
88+
89+
90+
def convert_image_scan_status(repository_scan_results):
91+
repository_scan_block_list = []
92+
for image in sorted(repository_scan_results, key=lambda imageScanResult: imageScanResult['imageTags'][0],
93+
reverse=True):
94+
image_block = dict()
95+
image_block["image"] = image['imageTags'][0]
96+
image_block["vulnerabilities"] = convert_scan_dict_to_string((image['imageScanStatus']))
97+
repository_scan_block_list.append(image_block)
98+
return repository_scan_block_list
99+
100+
101+
def create_image_scan_slack_block(repository, repository_scan_block_list):
102+
103+
blocks = []
104+
105+
# Generate slack messages for image scan results.
106+
for image in repository_scan_block_list:
107+
blocks.append(
108+
{
109+
"type": "section",
110+
"text": {
111+
"type": "mrkdwn",
112+
"text": "*`{}:{}`* vulnerabilities: {}".format(repository, image['image'], image['vulnerabilities'])
113+
}
114+
}
115+
)
116+
return blocks
117+
118+
119+
# Send a message to a slack channel
120+
def notify_slack(slack_block):
121+
slack_url = os.environ['SLACK_WEBHOOK_URL']
122+
slack_channel = os.environ['SLACK_CHANNEL']
123+
slack_username = os.environ['SLACK_USERNAME']
124+
slack_emoji = os.environ['SLACK_EMOJI']
125+
126+
payload = {
127+
"channel": slack_channel,
128+
"username": slack_username,
129+
"icon_emoji": slack_emoji,
130+
"blocks": slack_block
131+
}
132+
133+
data = urllib.parse.urlencode({"payload": json.dumps(payload)}).encode("utf-8")
134+
req = urllib.request.Request(slack_url)
135+
136+
try:
137+
result = urllib.request.urlopen(req, data)
138+
return json.dumps({"code": result.getcode(), "info": result.info().as_string()})
139+
140+
except HTTPError as e:
141+
logging.error("{}: result".format(e))
142+
return json.dumps({"code": e.getcode(), "info": e.info().as_string()})
143+
144+
145+
def lambda_handler(event, context):
146+
147+
scan_results = get_scan_results()
148+
149+
for repository, values in scan_results.items():
150+
if len(values) > 0:
151+
repository_scan_results = convert_image_scan_status(values)
152+
slack_block = create_image_scan_slack_block(repository, repository_scan_results)
153+
else:
154+
continue
155+
156+
if len(slack_block) > 0:
157+
try:
158+
response = notify_slack(slack_block=slack_block)
159+
if json.loads(response)["code"] != 200:
160+
logger.error("Error: received status {} for slack_block {}".format(json.loads(response)["info"], slack_block))
161+
except Exception as e:
162+
logger.error(msg=e)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import boto3
2+
import logging
3+
4+
from botocore.exceptions import ClientError
5+
6+
7+
client = boto3.client('ecr')
8+
logger = logging.getLogger()
9+
10+
11+
def get_all_images(repository):
12+
"""
13+
Return a list of images for specified repository.
14+
:param repository:
15+
:return: ['1.0.1', '1.0.2', '1.0.3']
16+
"""
17+
images = []
18+
try:
19+
response = client.describe_images(repositoryName=repository)['imageDetails']
20+
except ClientError as c:
21+
logger.error("Failed to get images for repository {} with error: ".format(repository, c))
22+
return []
23+
for image in response:
24+
images.append(image['imageDigest'])
25+
return images
26+
27+
28+
def get_all_repositories():
29+
"""
30+
Returns a list with ECR repository names
31+
:return: ['repo1', 'repo2', 'repo3']
32+
"""
33+
repositories = []
34+
try:
35+
response = client.describe_repositories()
36+
except ClientError as c:
37+
logger.error("Failed to retrieve ECR repositories with the following error: {}: ".format(c))
38+
return
39+
40+
try:
41+
repository_names = response['repositories']
42+
except KeyError:
43+
logger.error("Response did not return any repository names")
44+
return
45+
46+
for repository in repository_names:
47+
repositories.append(repository['repositoryName'])
48+
return repositories
49+
50+
51+
def get_repository_image_dict():
52+
"""
53+
Returns a dict with repository and a list of images that it has.
54+
:return: {'repo1': ['1.0.1', '1.0.2', '1.0.3'], 'repo2': ['2.2.1', '2.2.2']}
55+
"""
56+
repositories = get_all_repositories()
57+
all_images = {}
58+
for repository in repositories:
59+
all_images[repository] = get_all_images(repository)
60+
return all_images
61+
62+
63+
def run_image_scan(repository, image):
64+
try:
65+
client.start_image_scan(repositoryName=repository,imageId={'imageDigest': image})
66+
except ClientError as c:
67+
logger.error("Got error: {}, when running image scan on repository: {}, image: {}".format(c, repository, image))
68+
69+
70+
def lambda_handler(event, context):
71+
72+
repository_image_dict = get_repository_image_dict()
73+
74+
for repository in repository_image_dict:
75+
for image in repository_image_dict[repository]:
76+
run_image_scan(repository, image)
77+

0 commit comments

Comments
 (0)