Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Trivy AWS ECR scan support #1163

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Start [here](https://lyft.github.io/cartography/install.html).
- [NIST CVE](https://lyft.github.io/cartography/modules/cve/index.html) - Common Vulnerabilities and Exposures (CVE) data from NIST database
- [Lastpass](https://lyft.github.io/cartography/modules/lastpass/index.html) - users
- [BigFix](https://lyft.github.io/cartography/modules/bigfix/index.html) - Computers
- Trivy Scanner - AWS ECR Images (TODO documentation)

## Usage
Start with our [tutorial](https://lyft.github.io/cartography/usage/tutorial.html). Our [data schema](https://lyft.github.io/cartography/usage/schema.html) is a helpful reference when you get stuck.
Expand Down
19 changes: 19 additions & 0 deletions cartography/client/aws/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import List

import neo4j

from cartography.client.core.tx import read_list_of_values_tx
from cartography.util import timeit


@timeit
def list_accounts(neo4j_session: neo4j.Session) -> List[str]:
"""
:param neo4j_session: The neo4j session object.
:return: A list of all AWS account IDs in the graph
"""
# See https://community.neo4j.com/t/extract-list-of-nodes-and-labels-from-path/13665/4
query = """
MATCH (a:AWSAccount) RETURN a.id
"""
return neo4j_session.read_transaction(read_list_of_values_tx, query)
35 changes: 35 additions & 0 deletions cartography/client/aws/ecr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import Set
from typing import Tuple

import neo4j

from cartography.client.core.tx import read_list_of_tuples_tx
from cartography.util import timeit


@timeit
def get_ecr_images(neo4j_session: neo4j.Session, aws_account_id: str) -> Set[Tuple[str, str, str, str, str]]:
"""
Queries the graph for all ECR images and their parent images.
Returns 5-tuples of ECR repository regions, tags, names, and binary digests. This is used to identify which images
to scan.
:param neo4j_session: The neo4j session object.
:param aws_account_id: The AWS account ID to get ECR repo data for.
:return: 5-tuples of repo region, image tag, image URI, repo_name, and image_digest.
"""
# See https://community.neo4j.com/t/extract-list-of-nodes-and-labels-from-path/13665/4
query = """
MATCH (e1:ECRRepositoryImage)<-[:REPO_IMAGE]-(repo:ECRRepository)
MATCH (repo)<-[:RESOURCE]-(:AWSAccount{id:$AWS_ID})
MATCH path = (e1)-[:PARENT*..]->(e2:ECRRepositoryImage) // TODO are there generic OSS ways to infer parent rels
WITH reduce(output=[], n in nodes(path) | output + n) as repo_img_collection
UNWIND repo_img_collection as repo_img
MATCH (er:ECRRepository)-[:REPO_IMAGE]->(repo_img:ECRRepositoryImage)-[:IMAGE]->(img:ECRImage)
RETURN DISTINCT
er.region as region,
repo_img.tag as tag,
repo_img.id as uri,
er.name as repo_name,
img.digest as digest
"""
return neo4j_session.read_transaction(read_list_of_tuples_tx, query, AWS_ID=aws_account_id)
12 changes: 12 additions & 0 deletions cartography/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ class Config:
:param bigfix_password: The password to authenticate to BigFix. Optional.
:type bigfix_root_url: str
:param bigfix_root_url: The API URL to use for BigFix, e.g. "https://example.com:52311". Optional.
:type trivy_path: str
:param trivy_path: The path the to the Trivy file binary.
:type trivy_opa_policy_file_path: str
:param trivy_path: The path to the OPA policy file to use with Trivy. Optional.
:type trivy_resource_type: str
:param trivy_resource_type: The resource type to scan with Trivy e.g. 'aws.ecr'.
"""

def __init__(
Expand Down Expand Up @@ -148,6 +154,9 @@ def __init__(
bigfix_username=None,
bigfix_password=None,
bigfix_root_url=None,
trivy_path=None,
trivy_opa_policy_file_path=None,
trivy_resource_type=None,
):
self.neo4j_uri = neo4j_uri
self.neo4j_user = neo4j_user
Expand Down Expand Up @@ -196,3 +205,6 @@ def __init__(
self.bigfix_username = bigfix_username
self.bigfix_password = bigfix_password
self.bigfix_root_url = bigfix_root_url
self.trivy_path = trivy_path
self.trivy_opa_policy_file_path = trivy_opa_policy_file_path
self.trivy_resource_type = trivy_resource_type
41 changes: 41 additions & 0 deletions cartography/data/jobs/cleanup/trivy_scan_findings_cleanup.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"statements": [
{
"query": "MATCH (:TrivyImageFinding)-[r:AFFECTS]->(:TrivyPackage)-[:DEPLOYED]->(:ECRImage)<-[:IMAGE]-(:ECRRepositoryImage)<-[:REPO_IMAGE]-(:ECRRepository)<-[:RESOURCE]-(:AWSAccount{id: $AWS_ID}) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE (r)",
"iterative": true,
"iterationsize": 100
},
{
"query": "MATCH (n:TrivyPackage)-[:DEPLOYED]->(:ECRImage)<-[:IMAGE]-(:ECRRepositoryImage)<-[:REPO_IMAGE]-(:ECRRepository)<-[:RESOURCE]-(:AWSAccount{id: $AWS_ID}) WHERE n.lastupdated <> $UPDATE_TAG WITH n LIMIT $LIMIT_SIZE DETACH DELETE (n)",
"iterative": true,
"iterationsize": 100
},
{
"query": "MATCH (:TrivyPackage)-[r:DEPLOYED]->(:ECRImage)<-[:IMAGE]-(:ECRRepositoryImage)<-[:REPO_IMAGE]-(:ECRRepository)<-[:RESOURCE]-(:AWSAccount{id: $AWS_ID}) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE (r)",
"iterative": true,
"iterationsize": 1000,
"__comment__": "In testing, setting this to 1000 made this job 10x faster. There may have been other problems too but this was worth playing with."
},
{
"query": "MATCH (:TrivyImageFinding)-[r:AFFECTS]->(:ECRImage)<-[:IMAGE]-(:ECRRepositoryImage)<-[:REPO_IMAGE]-(:ECRRepository)<-[:RESOURCE]-(:AWSAccount{id: $AWS_ID}) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE (r)",
"iterative": true,
"iterationsize": 100
},
{
"query": "MATCH (:TrivyFix)-[r:APPLIES_TO]->(:TrivyImageFinding) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE (r)",
"iterative": true,
"iterationsize": 100
},
{
"query": "MATCH (:TrivyPackage)-[r:SHOULD_UPDATE_TO]->(:TrivyFix) WHERE r.lastupdated <> $UPDATE_TAG WITH r LIMIT $LIMIT_SIZE DELETE (r)",
"iterative": true,
"iterationsize": 100
},
{
"query": "MATCH (n:TrivyImageFinding) WHERE n.lastupdated <> $UPDATE_TAG WITH n LIMIT $LIMIT_SIZE DELETE (n)",
"iterative": true,
"iterationsize": 100
}
],
"name": "cleanup Trivy image scan findings"
}
119 changes: 119 additions & 0 deletions cartography/intel/trivy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import logging
import subprocess
from typing import Any
from typing import Dict
from typing import List
from typing import Tuple

import neo4j

import cartography.config
from cartography.client.aws import list_accounts
from cartography.client.aws.ecr import get_ecr_images
from cartography.intel.trivy.scanner import _call_trivy_update_db
from cartography.intel.trivy.scanner import cleanup
from cartography.intel.trivy.scanner import sync_single_image
from cartography.stats import get_stats_client
from cartography.util import timeit


logger = logging.getLogger(__name__)
stat_handler = get_stats_client('trivy.scanner')


# If we have >= this percentage of Trivy fatal failures, crash the sync. 10 == 10%, 20 == 20%, etc.
TRIVY_SCAN_FATAL_CIRCUIT_BREAKER_PERCENT = 10


@timeit
def get_scan_targets(neo4j_session: neo4j.Session) -> List[Tuple[str, str, str, str, str]]:
aws_accounts = list_accounts(neo4j_session)
ecr_images: List[Tuple[str, str, str, str, str]] = []
for account_id in aws_accounts:
ecr_images.extend(get_ecr_images(neo4j_session, account_id))
return ecr_images


@timeit
def sync_trivy_aws_ecr(
neo4j_session: neo4j.Session,
trivy_path: str,
trivy_opa_policy_file_path: str,
update_tag: int,
common_job_parameters: Dict[str, Any],
) -> None:
trivy_scan_failure_count = 0

ecr_images = get_scan_targets(neo4j_session)
num_images = len(ecr_images)
logger.info(f"Scanning {num_images} ECR images with Trivy")

for region, image_tag, image_uri, repo_name, image_digest in ecr_images:
try:
sync_single_image(
neo4j_session,
image_tag,
image_uri,
repo_name,
image_digest,
update_tag,
True,
trivy_path,
trivy_opa_policy_file_path,
)
except subprocess.CalledProcessError as exc:
trivy_error_msg = exc.output.decode('utf-8') if type(exc.output) == bytes else exc.output
if 'rego_parse_error' in trivy_error_msg:
logger.error(
'Trivy image scan failed due to rego_parse_error - please check rego syntax! '
f"image_uri = {image_uri}, "
f"trivy_error_msg = {trivy_error_msg}.",
)
raise
else:
trivy_scan_failure_count += 1
logger.warning(
"Trivy image scan failed - please investigate. trivy_scan_failure_count++."
f"image_uri = {image_uri}"
f"trivy_error_msg = {trivy_error_msg}.",
)
if (trivy_scan_failure_count / num_images) * 100 >= TRIVY_SCAN_FATAL_CIRCUIT_BREAKER_PERCENT:
logger.error('Trivy scan fatal failure circuit breaker hit, crashing.')
raise
# Else if circuit breaker is not hit, then keep going.
except KeyError:
trivy_scan_failure_count += 1
logger.warning(
'Trivy image scan failed because it returned unexpectedly incomplete data. '
'Please repro locally. trivy_scan_failure_count++. '
f"image_uri = {image_uri}.",
)
if (trivy_scan_failure_count / num_images) * 100 >= TRIVY_SCAN_FATAL_CIRCUIT_BREAKER_PERCENT:
logger.error('Trivy scan fatal failure circuit breaker hit, crashing.')
raise
# Else if circuit breaker is not hit, then keep going.
cleanup(neo4j_session, common_job_parameters)


@timeit
def start_trivy_scans(neo4j_session: neo4j.Session, config: cartography.config.Config) -> None:
if not config.trivy_path:
logger.info("Trivy module not configured. Skipping.")
return

common_job_parameters = {
"UPDATE_TAG": config.update_tag,
# TODO we will need to infer the sub resource id based on what resource is being processed
"AWS_ID": 'id goes here',
}
_call_trivy_update_db(config.trivy_path)
if config.trivy_resource_type == 'aws.ecr':
sync_trivy_aws_ecr(
neo4j_session,
config.trivy_path,
config.trivy_opa_policy_file_path,
config.update_tag,
common_job_parameters,
)

# Support other Trivy resource types here e.g. if Google Cloud has images.
Loading