Initial CircleCI config.yaml pipelines runner
ADD
checkov/circleci_pipelines/__init__.py
+from checkov.circleci_pipelines.checks import * # noqa

ADD
checkov/circleci_pipelines/base_circleci_pipelines_check.py
+from checkov.common.checks.base_check import BaseCheck
+
+from checkov.common.models.enums import CheckCategories
+from checkov.circleci_pipelines.registry import registry
+
+
+class BaseCircleCIPipelinesCheck(BaseCheck):
+ def __init__(self, name, id, supported_entities, block_type, path=None):
+ categories = [CheckCategories.SUPPLY_CHAIN]
+
+ super().__init__(
+ name=name,
+ id=id,
+ categories=categories,
+ supported_entities=supported_entities,
+ block_type=block_type,
+ )
+ self.path = path
+ registry.register(self)

ADD
checkov/circleci_pipelines/checks/__init__.py
+from pathlib import Path
+
+modules = Path(__file__).parent.glob("*.py")
+__all__ = [f.stem for f in modules if f.is_file() and not f.stem == "__init__"]

ADD
checkov/circleci_pipelines/checks/image_version_not_hash.py
+from operator import contains
+from checkov.circleci_pipelines.base_circleci_pipelines_check import BaseCircleCIPipelinesCheck
+from checkov.common.models.enums import CheckResult
+from checkov.yaml_doc.enums import BlockType
+
+
+class ImageReferenceHashVersion(BaseCircleCIPipelinesCheck):
+ def __init__(self):
+ name = "Ensure the pipeline image version is referenced via hash not arbitrary tag."
+ id = "CKV_CIRCLECIPIPELINES_2"
+ super().__init__(
+ name=name,
+ id=id,
+ block_type=BlockType.ARRAY,
+ supported_entities=['jobs.*.docker[].{image: image, __startline__: __startline__, __endline__:__endline__}']
+ )
+
+ def scan_entity_conf(self, conf):
+ if not isinstance(conf, dict):
+ return
+ image = conf.get("image", None)
+ if not image:
+ return
+ if isinstance(image, str):
+ if "@" in image:
+ return CheckResult.PASSED, conf
+
+ return CheckResult.FAILED, conf
+
+
+check = ImageReferenceHashVersion()

ADD
checkov/circleci_pipelines/checks/latest_image.py
+from checkov.circleci_pipelines.base_circleci_pipelines_check import BaseCircleCIPipelinesCheck
+from checkov.common.models.enums import CheckResult
+from checkov.yaml_doc.enums import BlockType
+
+class ImageReferenceLatestTag(BaseCircleCIPipelinesCheck):
+ def __init__(self):
+ name = "Ensure the pipeline image uses a non latest version tag"
+ id = "CKV_CIRCLECIPIPELINES_1"
+ super().__init__(
+ name=name,
+ id=id,
+ block_type=BlockType.ARRAY,
+ supported_entities=['jobs.*.docker[].{image: image, __startline__: __startline__, __endline__:__endline__}']
+ )
+
+ def scan_entity_conf(self, conf):
+ if not isinstance(conf, dict):
+ return
+ image = conf.get("image", None)
+ if not image:
+ return
+ if isinstance(image, str):
+ if image.endswith(":latest"):
+ return CheckResult.FAILED, conf
+
+ return CheckResult.PASSED, conf
+
+
+check = ImageReferenceLatestTag()

ADD
checkov/circleci_pipelines/checks/prevent_development_orbs.py
+from asyncio.log import logger
+from checkov.circleci_pipelines.base_circleci_pipelines_check import BaseCircleCIPipelinesCheck
+from checkov.common.models.enums import CheckResult
+from checkov.yaml_doc.enums import BlockType
+
+class PreventDevelopmentOrbs(BaseCircleCIPipelinesCheck):
+ def __init__(self):
+ name = "Ensure mutable development orbs are not used."
+ id = "CKV_CIRCLECIPIPELINES_3"
+ super().__init__(
+ name=name,
+ id=id,
+ block_type=BlockType.ARRAY,
+ supported_entities=['orbs']
+ )
+
+ def scan_entity_conf(self, conf):
+ if isinstance(conf, str):
+ if "@dev:" in conf:
+ return CheckResult.FAILED, conf
+ else:
+ return CheckResult.PASSED, conf
+ return CheckResult.PASSED, conf
+
+
+check = PreventDevelopmentOrbs()

ADD
checkov/circleci_pipelines/prevent_volatile_orbs.py
+from asyncio.log import logger
+from checkov.circleci_pipelines.base_circleci_pipelines_check import BaseCircleCIPipelinesCheck
+from checkov.common.models.enums import CheckResult
+from checkov.yaml_doc.enums import BlockType
+
+class PreventDevelopmentOrbs(BaseCircleCIPipelinesCheck):
+ def __init__(self):
+ name = "Ensure unversioned volatile orbs are not used."
+ id = "CKV_CIRCLECIPIPELINES_4"
+ super().__init__(
+ name=name,
+ id=id,
+ block_type=BlockType.ARRAY,
+ supported_entities=['orbs']
+ )
+
+ def scan_entity_conf(self, conf):
+ if isinstance(conf, str):
+ if "@volatile" in conf:
+ return CheckResult.FAILED, conf
+ else:
+ return CheckResult.PASSED, conf
+ return CheckResult.PASSED, conf
+
+
+check = PreventDevelopmentOrbs()

ADD
checkov/circleci_pipelines/registry.py
+from checkov.yaml_doc.base_registry import Registry
+
+registry = Registry()

ADD
checkov/circleci_pipelines/runner.py
+from __future__ import annotations
+from asyncio.log import logger
+import jmespath
+import os
+from typing import TYPE_CHECKING, Any
+from checkov.common.images.image_referencer import ImageReferencer, Image
+from checkov.common.output.report import CheckType
+from checkov.circleci_pipelines.registry import registry
+from checkov.yaml_doc.runner import Runner as YamlRunner
+
+if TYPE_CHECKING:
+ from checkov.common.checks.base_check_registry import BaseCheckRegistry
+
+WORKFLOW_DIRECTORY = "circleci"
+
+class Runner(YamlRunner, ImageReferencer):
+ check_type = CheckType.CIRCLECI_PIPELINES # noqa: CCE003 # a static attribute
+
+ def __init__(self):
+ super().__init__()
+
+ def require_external_checks(self):
+ return False
+
+ def import_registry(self):
+ return registry
+
+ def _parse_file(self, f):
+ if self.is_workflow_file(f):
+ return super()._parse_file(f)
+
+ def is_workflow_file(self, file_path):
+ """
+ :return: True if the file mentioned is named config.yml/yaml in .circleci dir. Otherwise: False
+ """
+ abspath = os.path.abspath(file_path)
+ return WORKFLOW_DIRECTORY in abspath and abspath.endswith(("config.yml", "config.yaml"))
+
+ def get_images(self, file_path):
+ """
+ Get container images mentioned in a file
+ :param file_path: File to be inspected
+
+ File sample that will return 5 Image objects:
+ # # Use the latest 2.1 version of CircleCI pipeline process engine.
+ # # See: https://circleci.com/docs/2.0/configuration-reference
+ # version: 2.1
+ # # Define a job to be invoked later in a workflow.
+ # # See: https://circleci.com/docs/2.0/configuration-reference/#jobs
+ # jobs:
+ # say-hello:
+ # docker:
+ # - image: buildpack-deps:latest # primary container
+ # auth:
+ # username: mydockerhub-user
+ # password: $DOCKERHUB_PASSWORD # context / project UI env-var reference
+ # environment:
+ # ENV: CI
+ # - image: mongo:2.6.8
+ # auth:
+ # username: mydockerhub-user
+ # password: $DOCKERHUB_PASSWORD # context / project UI env-var reference
+ # command: [--smallfiles]
+ # - image: postgres:14.2
+ # auth:
+ # username: mydockerhub-user
+ # password: $DOCKERHUB_PASSWORD # context / project UI env-var reference
+ # environment:
+ # POSTGRES_USER: user
+ # - image: redis@sha256:54057dd7e125ca41afe526a877e8bd35ec2cdd33b9217e022ed37bdcf7d09673
+ # auth:
+ # username: mydockerhub-user
+ # password: $DOCKERHUB_PASSWORD # context / project UI env-var reference
+ # - image: acme-private/private-image:321
+ # auth:
+ # username: mydockerhub-user
+ # password: $DOCKERHUB_PASSWORD # context / project UI env-var reference
+ # # Invoke jobs via workflows
+ # # See: https://circleci.com/docs/2.0/configuration-reference/#workflows
+ # workflows:
+ # say-hello-workflow:
+ # jobs:
+ # - say-hello
+ :return: List of container image objects mentioned in the file.
+
+ """
+
+ images = set()
+
+ workflow, workflow_line_numbers = self._parse_file(file_path)
+ self.add_docker_job_images(workflow, images, file_path)
+
+ return images
+
+ def add_docker_job_images(self, workflow: dict, images: set, file_path: str) -> None:
+ """
+
+ :param workflow: parsed workflow file
+ :param images: set of images to be updated
+ :param file_path: path of analyzed workflow
+ """
+
+ # Only 'docker' executor paths are supported. (machine, windows & osx executors do not yeild OCI compatible images see https://circleci.com/docs/2.0/configuration-reference#docker-machine-macos-windows-executor)
+ keywords = [
+ 'jobs.*.docker[].{image: image, __startline__: __startline__, __endline__:__endline__}']
+ for keyword in keywords:
+ results = jmespath.search(keyword, workflow)
+ for result in results:
+ image_name = result.get("image", None)
+ if image_name:
+ image_id = self.inspect(image_name)
+ image_obj = Image(file_path=file_path, name=image_name, image_id=image_id,
+ start_line=result["__startline__"],
+ end_line=result["__endline__"])
+ images.add(image_obj)
+
+ # def add_root_image(self, file_path: str, images: set,
+ # workflow_line_numbers: dict, workflow: dict) -> None:
+ # root_image = workflow.get("image", "")
+
+ # if root_image:
+ # for line_number, line_txt in workflow_line_numbers:
+ # if "image" in line_txt and not line_txt.startswith(' '):
+ # image_id = self.inspect(root_image)
+ # image_obj = Image(
+ # file_path=file_path,
+ # name=root_image,
+ # image_id=image_id,
+ # start_line=line_number,
+ # end_line=line_number,
+ # )
+ # images.add(image_obj)

MODIFY
checkov/common/output/report.py
checkov/common/output/report.py
@@ -32,6 +32,7 @@ init(autoreset=True)
@dataclass
class CheckType:
BITBUCKET_PIPELINES = "bitbucket_pipelines"
+ CIRCLECI_PIPELINES = "circleci_pipelines"
ARGO_WORKFLOWS = "argo_workflows"
ARM = "arm"
BICEP = "bicep"

MODIFY
checkov/main.py
checkov/main.py
@@ -61,7 +61,7 @@ from checkov.version import version
from checkov.yaml_doc.runner import Runner as yaml_runner
from checkov.bicep.runner import Runner as bicep_runner
from checkov.openapi.runner import Runner as openapi_runner
-
+from checkov.circleci_pipelines.runner import Runner as circleci_pipelines_runner
signal.signal(signal.SIGINT, lambda x, y: sys.exit(''))
outer_registry = None
@@ -93,6 +93,7 @@ DEFAULT_RUNNERS = (
openapi_runner(),
sca_image_runner(),
argo_workflows_runner(),
+ circleci_pipelines_runner(),
)

Initial CircleCI config.yaml pipelines runner