Repository: stelligent/config-lint Branch: master Commit: 8e87d18df9df Files: 666 Total size: 840.3 KB Directory structure: gitextract_py6281yd/ ├── .devcontainer/ │ ├── Dockerfile │ ├── build/ │ │ ├── Dockerfile │ │ └── dockerhub.sh │ └── devcontainer.json ├── .dockerhub/ │ └── Dockerfile ├── .github/ │ └── workflows/ │ ├── build.yml │ ├── build_and_deploy.yml │ ├── bump_version.yml │ └── vscode_remote_development.yml ├── .gitignore ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── assertion/ │ ├── compare.go │ ├── compare_test.go │ ├── contains.go │ ├── contains_test.go │ ├── expression.go │ ├── expression_test.go │ ├── has_properties.go │ ├── helper_test.go │ ├── invoke.go │ ├── invoke_test.go │ ├── ip_operations.go │ ├── ip_operations_test.go │ ├── log.go │ ├── match.go │ ├── match_test.go │ ├── rules.go │ ├── rules_test.go │ ├── search.go │ ├── testdata/ │ │ ├── collection-assertions.yaml │ │ ├── conditions.yaml │ │ ├── default-severity.yaml │ │ └── has-properties.yaml │ ├── types.go │ ├── util.go │ ├── util_test.go │ ├── value.go │ └── value_test.go ├── cli/ │ ├── app.go │ ├── app_test.go │ ├── assets/ │ │ ├── lint-rules.yml │ │ └── terraform/ │ │ └── aws/ │ │ ├── api_gateway/ │ │ │ └── api_gateway_domain_name/ │ │ │ └── security_policy/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── security_policy.tf │ │ │ └── test.yml │ │ ├── batch/ │ │ │ └── batch_job_definition/ │ │ │ ├── aws_secrets/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── aws_secrets.tf │ │ │ │ └── test.yml │ │ │ └── container_properties_privileged/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── container_properties_privileged.tf │ │ │ ├── terraform12/ │ │ │ │ └── container_properties_privileged.tf │ │ │ └── test.yml │ │ ├── cloudfront/ │ │ │ └── cloudfront_distribution/ │ │ │ ├── custom_origin_config/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── custom_origin_config.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── custom_origin_config.tf │ │ │ │ └── test.yml │ │ │ ├── logging_config/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── logging_config.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── logging_config.tf │ │ │ │ └── test.yml │ │ │ ├── minimum_ssl_protocol/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── minimum_ssl_protocol.tf │ │ │ │ └── test.yml │ │ │ └── viewer_protocol_policy/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── viewer_protocol_policy.tf │ │ │ ├── terraform12/ │ │ │ │ └── viewer_protocol_policy.tf │ │ │ └── test.yml │ │ ├── cloudtrail/ │ │ │ └── cloudtrail/ │ │ │ └── kms_key_id/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── kms_key_id.tf │ │ │ ├── terraform12/ │ │ │ │ └── kms_key_id.tf │ │ │ └── test.yml │ │ ├── cloudwatch/ │ │ │ └── cloudwatch_log_destination_policy/ │ │ │ └── wildcard_principal/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── wildcard_principal.tf │ │ │ └── test.yml │ │ ├── codebuild/ │ │ │ └── codebuild_project/ │ │ │ ├── artifact_encryption/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── artifact_encryption.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── artifact_encryption.tf │ │ │ │ └── test.yml │ │ │ └── project_encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── project_encryption.tf │ │ │ ├── terraform12/ │ │ │ │ └── project_encryption.tf │ │ │ └── test.yml │ │ ├── codepipeline/ │ │ │ └── codepipeline/ │ │ │ └── encryption_key/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── encryption_key.tf │ │ │ ├── terraform12/ │ │ │ │ └── encryption_key.tf │ │ │ └── test.yml │ │ ├── dms/ │ │ │ └── dms_endpoint/ │ │ │ └── endpoint_kms_key/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── kms_key.tf │ │ │ ├── terraform12/ │ │ │ │ └── kms_key.tf │ │ │ └── test.yml │ │ ├── documentdb/ │ │ │ └── docdb_cluster/ │ │ │ ├── audit_logs/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── audit_logs.tf │ │ │ │ └── test.yml │ │ │ └── storage_encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── storage_encryption.tf │ │ │ └── test.yml │ │ ├── ec2/ │ │ │ ├── ami/ │ │ │ │ └── ebs_block_device_encrypted/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── ebs_block_device_encrypted.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── ebs_block_device_encrypted.tf │ │ │ │ └── test.yml │ │ │ ├── ami_copy/ │ │ │ │ └── encrypted/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── encrypted.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── encrypted.tf │ │ │ │ └── test.yml │ │ │ ├── ebs_volume/ │ │ │ │ └── encryption/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── encrypted.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── encrypted.tf │ │ │ │ └── test.yml │ │ │ └── instance/ │ │ │ └── ebs_block_device_encrypted/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── ebs_block_device_encrypted.tf │ │ │ └── test.yml │ │ ├── ecr/ │ │ │ └── ecr_repository_policy/ │ │ │ └── wildcard_principal/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── wildcard_principal.tf │ │ │ └── test.yml │ │ ├── ecs/ │ │ │ └── ecs_task_definition/ │ │ │ └── task_definition_secrets/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── secrets.tf │ │ │ ├── terraform12/ │ │ │ │ └── secrets.tf │ │ │ └── test.yml │ │ ├── efs/ │ │ │ └── efs_file_system/ │ │ │ └── encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── encrypted.tf │ │ │ ├── terraform12/ │ │ │ │ └── encrypted.tf │ │ │ └── test.yml │ │ ├── elastic_load_balancing/ │ │ │ ├── alb/ │ │ │ │ └── alb_access_logs_enabled/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── access_logs_enabled.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── access_logs_enabled.tf │ │ │ │ └── test.yml │ │ │ ├── alb_listener/ │ │ │ │ └── alb_listener_https/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── https.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── https.tf │ │ │ │ └── test.yml │ │ │ ├── elb/ │ │ │ │ └── access_logs_enabled/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── access_logs_enabled.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── access_logs_enabled.tf │ │ │ │ └── test.yml │ │ │ ├── lb/ │ │ │ │ └── access_logs_enabled/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── access_logs_enabled.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── access_logs_enabled.tf │ │ │ │ └── test.yml │ │ │ └── lb_listener/ │ │ │ ├── listener_https/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── https.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── https.tf │ │ │ │ └── test.yml │ │ │ └── listener_ssl_policy/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── ssl_policy.tf │ │ │ ├── terraform12/ │ │ │ │ └── ssl_policy.tf │ │ │ └── test.yml │ │ ├── elasticache/ │ │ │ └── elasticache_replication_group/ │ │ │ ├── encryption_at_rest/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── encryption_at_rest.tf │ │ │ │ └── test.yml │ │ │ └── encryption_in_transit/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── encryption_in_transit.tf │ │ │ ├── terraform12/ │ │ │ │ └── encryption_in_transit.tf │ │ │ └── test.yml │ │ ├── elasticsearch/ │ │ │ ├── elasticsearch_domain/ │ │ │ │ ├── encryption_at_rest/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── encryption_at_rest.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── encryption_node_to_node/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── encryption_node_to_node.tf │ │ │ │ │ └── test.yml │ │ │ │ └── vpc_subnets/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── elasticsearch_vpc.tf │ │ │ │ └── test.yml │ │ │ └── shared/ │ │ │ └── wildcard_principal/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ ├── elasticsearch_domain_policy_wildcard_principal.tf │ │ │ │ └── elasticsearch_domain_wildcard_principal.tf │ │ │ └── test.yml │ │ ├── elastictranscoder/ │ │ │ └── elastictranscoder_pipeline/ │ │ │ └── require_encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── require_encryption.tf │ │ │ └── test.yml │ │ ├── emr/ │ │ │ └── emr_cluster/ │ │ │ └── logging/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── logging.tf │ │ │ ├── terraform12/ │ │ │ │ └── logging.tf │ │ │ └── test.yml │ │ ├── glue/ │ │ │ └── glue_connection/ │ │ │ └── connection_properties/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── connection_properties.tf │ │ │ └── test.yml │ │ ├── iam/ │ │ │ ├── iam_group_membership/ │ │ │ │ └── group_and_users/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── group_and_users.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── group_and_users.tf │ │ │ │ └── test.yml │ │ │ ├── iam_policy/ │ │ │ │ ├── policy_action_wildcard/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── policy_action_wildcard.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── policy_action_wildcard.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── policy_notaction/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── policy_notaction.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── policy_notaction.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── policy_notresource/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── policy_notresource.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── policy_notresource.tf │ │ │ │ │ └── test.yml │ │ │ │ └── policy_resource_wildcard/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_resource_wildcard.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_resource_wildcard.tf │ │ │ │ └── test.yml │ │ │ ├── iam_role/ │ │ │ │ ├── assume_role_policy_action_wildcard/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── assume_role_policy_action_wildcard.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── assume_role_policy_action_wildcard.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── assume_role_policy_notaction/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── assume_role_policy_notaction.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── assume_role_policy_notaction.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── assume_role_policy_notprincipal/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── assume_role_policy_notprincipal.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── assume_role_policy_notprincipal.tf │ │ │ │ │ └── test.yml │ │ │ │ └── assume_role_policy_version/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── assume_role_policy_version.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── assume_role_policy_version.tf │ │ │ │ └── test.yml │ │ │ ├── iam_role_policy/ │ │ │ │ ├── role_policy_action_wildcard/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── policy_action_wildcard.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── policy_action_wildcard.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── role_policy_notaction/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── policy_notaction.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── policy_notaction.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── role_policy_notresource/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── policy_notresource.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── policy_notresource.tf │ │ │ │ │ └── test.yml │ │ │ │ └── role_policy_resource_wildcard/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_resource_wildcard.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_resource_wildcard.tf │ │ │ │ └── test.yml │ │ │ ├── iam_user_policy/ │ │ │ │ └── exists/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── resource_exists.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── resource_exists.tf │ │ │ │ └── test.yml │ │ │ └── iam_user_policy_attachment/ │ │ │ └── exists/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── resource_exists.tf │ │ │ ├── terraform12/ │ │ │ │ └── resource_exists.tf │ │ │ └── test.yml │ │ ├── iot/ │ │ │ └── iot_policy/ │ │ │ └── wildcard_principal/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── wildcard_principal.tf │ │ │ └── test.yml │ │ ├── kinesis/ │ │ │ └── kinesis_stream/ │ │ │ ├── kinesis_stream_encryption/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── encryption.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── encryption.tf │ │ │ │ └── test.yml │ │ │ └── kinesis_stream_kms_key/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── kms_key.tf │ │ │ ├── terraform12/ │ │ │ │ └── kms_key.tf │ │ │ └── test.yml │ │ ├── kinesis_firehouse/ │ │ │ └── kinesis_firehose_delivery_stream/ │ │ │ └── encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── encryption.tf │ │ │ ├── terraform12/ │ │ │ │ └── encryption.tf │ │ │ └── test.yml │ │ ├── kms/ │ │ │ └── kms_key/ │ │ │ ├── rotation/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── rotation.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── rotation.tf │ │ │ │ └── test.yml │ │ │ └── wildcard_policy/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── wildcard_policy.tf │ │ │ └── test.yml │ │ ├── lambda/ │ │ │ ├── lambda_function/ │ │ │ │ ├── encryption/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── encryption.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── encryption.tf │ │ │ │ │ └── test.yml │ │ │ │ └── environment_variables_aws_secrets/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── environment_variables_aws_secrets.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── environment_variables_aws_secrets.tf │ │ │ │ └── test.yml │ │ │ └── lambda_permission/ │ │ │ ├── action/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── action.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── action.tf │ │ │ │ └── test.yml │ │ │ └── principal_wildcard/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── principal_wildcard.tf │ │ │ ├── terraform12/ │ │ │ │ └── principal_wildcard.tf │ │ │ └── test.yml │ │ ├── mediastore/ │ │ │ └── media_store_container_policy/ │ │ │ └── wildcard_principal/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── wildcard_principal.tf │ │ │ └── test.yml │ │ ├── neptune/ │ │ │ └── neptune_cluster/ │ │ │ └── encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── encryption.tf │ │ │ └── test.yml │ │ ├── opsworks/ │ │ │ └── opsworks_application/ │ │ │ └── require_ssl/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── require_ssl.tf │ │ │ └── test.yml │ │ ├── rds/ │ │ │ ├── db_instance/ │ │ │ │ ├── encryption/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── storage_encryption.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── storage_encryption.tf │ │ │ │ │ └── test.yml │ │ │ │ └── publicly_accessible/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── publicly_accessible.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── publicly_accessible.tf │ │ │ │ └── test.yml │ │ │ └── rds_cluster/ │ │ │ └── encryption/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── storage_encryption.tf │ │ │ └── test.yml │ │ ├── redshift/ │ │ │ ├── redshift_cluster/ │ │ │ │ ├── encrypted/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── encrypted.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── enhanced_vpc_routing/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── enhanced_vpc_routing.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── enhanced_vpc_routing.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── kms_key_id/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── kms_key_id.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── kms_key_id.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── logging/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── logging.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── logging.tf │ │ │ │ │ └── test.yml │ │ │ │ └── publicly_accessible/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── publicly_accessible.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── publicly_accessible.tf │ │ │ │ └── test.yml │ │ │ └── redshift_parameter_group/ │ │ │ ├── require_ssl/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── require_ssl.tf │ │ │ │ └── test.yml │ │ │ └── user_logging/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── user_logging.tf │ │ │ └── test.yml │ │ ├── s3/ │ │ │ ├── s3_bucket/ │ │ │ │ ├── acl_not_public/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── acl_not_public.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── acl_not_public.tf │ │ │ │ │ └── test.yml │ │ │ │ └── server_side_encryption_enabled/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ └── server_side_encryption_enabled.tf │ │ │ │ └── test.yml │ │ │ ├── s3_bucket_object/ │ │ │ │ └── encryption_enabled/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── encryption_enabled.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── encryption_enabled.tf │ │ │ │ └── test.yml │ │ │ └── s3_bucket_policy/ │ │ │ ├── policy_statement_action_wildcard/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_action_wildcard.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_action_wildcard.tf │ │ │ │ └── test.yml │ │ │ ├── policy_statement_notaction/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_notaction.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_notaction.tf │ │ │ │ └── test.yml │ │ │ ├── policy_statement_notprincipal/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_notprincipal.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_notprincipal.tf │ │ │ │ └── test.yml │ │ │ ├── policy_statement_principal_wildcard/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_principal_wildcard.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_principal_wildcard.tf │ │ │ │ └── test.yml │ │ │ └── policy_statement_secure_transport/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── policy_statement_secure_transport.tf │ │ │ ├── terraform12/ │ │ │ │ └── policy_statement_secure_transport.tf │ │ │ └── test.yml │ │ ├── sagemaker/ │ │ │ ├── sagemaker_endpoint_configuration/ │ │ │ │ └── kms_key/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── kms_key.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── kms_key.tf │ │ │ │ └── test.yml │ │ │ └── sagemaker_notebook_instance/ │ │ │ └── kms_key/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── kms_key.tf │ │ │ ├── terraform12/ │ │ │ │ └── kms_key.tf │ │ │ └── test.yml │ │ ├── ses/ │ │ │ └── ses_identity_policy/ │ │ │ └── wildcard_principal/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform12/ │ │ │ │ └── wildcard_principal.tf │ │ │ └── test.yml │ │ ├── shared/ │ │ │ ├── exists.todo.txt │ │ │ ├── https.todo.txt │ │ │ ├── kms_key.todo.txt │ │ │ ├── policy.todo.txt │ │ │ ├── policy_version.todo.txt │ │ │ └── require_ssl.todo.txt │ │ ├── sns/ │ │ │ ├── shared/ │ │ │ │ └── wildcard_principal/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ ├── sns_topic_policy_wildcard_principal.tf │ │ │ │ │ └── sns_topic_wildcard_principal.tf │ │ │ │ └── test.yml │ │ │ └── sns_topic_policy/ │ │ │ ├── topic_policy_statement_notaction/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_notaction.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_notaction.tf │ │ │ │ └── test.yml │ │ │ ├── topic_policy_statement_notprincipal/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_notprincipal.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_notprincipal.tf │ │ │ │ └── test.yml │ │ │ └── topic_policy_statement_principal_wildcard-copy/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── policy_statement_principal_wildcard-copy.tf │ │ │ ├── terraform12/ │ │ │ │ └── policy_statement_principal_wildcard-copy.tf │ │ │ └── test.yml │ │ ├── sqs/ │ │ │ ├── shared/ │ │ │ │ └── wildcard_principal/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform12/ │ │ │ │ │ ├── sqs_queue_policy_wildcard_principal.tf │ │ │ │ │ └── sqs_queue_wildcard_principal.tf │ │ │ │ └── test.yml │ │ │ ├── sqs_queue/ │ │ │ │ └── encryption/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── encryption.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── encryption.tf │ │ │ │ └── test.yml │ │ │ └── sqs_queue_policy/ │ │ │ ├── policy_statement_action_wildcard/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_action_wildcard.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_action_wildcard.tf │ │ │ │ └── test.yml │ │ │ ├── policy_statement_notaction/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_notaction.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_notaction.tf │ │ │ │ └── test.yml │ │ │ ├── policy_statement_notprincipal/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_notprincipal.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_notprincipal.tf │ │ │ │ └── test.yml │ │ │ ├── policy_statement_principal_wildcard/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── policy_statement_principal_wildcard.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── policy_statement_principal_wildcard.tf │ │ │ │ └── test.yml │ │ │ └── policy_version/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── policy_version.tf │ │ │ ├── terraform12/ │ │ │ │ └── policy_version.tf │ │ │ └── test.yml │ │ ├── vpc/ │ │ │ ├── security_group/ │ │ │ │ ├── egress_all_protocols/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── egress_all_protocols.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── egress_all_protocols.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── egress_port_range/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── egress_port_range.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── egress_port_range.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── ingress_all_protocols/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── ingress_all_protocols.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── ingress_all_protocols.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── ingress_port_range/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── ingress_port_range.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── ingress_port_range.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── missing_egress/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── missing_egress.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── missing_egress.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── non_32_ingress/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── non_32_ingress.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── non_32_ingress.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── rdp_world_ingress/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── rdp_world_ingress.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── rdp_world_ingress.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── ssh_world_ingress/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── ssh_world_ingress.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── ssh_world_ingress.tf │ │ │ │ │ └── test.yml │ │ │ │ ├── world_egress/ │ │ │ │ │ ├── rule.yml │ │ │ │ │ └── tests/ │ │ │ │ │ ├── terraform11/ │ │ │ │ │ │ └── world_egress.tf │ │ │ │ │ ├── terraform12/ │ │ │ │ │ │ └── world_egress.tf │ │ │ │ │ └── test.yml │ │ │ │ └── world_ingress/ │ │ │ │ ├── rule.yml │ │ │ │ └── tests/ │ │ │ │ ├── terraform11/ │ │ │ │ │ └── world_ingress.tf │ │ │ │ ├── terraform12/ │ │ │ │ │ └── world_ingress.tf │ │ │ │ └── test.yml │ │ │ └── subnet/ │ │ │ └── map_public_ip_on_launch/ │ │ │ ├── rule.yml │ │ │ └── tests/ │ │ │ ├── terraform11/ │ │ │ │ └── map_public_ip_on_launch.tf │ │ │ ├── terraform12/ │ │ │ │ └── map_public_ip_on_launch.tf │ │ │ └── test.yml │ │ └── waf/ │ │ └── waf_web_acl/ │ │ └── default_action_type/ │ │ ├── rule.yml │ │ └── tests/ │ │ ├── terraform11/ │ │ │ └── default_action_type.tf │ │ ├── terraform12/ │ │ │ └── default_action_type.tf │ │ └── test.yml │ ├── builtin_test.go │ ├── options.go │ ├── options_test.go │ ├── report_writer.go │ ├── report_writer_test.go │ ├── terraform_test.go │ └── testdata/ │ ├── builtin/ │ │ └── terraform12/ │ │ └── test.tf │ ├── dirtest/ │ │ ├── a.yml │ │ └── b.yml │ ├── exclude-list │ ├── profile-exceptions.yml │ ├── profile.yml │ ├── smoketest_exceptions.tf │ ├── smoketest_tf11.tf │ ├── smoketest_tf12.tf │ ├── syntax-errors.yml │ └── terraform.yml ├── docs/ │ ├── README.md │ ├── conditions.md │ ├── coverpage.md │ ├── css/ │ │ └── style.css │ ├── design.md │ ├── development.md │ ├── example-rules.md │ ├── faq.md │ ├── github_workflow.md │ ├── index.html │ ├── install.md │ ├── operations.md │ ├── output.md │ ├── profiles.md │ ├── rule_development.md │ ├── rules.md │ ├── running.md │ ├── sidebar.md │ ├── terraform.md │ ├── tests.md │ ├── value_from.md │ └── yaml.md ├── example-files/ │ ├── config/ │ │ ├── cloudfront.tf │ │ ├── elb.tf │ │ ├── generic.config │ │ ├── iam.tf │ │ ├── my-pod.yml │ │ ├── network-policy-1.yml │ │ ├── network-policy-2.yml │ │ ├── no-containers.yml │ │ ├── not-a-pod.yml │ │ ├── pod-nginx.yml │ │ ├── pod-redis.yml │ │ ├── policy.yml │ │ ├── provider.tf │ │ ├── s3-bucket-policy.tf │ │ ├── s3-encryption.tf │ │ ├── s3.tf │ │ ├── search-debug.tf │ │ ├── security_group.tf │ │ ├── service-account.yml │ │ ├── sns.tf │ │ ├── sqs.tf │ │ ├── terraform.tf │ │ ├── variables.tf │ │ ├── volumes.tf │ │ └── web-and-helper.yml │ ├── demo-resources/ │ │ └── s3-bucket.tf │ └── rules/ │ ├── alias.yml │ ├── generic-json.yml │ ├── generic-yaml.yml │ ├── iam-policies.yml │ ├── iam-restricted.yml │ ├── kubernetes.yml │ ├── lint-rules-with-error.yml │ ├── no-iam-actions.yml │ ├── s3-encryption.yml │ ├── terraform-more.yml │ ├── terraform.yml │ └── variables.tf ├── go.mod ├── go.sum └── linter/ ├── common.go ├── common_test.go ├── csv_resource_loader.go ├── csv_resource_loader_test.go ├── file_linter.go ├── file_linter_test.go ├── helpers_test.go ├── json_resource_loader.go ├── json_resource_loader_test.go ├── kubernetes.go ├── kubernetes_test.go ├── linter.go ├── linter_test.go ├── resource_linter.go ├── resource_linter_test.go ├── rules_resource_loader.go ├── rules_resource_loader_test.go ├── schema.go ├── terraform.go ├── terraform_interpolate.go ├── terraform_interpolate_test.go ├── terraform_test.go ├── terraform_v12.go ├── terraform_v12_test.go ├── testdata/ │ ├── data/ │ │ ├── bucket_name │ │ ├── multi_line_content │ │ ├── reference_relative.tf │ │ ├── template_file_example_basic │ │ ├── template_file_example_conditional │ │ └── template_file_example_for_loop │ ├── resources/ │ │ ├── batch_privileged.tf │ │ ├── cloudfront_access_logs.tf │ │ ├── defines_variables.tf │ │ ├── dms_endpoint_encryption.tf │ │ ├── dynamic_block.tf │ │ ├── ec2_public.tf │ │ ├── elasticache_encryption_rest.tf │ │ ├── elasticache_encryption_transit.tf │ │ ├── embedded_yaml.yml │ │ ├── empty_document.yml │ │ ├── emr_cluster_logs.tf │ │ ├── explicit_chars.tf │ │ ├── generic.config │ │ ├── invalid.yml │ │ ├── kinesis_kms_stream.tf │ │ ├── kms_key_rotation.tf │ │ ├── missing_kind.yml │ │ ├── multi_level.tf │ │ ├── multiple_blocks_same.tf │ │ ├── multiple_pods.yml │ │ ├── neptune_db_encryption.tf │ │ ├── nullable_value.tf │ │ ├── pod.yml │ │ ├── policy_with_expression.tf │ │ ├── policy_with_variables.tf │ │ ├── rds_publicly_available.tf │ │ ├── reference_file.tf │ │ ├── reference_file_multi_line.tf │ │ ├── reference_variables.tf │ │ ├── sagemaker_endpoint_encryption.tf │ │ ├── sagemaker_notebook_encryption.tf │ │ ├── tagging.tf │ │ ├── template_file_function_basic.tf │ │ ├── template_file_function_conditional.tf │ │ ├── template_file_function_for_loop.tf │ │ ├── terraform_data.tf │ │ ├── terraform_inner_objects.tf │ │ ├── terraform_instance.tf │ │ ├── terraform_module.tf │ │ ├── terraform_policy.tf │ │ ├── terraform_policy_empty.tf │ │ ├── terraform_policy_invalid_json.tf │ │ ├── terraform_provider.tf │ │ ├── terraform_syntax_error.tf │ │ ├── tf12_for_loop.tf │ │ ├── tf12_resource_dependency.tf │ │ ├── users.csv │ │ ├── users.json │ │ ├── uses_local_variables.tf │ │ ├── uses_tf12_variables.tf │ │ └── uses_variables.tf │ └── rules/ │ ├── aggregate.yml │ ├── bad-format.yml │ ├── batch_definition.yml │ ├── cloudfront_access_logs.yml │ ├── dms_endpoint_encryption.yml │ ├── dynamic_block.yml │ ├── ec2_public.yml │ ├── elasticache_encryption_rest.yml │ ├── elasticache_encryption_transit.yml │ ├── emr_cluster_logs.yml │ ├── exclude_resource.yml │ ├── explicit_chars.yml │ ├── generic-csv.yml │ ├── generic-json.yml │ ├── generic-yaml.yml │ ├── kinesis_kms_stream.yml │ ├── kms_key_rotation.yml │ ├── kubernetes.yml │ ├── neptune_db_encryption.yml │ ├── nullable_value.yml │ ├── policy_variable.yml │ ├── rds_publicly_available.yml │ ├── rules.yml │ ├── sagemaker_endpoint_encryption.yml │ ├── sagemaker_notebook_encryption.yml │ ├── tagging.yml │ ├── terraform_bucket.yml │ ├── terraform_data.yml │ ├── terraform_instance.yml │ ├── terraform_module.yml │ ├── terraform_policy.yml │ ├── terraform_provider.yml │ ├── terraform_v12_variables.yml │ ├── tf12_for_loop.yml │ └── unknown.yml ├── tf12parser/ │ ├── README.md │ ├── attribute.go │ ├── block.go │ ├── parser.go │ ├── parser_test.go │ ├── range.go │ └── schema.go ├── yaml_resource_loader.go └── yaml_resource_loader_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM stelligent/vscode-remote-config-lint:latest ================================================ FILE: .devcontainer/build/Dockerfile ================================================ FROM ubuntu:latest # Avoid warnings by switching to noninteractive ENV DEBIAN_FRONTEND=noninteractive # Install packages RUN apt-get update \ # Install apt-utils and suppress package configuration warning && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ # Install build tools && apt-get install -y \ bc \ unzip \ wget \ git \ g++ \ gcc \ libc6-dev \ make \ pkg-config \ ca-certificates \ gnupg-agent \ # Cleanup apt lists && rm -rf /var/lib/apt/lists/* # Install Go ENV GOLANG_VERSION 1.13.7 RUN set -eux; \ goRelArch='linux-amd64'; \ goRelSha256='b3dd4bd781a0271b33168e627f7f43886b4c5d1c794a4015abf34e99c6526ca3'; \ url="https://golang.org/dl/go${GOLANG_VERSION}.${goRelArch}.tar.gz"; \ wget -O go.tgz "$url"; \ echo "${goRelSha256} *go.tgz" | sha256sum -c -; \ tar -C /usr/local -xzf go.tgz; \ rm go.tgz ENV GOPATH /go ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH" ENV GO111MODULE=on # Install VS Code Go Dependencies RUN go get -x -d github.com/stamblerre/gocode \ && go build -o gocode-gomod github.com/stamblerre/gocode \ && mv gocode-gomod $GOPATH/bin/ \ && go get -u -v \ golang.org/x/tools/gopls \ github.com/mdempsky/gocode \ # Workaround for https://github.com/uudashr/gopkgs/issues/25 github.com/uudashr/gopkgs/v2/cmd/gopkgs \ github.com/sqs/goreturns \ golang.org/x/lint/golint \ github.com/ramya-rao-a/go-outline \ && go get github.com/go-delve/delve/cmd/dlv # Create a non-root user ARG USERNAME=config-lint-dev ARG USER_UID=1000 ARG USER_GID=$USER_UID RUN groupadd --gid $USER_GID $USERNAME \ && useradd --uid $USER_UID --gid $USER_GID --shell /bin/bash -m $USERNAME # Prompt gpg window inside container for signing commits & setup folder permissions for non-root user RUN echo 'export GPG_TTY="$(tty)"' >> /home/$USERNAME/.bashrc \ && mkdir /home/$USERNAME/.gnupg \ && chown -R $USERNAME:$USERNAME /home/$USERNAME/.gnupg # Persist bash history between runs RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ && mkdir /commandhistory \ && touch /commandhistory/.bash_history \ && chown -R $USERNAME /commandhistory \ && echo $SNIPPET >> "/home/$USERNAME/.bashrc" # Add non-root user to $GOPATH RUN chown -R $USERNAME $GOPATH \ # Add write permission for /go/pkg && chmod -R a+w /go/pkg # Install Terraform ARG TERRAFORM_VERSION=0.12.20 RUN cd /tmp \ && wget https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ && unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ && mv terraform /usr/local/bin/ \ && rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip # Enter container as non-root user USER $USERNAME ================================================ FILE: .devcontainer/build/dockerhub.sh ================================================ #!/bin/bash -ex set +x if [[ -z ${DOCKER_ORG} ]]; then echo DOCKER_ORG must be set in the environment exit 1 fi if [[ -z ${GITHUB_SHA} ]]; then echo GITHUB_SHA must be set in the environment exit 1 fi set -x COMMIT_HASH=${GITHUB_SHA:0:8} # publish vscode-remote docker image to DockerHub, https://hub.docker.com/r/stelligent/vscode-remote-config-lint docker build -t $DOCKER_ORG/vscode-remote-config-lint:${COMMIT_HASH} --file .devcontainer/build/Dockerfile . docker tag $DOCKER_ORG/vscode-remote-config-lint:${COMMIT_HASH} $DOCKER_ORG/vscode-remote-config-lint:latest docker push $DOCKER_ORG/vscode-remote-config-lint:${COMMIT_HASH} docker push $DOCKER_ORG/vscode-remote-config-lint:latest ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "config-lint Development", "dockerFile": "Dockerfile", "appPort": 9000, "remote.containers.workspaceMountConsistency": "consistent", "mounts": [ // Bash History "source=config-lint-bash_history,target=/commandhistory,type=volume" ], "runArgs": [ // SSH "-v", "${localEnv:HOME}/.ssh:/home/config-lint-dev/.ssh:ro", // GPG "-v", "${localEnv:HOME}/.gnupg/private-keys-v1.d:/home/config-lint-dev/.gnupg/private-keys-v1.d:ro", "-v", "${localEnv:HOME}/.gnupg/pubring.kbx:/home/config-lint-dev/.gnupg/pubring.kbx:ro", "-v", "${localEnv:HOME}/.gnupg/trustdb.gpg:/home/config-lint-dev/.gnupg/trustdb.gpg:ro" ], "extensions": [ // General "CoenraadS.bracket-pair-colorizer", "fabiospampinato.vscode-diff", "mrmlnc.vscode-duplicate", "ms-azuretools.vscode-docker", "wayou.vscode-todo-highlight", // Go "ms-vscode.go", // Terraform "mauve.terraform", // JSON "mohsen1.prettify-json", // YAML "redhat.vscode-yaml" ], "settings": { // Bracket Pair Colorizer "bracketPairColorizer.forceUniqueOpeningColor": false, "bracketPairColorizer.colorMode": "Consecutive", "bracketPairColorizer.highlightActiveScope": true, "bracketPairColorizer.activeScopeCSS": [ "borderStyle : solid", "borderWidth : 1px", "borderColor : {color}; opacity: 0.5", "backgroundColor : {color}" ], "editor.matchBrackets": "never", "bracketPairColorizer.showBracketsInGutter": true, // Go "go.gopath": "/go", "go.inferGopath": true, "go.useLanguageServer": true, "[go]": { "editor.insertSpaces": true, "editor.tabSize": 4, "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": true } }, "gopls": { "usePlaceholders": true }, // Terraform "[terraform]": { "editor.formatOnSave": true }, "terraform.languageServer": { "enabled": false, "args": [] }, "terraform.indexing": { "enabled": false, "liveIndexing": false }, // YAML "[yaml]": { "editor.insertSpaces": true, "editor.tabSize": 2 }, "yaml.format.enable": true, "yaml.format.singleQuote": true, "yaml.format.bracketSpacing": true, "yaml.format.printWidth": 120, "yaml.format.proseWrap": "always", // TODO "todohighlight.isEnable": true, "todohighlight.isCaseSensitive": false }, "postCreateCommand": "make deps" } ================================================ FILE: .dockerhub/Dockerfile ================================================ FROM scratch COPY config-lint / ENTRYPOINT ["/config-lint"] ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: branches-ignore: - 'master' tags-ignore: - '**' paths-ignore: - 'docs/**' - '**.md' pull_request: branches: - master jobs: build: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@master - name: setup go uses: actions/setup-go@v1 with: go-version: '1.13' - name: dependencies run: | go mod download - name: build run: | export GOPATH=/home/runner/go export PATH="$PATH:$GOPATH/bin" make build - name: test run: | export GOPATH=/home/runner/go export PATH="$PATH:$GOPATH/bin" make test make smoke-test ================================================ FILE: .github/workflows/build_and_deploy.yml ================================================ name: Build & Deploy on: push: tags: - 'v*.*.*' jobs: build: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@master - name: setup go uses: actions/setup-go@v1 with: go-version: '1.13' - name: dependencies run: | go mod download - name: docker login env: DOCKER_USER: ${{ secrets.docker_user }} DOCKER_PASSWORD: ${{ secrets.docker_password }} run: | echo $DOCKER_PASSWORD | docker login -u $DOCKER_USER --password-stdin - name: build run: | export GOPATH=/home/runner/go export PATH="$PATH:$GOPATH/bin" make build - name: test run: | export GOPATH=/home/runner/go export PATH="$PATH:$GOPATH/bin" make test make smoke-test - name: release uses: goreleaser/goreleaser-action@v1 with: args: release --skip-validate env: GITHUB_TOKEN: ${{ secrets.gh_actions_token }} ================================================ FILE: .github/workflows/bump_version.yml ================================================ # Push with a commit message containing `#major` to bump major version # and update the major version number here. # MAJOR Version: 1.x name: Bump Version on: push: branches: - master tags-ignore: - '**' paths-ignore: - 'docs/**' - '**.md' - '.devcontainer/**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@master with: fetch-depth: '0' - name: Bump version and push tag uses: anothrNick/github-tag-action@1.19.0 env: GITHUB_TOKEN: ${{ secrets.gh_actions_token }} WITH_V: true DEFAULT_BUMP: minor ================================================ FILE: .github/workflows/vscode_remote_development.yml ================================================ name: VS Code DockerHub Build & Push on: push: branches: - 'master' paths: - '.devcontainer/**' jobs: build: runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@master - name: docker login env: DOCKER_USER: ${{ secrets.docker_user }} DOCKER_PASSWORD: ${{ secrets.docker_password }} run: | echo $DOCKER_PASSWORD | docker login -u $DOCKER_USER --password-stdin - name: Build & Push to DockerHub env: DOCKER_ORG: stelligent run: bash ./.devcontainer/build/dockerhub.sh ================================================ FILE: .gitignore ================================================ # Local dev dist/ .bundle .DS_Store .vscode/**/* .release/ .idea/ .DS_Store .test/ */coverage.out */*packr.go **/*.log **/*.pem **/*.retry **/*.sw* **/local.json # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 .glide/ # Dependency directories (remove the comment below to include it) vendor/ ================================================ FILE: .goreleaser.yml ================================================ builds: - main: ./cli env: - CGO_ENABLED=0 goos: - linux - darwin - windows goarch: - 386 - amd64 - arm - arm64 archives: - id: archive name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}' replacements: darwin: Darwin linux: Linux windows: Windows 386: i386 amd64: x86_64 format_overrides: - goos: windows format: zip checksum: name_template: 'checksums.txt' snapshot: name_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' brews: - github: owner: stelligent name: homebrew-tap commit_author: name: goreleaserbot email: goreleaser@stelligent.com folder: Formula homepage: https://github.com/stelligent/config-lint description: Validate configuration files using rules specified in YAML test: | system "#{bin}/config-lint -version" dockers: - dockerfile: .dockerhub/Dockerfile image_templates: - "stelligent/config-lint:{{ .Tag }}" - "stelligent/config-lint:latest" ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to config-lint Help wanted! We'd love your contributions to config-lint. Please review the following guidelines before contributing. Also, feel free to propose changes to these guidelines by updating this file and submitting a pull request. - [I have a question](#questions) - [I found a bug](#bugs) - [I have a feature request](#features) - [I have a contribution to share](#process) ## Have a Question? Please don't open a GitHub issue for questions about how to use config-lint, as the goal is to use issues for managing bugs and feature requests. Until we have a better way to communicate with users, please use the [Stelligent contact page](https://stelligent.com/contact/) if you have any questions. ## Found a Bug? If you've identified a bug in config-lint, please [submit an issue](#issue) to our GitHub repo: [stelligent/config-lint](https://github.com/stelligent/config-lint/issues/new). Please also feel free to submit a [Pull Request](#pr) with a fix for the bug! ## Have a Feature Request? All feature requests should start with [submitting an issue](#issue) documenting the user story and acceptance criteria. Again, feel free to submit a [Pull Request](#pr) with a proposed implementation of the feature. ## Ready to Contribute! ### Create an issue Before submitting a new issue, please search the issues to make sure there isn't a similar issue doesn't already exist. Assuming no existing issues exist, please ensure you include the following bits of information when submitting the issue to ensure we can quickly reproduce your issue: - The version of config-lint. - The platform (Linux, OS X, Windows). - The complete rule file(s) used if not using a built-in ruleset. - The complete code the rule is running against. - The complete command that was executed. - Any output from the command. - Details of the expected results and how they differed from the actual results. We may have additional questions and will communicate through the GitHub issue, so please respond back to our questions to help reproduce and resolve the issue as quickly as possible. New issues can be created with in our [GitHub repo](https://github.com/stelligent/config-lint/issues/new). ### Pull Requests Pull requests should target the `master` branch. Ensure you have a successful build for your branch. Please also reference the issue from the description of the pull request using [special keyword syntax](https://help.github.com/articles/closing-issues-via-commit-messages/) to auto close the issue when the PR is merged. For example, include the phrase `fixes #14` in the PR description to have issue #14 auto close. ### Styleguide When submitting code, please make every effort to follow existing conventions and style in order to keep the code as readable as possible. Here are a few points to keep in mind: - Please run `make lint` before committing to ensure code aligns with go standards. - Please run `make cyclo` before committing to ensure cyclomatic complexity is lower than 15. - Pleae ensure any new code is well tested and running `make test` is successful. - Dependencies are managed with [go modules](https://blog.golang.org/using-go-modules) and dependency requirements are defined in [go.mod](go.mod) ### License By contributing your code, you agree to license your contribution under the terms of the [MIT License](LICENSE). All files are released with the MIT license. ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2018-2020 Stelligent Portions copyright 2019 Liam Galvin (https://github.com/liamg/tfsec) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Versioning based on latest git tag. VERSION := $(shell git tag -l --sort=creatordate | grep "^v[0-9]*.[0-9]*.[0-9]*$$" | tail -1) BUILD_DIR = .release GOLDFLAGS = "-X main.version=$(VERSION)" CLI_FILES = $(shell find cli linter assertion -name \*.go) default: all devdeps: @echo "=== dev dependencies ===" @go get "github.com/gobuffalo/packr/..." @go get -u golang.org/x/lint/golint @go get "github.com/fzipp/gocyclo" deps: @echo "=== dependencies ===" go mod download gen: @echo "=== generating ===" @go get "github.com/gobuffalo/packr/..." @go generate ./... lint: gen @echo "=== linting ===" @go vet ./... @go get -u golang.org/x/lint/golint @golint $(go list ./... | grep -v /vendor/) cyclo: @echo "=== cyclomatic complexity ===" @go get "github.com/fzipp/gocyclo" @gocyclo -over 15 assertion linter cli || echo "WARNING: cyclomatic complexity is high" test: lint cyclo @echo "=== testing ===" @go test -v ./... testtf: lint cyclo @echo "=== testing Terraform Built In Rules ===" @go test -v ./cli/... -run TestTerraformBuiltInRules $(BUILD_DIR)/config-lint: $(CLI_FILES) @echo "=== building config-lint - $@ ===" mkdir -p $(BUILD_DIR) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags=$(GOLDFLAGS) -o $(BUILD_DIR)/config-lint cli/*.go build: gen $(BUILD_DIR)/config-lint all: clean deps test build smoke-test dev: deps devdeps clean: @echo "=== cleaning ===" rm -rf $(BUILD_DIR) rm -rf vendor rm -f cli/*-packr.go cover-assertion: @cd assertion && go test -coverprofile=coverage.out && go tool cover -html=coverage.out cover-linter: @cd linter && go test -coverprofile=coverage.out && go tool cover -html=coverage.out cover-cli: @cd cli && go test -coverprofile=coverage.out && go tool cover -html=coverage.out smoke-test: @$(BUILD_DIR)/config-lint -terraform cli/testdata/smoketest_tf12.tf @$(BUILD_DIR)/config-lint -tfparser tf11 -terraform cli/testdata/smoketest_tf11.tf @$(BUILD_DIR)/config-lint -tfparser tf11 -terraform -profile cli/testdata/profile-exceptions.yml cli/testdata/smoketest_exceptions.tf ================================================ FILE: README.md ================================================ [![Latest Release](https://img.shields.io/github/v/release/stelligent/config-lint?color=%233D9970)](https://img.shields.io/github/v/release/stelligent/config-lint?color=%233D9970) [![Build & Deploy](https://github.com/stelligent/config-lint/workflows/Build%20%26%20Deploy/badge.svg)](https://github.com/stelligent/config-lint/workflows/Build%20%26%20Deploy/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/stelligent/config-lint)](https://goreportcard.com/report/github.com/stelligent/config-lint) # 🔍 config-lint 🔎 A command line tool to validate configuration files using rules specified in YAML. The configuration files can be one of several formats: Terraform, JSON, YAML, with support for Kubernetes. There are built-in rules provided for Terraform, and custom files can be used for other formats. 📓 [Documentation](https://stelligent.github.io/config-lint) 👷 [Contributing](CONTRIBUTING.md) 🐛 [Issues & Bugs](https://github.com/stelligent/config-lint/issues) ## Blog Posts ✏️ [config-lint: Up and Running](https://stelligent.com/2020/04/17/config-lint-up-and-running/) ✏️ [Development Acceleration Through VS Code Remote Containers](https://stelligent.com/2020/04/10/development-acceleration-through-vs-code-remote-containers-setting-up-a-foundational-configuration/) ## Quick Start Install the latest version of config-lint on macOS using [Homebrew](https://brew.sh/): ``` bash brew tap stelligent/tap brew install config-lint ``` Or manually on Linux: ``` bash curl -L https://github.com/stelligent/config-lint/releases/latest/download/config-lint_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin config-lint chmod +rx /usr/local/bin/config-lint ``` Run the built-in ruleset against your Terraform files. For instance if you want to run config-lint against our [example files](example-files/): ``` bash config-lint -terraform example-files/config ``` You will see failure and warning violations in the output like this: ``` bash [ { "AssertionMessage": "viewer_certificate[].cloudfront_default_certificate | [0] should be 'false', not ''", "Category": "resource", "CreatedAt": "2020-04-15T19:24:33Z", "Filename": "example-files/config/cloudfront.tf", "LineNumber": 10, "ResourceID": "s3_distribution", "ResourceType": "aws_cloudfront_distribution", "RuleID": "CLOUDFRONT_MINIMUM_SSL", "RuleMessage": "CloudFront Distribution must use TLS 1.2", "Status": "FAILURE" }, ... ``` You can find more install options in our [installation guide](/docs/install.md). ================================================ FILE: assertion/compare.go ================================================ package assertion import ( "strconv" "time" ) func intCompare(n1 int, n2 int) int { if n1 < n2 { return -1 } if n1 > n2 { return 1 } return 0 } func daysOld(data interface{}) int { if stringValue, ok := data.(string); ok { layout := "2006-01-02T15:04:05Z" t, err := time.Parse(layout, stringValue) if err != nil { return 0 } days := int(time.Since(t).Hours() / 24.0) Debugf("Date: %v Days ago: %d\n", data, days) return days } return 0 } func compare(data interface{}, value string, valueType string) int { switch valueType { case "size": n, _ := strconv.Atoi(value) l := 0 switch v := data.(type) { case []interface{}: l = len(v) case map[string]interface{}: l = len(v) } return intCompare(l, n) case "integer": switch v := data.(type) { case float64: n1 := int(v) n2, _ := strconv.Atoi(value) return intCompare(n1, n2) case int: n2, _ := strconv.Atoi(value) return intCompare(v, n2) case string: n1, _ := strconv.Atoi(v) n2, _ := strconv.Atoi(value) return intCompare(n1, n2) } return 0 case "age": n, _ := strconv.Atoi(value) return intCompare(daysOld(data), n) default: tmp, _ := JSONStringify(data) s := unquoted(tmp) if s > value { return 1 } if s < value { return -1 } return 0 } } ================================================ FILE: assertion/compare_test.go ================================================ package assertion import ( "github.com/stretchr/testify/assert" "testing" "time" ) func TestDaysOldForToday(t *testing.T) { now := time.Now().Format("2006-01-02T15:04:05Z") assert.Equal(t, 0, daysOld(now), "Expecting daysOld to return 0") } func TestDaysOldFor90DaysAgo(t *testing.T) { then := time.Now().Add(-time.Duration(90) * time.Hour * 24).Format("2006-01-02T15:04:05Z") assert.Equal(t, 90, daysOld(then), "Expecting daysOld to return 90") } ================================================ FILE: assertion/contains.go ================================================ package assertion import ( "strings" ) func interfaceListContains(v []interface{}, key, value string) (MatchResult, error) { for _, element := range v { if stringElement, isString := element.(string); isString { if stringElement == value { return matches() } if strings.Contains(stringElement, value) { return matches() } } } return doesNotMatch("%v does not contain %v", key, value) } func stringListContains(v []string, key, value string) (MatchResult, error) { for _, stringElement := range v { if stringElement == value { return matches() } if strings.Contains(stringElement, value) { return matches() } } return doesNotMatch("%v does not contain %v", key, value) } func stringContains(v string, key, value string) (MatchResult, error) { if strings.Contains(v, value) { return matches() } return doesNotMatch("%v does not contain %v", key, value) } func defaultContains(data interface{}, key, value string) (MatchResult, error) { searchResult, err := JSONStringify(data) if err != nil { return matchError(err) } if strings.Contains(searchResult, value) { return matches() } return doesNotMatch("%v does not contain %v", key, value) } func contains(data interface{}, key, value string) (MatchResult, error) { switch v := data.(type) { case []interface{}: return interfaceListContains(v, key, value) case []string: return stringListContains(v, key, value) case string: return stringContains(v, key, value) default: return defaultContains(v, key, value) } } func doesNotContain(data interface{}, key, value string) (MatchResult, error) { m, err := contains(data, key, value) if err != nil { return matchError(err) } if m.Match { return doesNotMatch("%v should not contain %v", key, value) } return matches() } func startsWith(data interface{}, key, prefix string) (MatchResult, error) { switch v := data.(type) { case string: if strings.HasPrefix(v, prefix) { return matches() } return doesNotMatch("%v does not start with %v", key, prefix) default: return doesNotMatch("%v is not a string %v", key, prefix) } } func endsWith(data interface{}, key, suffix string) (MatchResult, error) { switch v := data.(type) { case string: if strings.HasSuffix(v, suffix) { return matches() } return doesNotMatch("%v does not end with %v", key, suffix) default: return doesNotMatch("%v is not a string %v", key, suffix) } } ================================================ FILE: assertion/contains_test.go ================================================ package assertion import ( "github.com/stretchr/testify/assert" "testing" ) // The non error cases are covered in match_test func TestContainsWithNonJSONType(t *testing.T) { var complexNumber complex128 _, err := contains(complexNumber, "foo", "1") if err == nil { t.Errorf("Expecting contains to return an error for non JSON encodable data") } } func TestDoesNotContainWithNonJSONType(t *testing.T) { var complexNumber complex128 _, err := doesNotContain(complexNumber, "foo", "1") if err == nil { t.Errorf("Expecting doesNotContain to return an error for non JSON encodable data") } } func TestContainsWithString(t *testing.T) { s := "s3:Get*" match, err := contains(s, "Action", "*") assert.Nil(t, err, "Expecting no error from contains") assert.True(t, match.Match, "Expecting match for string") } func TestContainsWithSliceOfStrings(t *testing.T) { s := []string{"s3:Get*"} match, err := contains(s, "Action", "*") assert.Nil(t, err, "Expecting no error from contains") assert.True(t, match.Match, "Expecting match for string") } ================================================ FILE: assertion/expression.go ================================================ package assertion func searchAndMatch(expression Expression, resource Resource) (MatchResult, error) { v, err := SearchData(expression.Key, resource.Properties) if err != nil { return matchError(err) } match, err := isMatch(v, expression) Debugf("ResourceID: %s Type: %s %v\n", resource.ID, resource.Type, match) return match, err } func orExpression(expressions []Expression, resource Resource) (MatchResult, error) { for _, childExpression := range expressions { match, err := booleanExpression(childExpression, resource) if err != nil { return matchError(err) } if match.Match { return matches() } } return doesNotMatch("Or expression fails") // TODO needs more information } func xorExpression(expressions []Expression, resource Resource) (MatchResult, error) { matchCount := 0 for _, childExpression := range expressions { match, err := booleanExpression(childExpression, resource) if err != nil { return matchError(err) } if match.Match { matchCount++ } } if matchCount == 1 { return matches() } return doesNotMatch("Xor expression fails") // TODO needs more information } func andExpression(expressions []Expression, resource Resource) (MatchResult, error) { for _, childExpression := range expressions { match, err := booleanExpression(childExpression, resource) if err != nil { return matchError(err) } if !match.Match { return doesNotMatch("And expression fails: %s", match.Message) } } return matches() } func notExpression(expressions []Expression, resource Resource) (MatchResult, error) { // more than one child filter treated as not any for _, childExpression := range expressions { match, err := booleanExpression(childExpression, resource) if err != nil { return matchError(err) } if match.Match { return doesNotMatch("Not expression fails") // TODO needs more information } } return matches() } func collectResources(key string, resource Resource) ([]Resource, error) { resources := make([]Resource, 0) value, err := SearchData(key, resource.Properties) if err != nil { return resources, err } if collection, ok := value.([]interface{}); ok { for _, properties := range collection { collectionResource := Resource{ ID: resource.ID, Type: resource.Type, Properties: properties, Filename: resource.Filename, } resources = append(resources, collectionResource) } } return resources, nil } func everyExpression(collectionExpression CollectionExpression, resource Resource) (MatchResult, error) { resources, err := collectResources(collectionExpression.Key, resource) if err != nil { return matchError(err) } for _, collectionResource := range resources { match, err := andExpression(collectionExpression.Expressions, collectionResource) if err != nil { return matchError(err) } if !match.Match { // at least one element is false, so entire expression is false return doesNotMatch("Every expression fails: %s", match.Message) } } // every element passes, so entire expression is true return matches() } func someExpression(collectionExpression CollectionExpression, resource Resource) (MatchResult, error) { resources, err := collectResources(collectionExpression.Key, resource) if err != nil { return matchError(err) } for _, collectionResource := range resources { match, err := andExpression(collectionExpression.Expressions, collectionResource) if err != nil { return matchError(err) } // at least one element passes, so entire expression is true if match.Match { return matches() } } // no element passes, so entire expression is false return doesNotMatch("Some expression fails") // TODO needs more information } func noneExpression(collectionExpression CollectionExpression, resource Resource) (MatchResult, error) { resources, err := collectResources(collectionExpression.Key, resource) if err != nil { return matchError(err) } for _, collectionResource := range resources { match, err := andExpression(collectionExpression.Expressions, collectionResource) if err != nil { return matchError(err) } // at least one element passes, so entire expression is false if match.Match { return doesNotMatch("None expression fails: %s", match.Message) } } // no element passes, so entire expression is true return matches() } func exactlyOneExpression(collectionExpression CollectionExpression, resource Resource) (MatchResult, error) { resources, err := collectResources(collectionExpression.Key, resource) if err != nil { return matchError(err) } matchCount := 0 for _, collectionResource := range resources { match, err := andExpression(collectionExpression.Expressions, collectionResource) if err != nil { return matchError(err) } if match.Match { matchCount++ } } if matchCount == 1 { return matches() } return doesNotMatch("ExactlyOne expression fails") } func booleanExpression(expression Expression, resource Resource) (MatchResult, error) { if expression.Or != nil && len(expression.Or) > 0 { return orExpression(expression.Or, resource) } if expression.Xor != nil && len(expression.Xor) > 0 { return xorExpression(expression.Xor, resource) } if expression.And != nil && len(expression.And) > 0 { return andExpression(expression.And, resource) } if expression.Not != nil && len(expression.Not) > 0 { return notExpression(expression.Not, resource) } if expression.Every.Key != "" { return everyExpression(expression.Every, resource) } if expression.Some.Key != "" { return someExpression(expression.Some, resource) } if expression.None.Key != "" { return noneExpression(expression.None, resource) } if expression.ExactlyOne.Key != "" { return exactlyOneExpression(expression.ExactlyOne, resource) } return searchAndMatch(expression, resource) } // ExcludeResource when resource.ID included in list of exceptions func ExcludeResource(rule Rule, resource Resource) bool { for _, id := range rule.Except { if id == resource.ID { return true } } return false } // FilterResourceExceptions filters out resources that should not be validated func FilterResourceExceptions(rule Rule, resources []Resource) []Resource { if rule.Except == nil || len(rule.Except) == 0 { return resources } filtered := make([]Resource, 0) for _, resource := range resources { if ExcludeResource(rule, resource) { filtered = append(filtered, resource) } } return filtered } // CheckExpression validates a single Resource using a single Expression func CheckExpression(rule Rule, expression Expression, resource Resource) (Result, error) { result := Result{ Status: "OK", Message: "", } match, err := booleanExpression(expression, resource) if err != nil { DebugJSON("Error: ", err) result.Status = "FAILURE" result.Message = err.Error() return result, err } if !match.Match { if rule.Severity == "" { result.Status = "FAILURE" } else { result.Status = rule.Severity } result.Message = match.Message } return result, nil } ================================================ FILE: assertion/expression_test.go ================================================ package assertion import ( "encoding/json" "testing" ) type ExpressionTestCase struct { Rule Rule Resource Resource ExpectedStatus string } func TestCheckExpression(t *testing.T) { simpleTestResource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{ "instance_type": "t2.micro", "ami": "ami-f2d3638a", }, Filename: "test.tf", } resourceWithTags := Resource{ ID: "another_test_resource", Type: "aws_instance", Properties: map[string]interface{}{ "instance_type": "t2.micro", "ami": "ami-f2d3638a", "tags": map[string]interface{}{ "Environment": "Development", "Project": "Web", }, }, Filename: "test.tf", } resourceWithRootVolume := Resource{ ID: "another_test_resource", Type: "aws_instance", Properties: map[string]interface{}{ "instance_type": "t2.micro", "ami": "ami-f2d3638a", "root_block_device": map[string]interface{}{ "volume_size": "1000", }, }, Filename: "test.tf", } testCases := map[string]ExpressionTestCase{ "testEq": { Rule{ ID: "test1", Message: "test rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, }, }, simpleTestResource, "OK", }, "testOr": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Or: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, Expression{ Key: "instance_type", Op: "eq", Value: "m4.large", }, }, }, }, }, simpleTestResource, "OK", }, "testOrFails": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Or: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.nano", }, Expression{ Key: "instance_type", Op: "eq", Value: "m4.large", }, }, }, }, }, simpleTestResource, "FAILURE", }, "testXor": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Xor: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, Expression{ Key: "instance_type", Op: "eq", Value: "m4.large", }, }, }, }, }, simpleTestResource, "OK", }, "testXorFails": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Xor: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, }, }, }, }, simpleTestResource, "FAILURE", }, "testXorFailsAgain": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Xor: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "m3.large", }, Expression{ Key: "instance_type", Op: "eq", Value: "c4.large", }, }, }, }, }, simpleTestResource, "FAILURE", }, "testAnd": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ And: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, Expression{ Key: "ami", Op: "eq", Value: "ami-f2d3638a", }, }, }, }, }, simpleTestResource, "OK", }, "testAndFails": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ And: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "m3.medium", }, Expression{ Key: "ami", Op: "eq", Value: "ami-f2d3638a", }, }, }, }, }, simpleTestResource, "FAILURE", }, "testNot": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Not: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "c4.large", }, }, }, }, }, simpleTestResource, "OK", }, "testNotFails": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Not: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, }, }, }, }, simpleTestResource, "FAILURE", }, "testNestedNot": { Rule{ ID: "TEST1", Message: "Test Rule", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Not: []Expression{ Expression{ Or: []Expression{ Expression{ Key: "instance_type", Op: "eq", Value: "t2.micro", }, Expression{ Key: "instance_type", Op: "eq", Value: "m3.medium", }, }, }, }, }, }, }, simpleTestResource, "FAILURE", }, "testSizeFails": { Rule{ ID: "TESTCOUNT", Message: "Test Resource Count Fails", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Key: "tags", ValueType: "size", Op: "eq", Value: "3", }, }, }, resourceWithTags, "FAILURE", }, "testSizeOK": { Rule{ ID: "TESTCOUNT", Message: "Test Resource Count OK", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Key: "tags", ValueType: "size", Op: "eq", Value: "2", }, }, }, resourceWithTags, "OK", }, "testIntegerFails": { Rule{ ID: "TESTCOUNT", Message: "Test integer Fails", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Key: "root_block_device.volume_size", ValueType: "integer", Op: "le", Value: "500", }, }, }, resourceWithRootVolume, "FAILURE", }, "testIntegerOK": { Rule{ ID: "TESTCOUNT", Message: "Test integer OK", Severity: "FAILURE", Resource: "aws_instance", Assertions: []Expression{ Expression{ Key: "root_block_device.volume_size", ValueType: "integer", Op: "le", Value: "2000", }, }, }, resourceWithRootVolume, "OK", }, } for k, tc := range testCases { expressionResult, err := CheckExpression(tc.Rule, tc.Rule.Assertions[0], tc.Resource) FailTestIfError(err, "TestSimple", t) if expressionResult.Status != tc.ExpectedStatus { t.Errorf("%s Failed Expected '%s' to be '%s'", k, expressionResult.Status, tc.ExpectedStatus) } } } func TestNestedBooleans(t *testing.T) { rule := Rule{ ID: "TEST1", Message: "Do not allow access to port 22 from 0.0.0.0/0", Severity: "NOT_COMPLIANT", Resource: "aws_instance", Assertions: []Expression{ Expression{ Not: []Expression{ Expression{ And: []Expression{ Expression{ Key: "ipPermissions[].fromPort[]", Op: "contains", Value: "22", }, Expression{ Key: "ipPermissions[].ipRanges[]", Op: "contains", Value: "0.0.0.0/0", }, }, }, }, }, }, } resource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{}, Filename: "test.tf", } resourceJSON := `{ "description": "2017-12-03T03:14:29.856Z", "groupName": "test-8246", "ipPermissions": [ { "fromPort": "22", "ipProtocol": "tcp", "toPort": "22", "ipv4Ranges": [ { "cidrIp": "0.0.0.0/0" } ], "ipRanges": [ "0.0.0.0/0" ] } ] }` err := json.Unmarshal([]byte(resourceJSON), &resource.Properties) if err != nil { t.Error("Error parsing resource JSON") } expressionResult, err := CheckExpression(rule, rule.Assertions[0], resource) FailTestIfError(err, "TestNestedBoolean", t) if expressionResult.Status != "NOT_COMPLIANT" { t.Error("Expecting nested boolean to return NOT_COMPLIANT") } } func TestExceptions(t *testing.T) { rule := Rule{ ID: "EXCEPT", Except: []string{"200", "300"}, } resources := []Resource{ Resource{ID: "100"}, Resource{ID: "200"}, Resource{ID: "300"}, Resource{ID: "400"}, } filteredResources := FilterResourceExceptions(rule, resources) if len(filteredResources) != 2 { t.Error("Expecting exceptions to be removed from resource list") } } func TestNoExceptions(t *testing.T) { rule := Rule{ ID: "EXCEPT", Except: []string{}, } resources := []Resource{ Resource{ID: "100"}, Resource{ID: "200"}, Resource{ID: "300"}, Resource{ID: "400"}, } filteredResources := FilterResourceExceptions(rule, resources) if len(filteredResources) != 4 { t.Error("Expecting no exceptions to return all resources") } } func TestUsingFixtures(t *testing.T) { fixtureFilenames := []string{ "./testdata/collection-assertions.yaml", "./testdata/has-properties.yaml", "./testdata/conditions.yaml", "./testdata/default-severity.yaml", } for _, filename := range fixtureFilenames { RunTestCasesFromFixture(filename, t) } } ================================================ FILE: assertion/has_properties.go ================================================ package assertion import ( "strings" ) func hasProperties(data interface{}, list string) (MatchResult, error) { for _, key := range strings.Split(list, ",") { if m, ok := data.(map[string]interface{}); ok { if _, ok := m[key]; !ok { return doesNotMatch("should have property %v", key) } } } return matches() } ================================================ FILE: assertion/helper_test.go ================================================ package assertion import ( "github.com/ghodss/yaml" "io/ioutil" "testing" ) type ( // FixtureTestCases is used to read a set of test cases from a YAML file FixtureTestCases struct { Description string TestCases []FixtureTestCase `json:"test_cases"` } // FixtureTestCase describes a single test case FixtureTestCase struct { Name string Rule Rule Resource Resource Result string } ) // FailTestIfError is a helper to check err and call test Error if it is not nil func FailTestIfError(err error, message string, t *testing.T) { if err != nil { t.Error(message + ":" + err.Error()) } } // LoadTestCasesFromFixture reads YAML data describing test cases func LoadTestCasesFromFixture(filename string, t *testing.T) FixtureTestCases { var testCases FixtureTestCases content, err := ioutil.ReadFile(filename) if err != nil { t.Errorf("Unable to read fixture file: %s", filename) return testCases } err = yaml.Unmarshal(content, &testCases) if err != nil { t.Errorf("Unable to parse fixture file: %s", filename) return testCases } return testCases } // RunTestCasesFromFixture loads a YAML file describing test cases and runs them func RunTestCasesFromFixture(filename string, t *testing.T) { fixture := LoadTestCasesFromFixture(filename, t) for _, testCase := range fixture.TestCases { status, _, err := CheckRule(testCase.Rule, testCase.Resource, mockExternalRuleInvoker()) FailTestIfError(err, testCase.Name, t) if status != testCase.Result { t.Errorf("Test case %s returned %s expecting %s", testCase.Name, status, testCase.Result) } } } ================================================ FILE: assertion/invoke.go ================================================ package assertion import ( "bytes" "encoding/json" "fmt" "io/ioutil" "net/http" ) // InvokeViolation has message describing a single validation error type InvokeViolation struct { Message string } // InvokeResponse contains a collection of validation errors type InvokeResponse struct { Violations []InvokeViolation } // StandardExternalRuleInvoker implements an external HTTP or HTTPS call type StandardExternalRuleInvoker struct { } func makeViolation(rule Rule, resource Resource, message string) Violation { return Violation{ RuleID: rule.ID, Status: rule.Severity, ResourceID: resource.ID, ResourceType: resource.Type, Category: resource.Category, Filename: resource.Filename, RuleMessage: rule.Message, AssertionMessage: message, CreatedAt: currentTime(), } } func makeViolations(rule Rule, resource Resource, message string) []Violation { v := makeViolation(rule, resource, message) return []Violation{v} } // Invoke an external API to validate a Resource func (e StandardExternalRuleInvoker) Invoke(rule Rule, resource Resource) (string, []Violation, error) { status := "OK" violations := make([]Violation, 0) var payload interface{} payload = resource if rule.Invoke.Payload != "" { p, err := SearchData(rule.Invoke.Payload, resource.Properties) if err != nil { return status, violations, err } payload = p } payloadJSON, err := JSONStringify(payload) if err != nil { violations := makeViolations(rule, resource, fmt.Sprintf("Unable to create JSON payload: %s", err.Error())) return rule.Severity, violations, err } Debugf("Invoke %s on %s\n", rule.Invoke.URL, payloadJSON) httpResponse, err := http.Post(rule.Invoke.URL, "application/json", bytes.NewBuffer([]byte(payloadJSON))) if err != nil { violations := makeViolations(rule, resource, fmt.Sprintf("Invoke failed: %s", err.Error())) return rule.Severity, violations, err } if httpResponse.StatusCode != 200 { violations := makeViolations(rule, resource, fmt.Sprintf("Invoke failed, StatusCode: %d", httpResponse.StatusCode)) return rule.Severity, violations, nil } defer httpResponse.Body.Close() body, err := ioutil.ReadAll(httpResponse.Body) if err != nil { violations := makeViolations(rule, resource, "Invoke response cannot be read") return rule.Severity, violations, nil } Debugf("Invoke body: %s\n", string(body)) var invokeResponse InvokeResponse err = json.Unmarshal(body, &invokeResponse) if err != nil { violations := makeViolations(rule, resource, "Invoke response cannot be parsed") return rule.Severity, violations, nil } for _, violation := range invokeResponse.Violations { status = rule.Severity v := makeViolation(rule, resource, violation.Message) violations = append(violations, v) } return status, violations, nil } ================================================ FILE: assertion/invoke_test.go ================================================ package assertion import ( "encoding/json" "fmt" "github.com/stretchr/testify/assert" "io/ioutil" "net/http" "net/http/httptest" "testing" ) func TestInvokeOK(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{}") })) defer ts.Close() i := StandardExternalRuleInvoker{} rule := Rule{ Invoke: InvokeRuleAPI{ URL: ts.URL, }, } resource := Resource{} status, violations, err := i.Invoke(rule, resource) assert.Equal(t, "OK", status, "Expecting Invoke to return 'OK'") assert.Equal(t, 0, len(violations), "Expecting Invoke to return no violations") assert.Nil(t, err, "Expecting Invoke to not return an error") } func TestInvokeWithViolations(t *testing.T) { response := InvokeResponse{ Violations: []InvokeViolation{ InvokeViolation{Message: "Something is not right"}, }, } jsonData, err := json.Marshal(response) assert.Nil(t, err, "Failed to marshal test response") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, string(jsonData)) })) defer ts.Close() i := StandardExternalRuleInvoker{} rule := Rule{ Severity: "FAILURE", Invoke: InvokeRuleAPI{ URL: ts.URL, }, } resource := Resource{} status, violations, err := i.Invoke(rule, resource) assert.Equal(t, "FAILURE", status, "Expecting Invoke to return 'FAILURE'") assert.Equal(t, 1, len(violations), "Expecting Invoke to return 1 violation") assert.Nil(t, err, "Expecting Invoke to not return an error") } func TestInvokeSendsMetadata(t *testing.T) { var invokedResource Resource ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := ioutil.ReadAll(r.Body) _ = json.Unmarshal(body, &invokedResource) fmt.Fprintln(w, "{}") })) defer ts.Close() i := StandardExternalRuleInvoker{} rule := Rule{ Invoke: InvokeRuleAPI{ URL: ts.URL, }, } resource := Resource{ Filename: "example.tf", } i.Invoke(rule, resource) assert.Equal(t, resource.Filename, invokedResource.Filename, "Expecting filename metadata in request body") } ================================================ FILE: assertion/ip_operations.go ================================================ package assertion import ( "fmt" "math" "net" "strconv" "strings" ) var rfc1918PrivateCIDRs = []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} func getIPObject(addressString string) (net.IP, error) { if !strings.Contains(addressString, "/") { addressString = fmt.Sprintf("%s/32", addressString) } ipAddress, _, err := net.ParseCIDR(addressString) if err != nil { return nil, err } return ipAddress, nil } func isSubnet(ipAddressStr string, supernet string) bool { ipAddress, parseError := getIPObject(ipAddressStr) if parseError != nil { Debugf("%v", parseError) return false } _, superNetwork, err := net.ParseCIDR(supernet) if err != nil { Debugf("error parsing supernet: %v", err) } return superNetwork.Contains(ipAddress) } func isPrivateIP(ipAddressStr string) bool { for _, cidr := range rfc1918PrivateCIDRs { if isSubnet(ipAddressStr, cidr) { return true } } return false } func maxHostCount(ruleCidr string, hostLimitStr string) bool { if !strings.Contains(ruleCidr, "/") { ruleCidr = fmt.Sprintf("%s/32", ruleCidr) } hostLimit, convErr := strconv.Atoi(hostLimitStr) if convErr != nil { Debugf("error converting %v to int", hostLimitStr) hostLimit = 0 } _, network, err := net.ParseCIDR(ruleCidr) if err != nil { Debugf("error parsing ruleCidr: %v", ruleCidr) return false } netmaskOnes, _ := network.Mask.Size() return hostCountByNetmaskOnes(netmaskOnes) <= hostLimit } func hostCountByNetmaskOnes(netmaskOnes int) int { return int(math.Pow(float64(2), float64(32-netmaskOnes))) } ================================================ FILE: assertion/ip_operations_test.go ================================================ package assertion import ( "testing" ) var ipTests = []struct { value string supernet string expectedResult bool }{ {"1.1.1.1", "10.0.0.0/8", false}, {"1.1.1.1/32", "10.0.0.0/8", false}, {"10.1.0.0/16", "10.0.0.0/8", true}, {"10.1.1.1/32", "10.0.0.0/8", true}, {"10.1.1.1", "10.0.0.0/8", true}, } func TestIsSubnet(t *testing.T) { for _, input := range ipTests { t.Run(input.value, func(t *testing.T) { result := isSubnet(input.value, input.supernet) if result != input.expectedResult { t.Errorf("got %v, want %v", result, input.expectedResult) } }) } } var privateIPTests = []struct { value string expectedResult bool }{ {"1.1.1.1", false}, {"1.1.1.1/32", false}, {"10.1.0.0/16", true}, {"10.1.1.1/32", true}, {"10.1.1.1", true}, {"172.16.0.0/12", true}, {"172.0.0.0/8", false}, {"172.16.1.1", true}, {"172.15.1.1", false}, {"192.168.1.1", true}, {"52.1.1.1", false}, {"sg-1234567", false}, } func TestIsPrivateIp(t *testing.T) { for _, input := range privateIPTests { t.Run(input.value, func(t *testing.T) { result := isPrivateIP(input.value) if result != input.expectedResult { t.Errorf("got %v, want %v", result, input.expectedResult) } }) } } var maxHostCountTests = []struct { value string max string expectedResult bool }{ {"10.0.0.0/8", "1000", false}, {"10.0.0.0/23", "500", false}, {"10.1.0.0/16", "65600", true}, {"10.1.1.1/32", "2", true}, {"10.1.1.1/32", "1", true}, {"10.1.1.1", "1", true}, {"sg-1234567", "0", false}, } func TestMaxHostCount(t *testing.T) { for _, input := range maxHostCountTests { t.Run(input.value, func(t *testing.T) { result := maxHostCount(input.value, input.max) if result != input.expectedResult { t.Errorf("got %v, want %v", result, input.expectedResult) } }) } } ================================================ FILE: assertion/log.go ================================================ package assertion import "fmt" var ( isDebug = false ) // SetDebug turns verbose logging on or off func SetDebug(b bool) { isDebug = b } // Debugf prints a formatted string when verbose logging is turned on func Debugf(format string, args ...interface{}) { if isDebug == false { return } fmt.Printf(format, args...) } func DebugJSON(title string, object interface{}) { if isDebug == false { return } s, _ := JSONStringify(object) fmt.Println(title) fmt.Println(s) } ================================================ FILE: assertion/match.go ================================================ package assertion import ( "fmt" "regexp" "strings" ) func matches() (MatchResult, error) { return MatchResult{Match: true, Message: ""}, nil } func doesNotMatch(format string, args ...interface{}) (MatchResult, error) { return MatchResult{ Match: false, Message: fmt.Sprintf(format, args...), }, nil } func matchError(err error) (MatchResult, error) { return MatchResult{ Match: false, Message: err.Error(), }, err } func isMatch(data interface{}, expression Expression) (MatchResult, error) { // FIXME eliminate searchResult this when all operations converted to use data // individual ops can call JSONStringify as needed searchResult, err := JSONStringify(data) if err != nil { return matchError(err) } searchResult = unquoted(searchResult) key := expression.Key op := expression.Op value := expression.Value valueType := expression.ValueType switch op { case "eq": if compare(data, value, valueType) == 0 { return matches() } return doesNotMatch("%v(%v) should be equal to %v", key, searchResult, value) case "ne": if compare(data, value, valueType) != 0 { return matches() } return doesNotMatch("%v(%v) should not be equal to %v", key, searchResult, value) case "lt": if compare(data, value, valueType) < 0 { return matches() } return doesNotMatch("%v(%v) should be less than %v", key, searchResult, value) case "le": if compare(data, value, valueType) <= 0 { return matches() } return doesNotMatch("%v(%v) should be less than or equal to %v", key, searchResult, value) case "gt": if compare(data, value, valueType) > 0 { return matches() } return doesNotMatch("%v(%v) should be greater than %v", key, searchResult, value) case "ge": if compare(data, value, valueType) >= 0 { return matches() } return doesNotMatch("%v(%v) should be greater than or equal to %v", key, searchResult, value) case "in": for _, v := range strings.Split(value, ",") { if v == searchResult { return matches() } } return doesNotMatch("%v(%v) should be in %v", key, searchResult, value) case "not-in": for _, v := range strings.Split(value, ",") { if v == searchResult { return doesNotMatch("%v(%v) should not be in %v", key, searchResult, value) } } return matches() case "absent": if isAbsent(searchResult) { return matches() } return doesNotMatch("%v should be absent", key) case "present": if isPresent(searchResult) { return matches() } return doesNotMatch("%v should be present", key) case "null": if data == nil { return matches() } return doesNotMatch("%v should be null", key) case "not-null": if data != nil { return matches() } return doesNotMatch("%v should not be null", key) case "empty": if isEmpty(data) { return matches() } return doesNotMatch("%v should be empty", key) case "not-empty": if !isEmpty(data) { return matches() } return doesNotMatch("%v should not be empty", key) case "is-array": if isArray(data) { return matches() } return doesNotMatch("%v should be an array", key) case "is-not-array": if !isArray(data) { return matches() } return doesNotMatch("%v should not be an array", key) case "intersect": if jsonListsIntersect(searchResult, value) { return matches() } return doesNotMatch("%v should intersect with %v", key, value) case "contains": return contains(data, key, value) case "not-contains": return doesNotContain(data, key, value) case "does-not-contain": return doesNotContain(data, key, value) case "starts-with": return startsWith(data, key, value) case "ends-with": return endsWith(data, key, value) case "regex": re, err := regexp.Compile(value) if err != nil { return matchError(err) } if re.MatchString(searchResult) { return matches() } return doesNotMatch("%v(%v) should match %v", key, searchResult, value) case "has-properties": return hasProperties(data, value) case "is-true": if searchResult == "true" { return matches() } return doesNotMatch("%v should be 'true', not '%v'", key, value) case "is-false": if searchResult == "false" { return matches() } return doesNotMatch("%v should be 'false', not '%v'", key, value) case "is-subnet": isSubnet := isSubnet(searchResult, value) if isSubnet { return matches() } return doesNotMatch("%v should be a subnet of %v", searchResult, value) case "is-private-ip": isPrivate := isPrivateIP(searchResult) if isPrivate { return matches() } return doesNotMatch("%v should be a private ip", searchResult) case "max-host-count": hostCountWithinLimit := maxHostCount(searchResult, value) if hostCountWithinLimit { return matches() } return doesNotMatch("%v should be less than or equal to %v", searchResult, value) } return doesNotMatch("unknown op %v", op) } ================================================ FILE: assertion/match_test.go ================================================ package assertion import ( "encoding/json" "fmt" "testing" ) type MatchTestCase struct { SearchResult interface{} Op string Value string ValueType string ExpectedResult bool } func getQuotesRight(jsonString string) string { if len(jsonString) == 0 { return jsonString } if jsonString[0] != '[' { jsonString = quoted(jsonString) } return jsonString } func unmarshal(s string) (interface{}, error) { var searchResult interface{} jsonString := getQuotesRight(s) if len(jsonString) > 0 { err := json.Unmarshal([]byte(jsonString), &searchResult) if err != nil { return "", err } } return searchResult, nil } func TestIsMatch(t *testing.T) { sliceOfTags := []interface{}{"Foo", "Bar"} emptySlice := []interface{}{} anotherSlice := []interface{}{"One", "Two"} stringSlice := []string{"One", "Two"} testCases := map[string]MatchTestCase{ "eqTrue": {"Foo", "eq", "Foo", "", true}, "eqFalse": {"Foo", "eq", "Bar", "", false}, "eqIntegerTrue": {22, "eq", "22", "integer", true}, "eqIntegerFalse": {80, "eq", "22", "integer", false}, "neFalse": {"Foo", "ne", "Foo", "", false}, "neTrue": {"Foo", "ne", "Bar", "", true}, "inTrue": {"Foo", "in", "Foo,Bar,Baz", "", true}, "inFalse": {"Foo", "in", "Bar,Baz", "", false}, "notInFalse": {"Foo", "not-in", "Foo,Bar,Baz", "", false}, "notInTrue": {"Foo", "not-in", "Bar,Baz", "", true}, "absentFalse": {"Foo", "absent", "", "", false}, "absentTrueForEmptyString": {"", "absent", "", "", true}, "absentTrueForNull": {"null", "absent", "", "", true}, "absentTrueForEmptyArray": {"[]", "absent", "", "", true}, "presentTrue": {sliceOfTags, "present", "", "", true}, "presentStringTrue": {"Foo", "present", "", "", true}, "presentFalseForNil": {nil, "present", "", "", false}, "presentFalseForEmptyString": {"", "present", "", "", false}, "presentFalseForNull": {"null", "present", "", "", false}, "presentFalseForEmptyArray": {"[]", "present", "", "", false}, "containsTrueForString": {"Foo", "contains", "oo", "", true}, "containsFalseForString": {"Foo", "contains", "aa", "", false}, "containsTrueForSlice": {sliceOfTags, "contains", "Bar", "", true}, "containsFalseForSubstring": {sliceOfTags, "contains", "abc", "", false}, "containsTrueForSliceOfStrings": {stringSlice, "contains", "One", "", true}, "containsFalseForSliceOfStrings": {stringSlice, "contains", "Three", "", false}, "containsTrueForInt": {1, "contains", "1", "", true}, "containsFalseForInt": {1, "contains", "One", "", false}, "notContainsFalseForString": {"Foo", "does-not-contain", "oo", "", false}, "notContainsTrueForString": {"Foo", "does-not-contain", "aa", "", true}, "notContainsFalseForSlice": {sliceOfTags, "does-not-contain", "Bar", "", false}, "notContainsTrueForSubstring": {sliceOfTags, "does-not-contain", "abc", "", true}, "regexTrueForEndOfString": {"Foo", "regex", "o$", "", true}, "regexFalseForEndOfString": {"Bar", "regex", "o$", "", false}, "regExTrueForBeginningOfString": {"Foo", "regex", "^F", "", true}, "regExFalseForBeginningOfString": {"Foo", "regex", "^B", "", false}, "reqExFalseForEntireString": {"Foo", "regex", "^Bar$", "", false}, "regExIgnoreCaseTrue": {"HTTPS", "regex", "(?i)https", "", true}, "regexIgnoreCaseFalse": {"HTTP", "regex", "(?i)https", "", false}, "ltTrue": {"a", "lt", "b", "", true}, "ltFalse": {"a", "lt", "a", "", false}, "leTrue": {"a", "le", "a", "", true}, "leFalse": {"b", "le", "a", "", false}, "gtTrue": {"b", "gt", "a", "", true}, "gtFalse": {"b", "gt", "b", "", false}, "geTrue": {"b", "ge", "b", "", true}, "geFalse": {"b", "ge", "c", "", false}, "nullTrue": {"", "null", "", "", true}, "nullFalse": {"1", "null", "", "", false}, "notNullFalse": {"", "not-null", "", "", false}, "notNullTrue": {"1", "not-null", "", "", true}, "emptyTrueForEmptyString": {"", "empty", "", "", true}, "emptyFalseForString": {"Foo", "empty", "", "", false}, "emptyTrueForEmptySlice": {emptySlice, "empty", "", "", true}, "emptyFalseForSlice": {sliceOfTags, "empty", "", "", false}, "notEmptyFalseForEmptyString": {"", "not-empty", "", "", false}, "notEmptyTrueForString": {"Foo", "not-empty", "", "", true}, "notEmptyFalseForEmptySlice": {emptySlice, "not-empty", "", "", false}, "notEmptyTrueForSlice": {sliceOfTags, "not-empty", "", "", true}, "intersectTrue": {"[\"one\",\"two\"]", "intersect", "[\"two\",\"three\"]", "", true}, "intersectFalse": {"[\"one\",\"two\"]", "intersect", "[\"three\",\"four\"]", "", false}, "eqSizeTrue": {anotherSlice, "eq", "2", "size", true}, "eqSizeFalse": {anotherSlice, "eq", "3", "size", false}, "isTrue": {"true", "is-true", "", "", true}, "isNotTrue": {"false", "is-true", "", "", false}, "isFalse": {"false", "is-false", "", "", true}, "isNotFalse": {"100", "is-false", "", "", false}, "startsWithTrue": {"FooBar", "starts-with", "Foo", "", true}, "startsWithFalse": {"FooBar", "starts-with", "Bar", "", false}, "startsWithNonString": {1, "starts-with", "Foo", "", false}, "endsWithTrue": {"FooBar", "ends-with", "Bar", "", true}, "endsWithFalse": {"FooBar", "ends-with", "Foo", "", false}, "endsartWithNonString": {1, "ends-with", "Foo", "", false}, "isArrayTrue": {sliceOfTags, "is-array", "", "", true}, "isArrayFalse": {"Foo", "is-array", "", "", false}, "isNotArrayTrue": {sliceOfTags, "is-not-array", "", "", false}, "isNotArrayFalse": {"Foo", "is-not-array", "", "", true}, } for k, tc := range testCases { var m MatchResult var err error expression := Expression{ Key: "key", Op: tc.Op, Value: tc.Value, ValueType: tc.ValueType, } if s, isString := tc.SearchResult.(string); isString { searchResult, err := unmarshal(s) if err != nil { fmt.Println(err) t.Errorf("Unable to parse %s\n", tc.SearchResult) } m, err = isMatch(searchResult, expression) } else { m, err = isMatch(tc.SearchResult, expression) } if err != nil { t.Errorf("%s Failed with error: %s", k, err.Error()) } if m.Match != tc.ExpectedResult { t.Errorf("%s Failed Expected '%s' %s '%s' to be %t", k, tc.SearchResult, tc.Op, tc.Value, tc.ExpectedResult) } } } ================================================ FILE: assertion/rules.go ================================================ package assertion import ( "errors" "github.com/ghodss/yaml" ) // ParseRules converts YAML string content to a Result func ParseRules(rules string) (RuleSet, error) { r := RuleSet{} err := yaml.Unmarshal([]byte(rules), &r) return r, err } // FilterRulesByTag selects a subset of rules based on a tag func FilterRulesByTag(rules []Rule, tags []string) []Rule { filteredRules := make([]Rule, 0) for _, rule := range rules { if tags == nil || listsIntersect(tags, rule.Tags) { filteredRules = append(filteredRules, rule) } } return filteredRules } // FilterRulesByID selectes a subset of rules based on ID func FilterRulesByID(rules []Rule, ruleIDs []string, ignoreRuleIDs []string) []Rule { if len(ruleIDs) == 0 && len(ignoreRuleIDs) == 0 { return rules } filteredRules := make([]Rule, 0) for _, rule := range rules { include := false for _, id := range ruleIDs { if id == rule.ID { include = true } } if len(ignoreRuleIDs) > 0 { include = true for _, id := range ignoreRuleIDs { if id == rule.ID { include = false } } } if include { filteredRules = append(filteredRules, rule) } } return filteredRules } func uniqueRules(list []Rule) []Rule { rules := make([]Rule, 0) keys := make(map[string]bool, 0) for _, rule := range list { if _, ok := keys[rule.ID]; !ok { keys[rule.ID] = true rules = append(rules, rule) } } return rules } // FilterRulesByTagAndID filters by both tag and id func FilterRulesByTagAndID(rules []Rule, tags []string, ruleIds []string, ignoreRuleIds []string) []Rule { if len(tags) == 0 && len(ruleIds) == 0 && len(ignoreRuleIds) == 0 { return rules } if len(tags) == 0 { return FilterRulesByID(rules, ruleIds, ignoreRuleIds) } if len(ruleIds) == 0 { return FilterRulesByTag(rules, tags) } return uniqueRules(append(FilterRulesByID(rules, ruleIds, ignoreRuleIds), FilterRulesByTag(rules, tags)...)) } // ResolveRules loads any dynamic values for a collection or rules func ResolveRules(rules []Rule, valueSource ValueSource) ([]Rule, []Violation) { resolvedRules := []Rule{} violations := []Violation{} for _, rule := range rules { r, vs := ResolveRule(rule, valueSource) resolvedRules = append(resolvedRules, r) violations = append(violations, vs...) } return resolvedRules, violations } // ResolveRule loads any dynamic values for a single Rule func ResolveRule(rule Rule, valueSource ValueSource) (Rule, []Violation) { resolvedRule := rule resolvedRule.Assertions = []Expression{} violations := []Violation{} for _, assertion := range rule.Assertions { value, err := valueSource.GetValue(assertion) if err != nil { Debugf("ResolveRule error: %s\n", err.Error()) violations = append(violations, Violation{ Category: "load", RuleID: "RULE_RESOLVE", ResourceID: rule.ID, ResourceType: "rule", Status: "FAILURE", RuleMessage: "Unable to resolve value in rule", AssertionMessage: err.Error(), CreatedAt: currentTime(), }) } resolvedAssertion := assertion resolvedAssertion.Value = value resolvedAssertion.ValueFrom = ValueFrom{} resolvedRule.Assertions = append(resolvedRule.Assertions, resolvedAssertion) } return resolvedRule, violations } // CheckRule returns a list of violations for a single Rule applied to a single Resource func CheckRule(rule Rule, resource Resource, e ExternalRuleInvoker) (string, []Violation, error) { returnStatus := "OK" violations := make([]Violation, 0) if ExcludeResource(rule, resource) { Debugf("Ignoring resource: %s", resource.ID) return returnStatus, violations, nil } if rule.Invoke.URL != "" { return e.Invoke(rule, resource) } match, err := andExpression(rule.Conditions, resource) if err != nil { return "FAILURE", violations, err } if !match.Match { return returnStatus, violations, nil } for _, ruleAssertion := range rule.Assertions { Debugf("Checking Category: %s, Type: %s, Id: %s\n", resource.Category, resource.Type, resource.ID) expressionResult, err := CheckExpression(rule, ruleAssertion, resource) if err != nil { return "FAILURE", violations, err } if expressionResult.Status != "OK" { // If the rule has category (e.g. Terraform rules), then return violations for that category only. // If the rule has no category it will be applied to all resources as normal. if rule.Category != "" && rule.Category != resource.Category { break } returnStatus = expressionResult.Status v := Violation{ RuleID: rule.ID, ResourceID: resource.ID, ResourceType: resource.Type, Category: resource.Category, Status: expressionResult.Status, RuleMessage: rule.Message, AssertionMessage: expressionResult.Message, Filename: resource.Filename, LineNumber: resource.LineNumber, CreatedAt: currentTime(), } violations = append(violations, v) } } return returnStatus, violations, nil } // Join two RuleSets together func JoinRuleSets(firstSet RuleSet, secondSet RuleSet) (RuleSet, error) { // if one of the sets is empty, return the other // if both are empty, an empty set is returned if len(firstSet.Rules) == 0 { return secondSet, nil } else if len(secondSet.Rules) == 0 { return firstSet, nil } // RuleSets must match Type and Version // Description will be taken from the first given rule set if firstSet.Type != secondSet.Type || firstSet.Version != secondSet.Version { return firstSet, errors.New("RuleSet Type and Version must match") } else { joinedSet := RuleSet{} joinedSet.Type = firstSet.Type joinedSet.Description = firstSet.Description joinedSet.Files = append(firstSet.Files, secondSet.Files...) joinedSet.Rules = append(firstSet.Rules, secondSet.Rules...) joinedSet.Version = firstSet.Version joinedSet.Resources = append(firstSet.Resources, secondSet.Resources...) joinedSet.Columns = append(firstSet.Columns, secondSet.Columns...) return joinedSet, nil } } ================================================ FILE: assertion/rules_test.go ================================================ package assertion import ( "errors" "testing" ) // TestValueSource provides test values type TestValueSource struct{} func (t TestValueSource) GetValue(expression Expression) (string, error) { if expression.Value != "" { return expression.Value, nil } return "m3.medium", nil } func testValueSource() ValueSource { return TestValueSource{} } // TestValueSourceWithError simulates errors for value provider type TestValueSourceWithError struct{} func (t TestValueSourceWithError) GetValue(expression Expression) (string, error) { return "", errors.New("GET_VALUE_ERROR") } func testValueSourceWithError() ValueSource { return TestValueSourceWithError{} } // MockExternalRuleInvoker simulates invocation of external endpoints to get values type MockExternalRuleInvoker int func mockExternalRuleInvoker() *MockExternalRuleInvoker { var m MockExternalRuleInvoker return &m } func (e *MockExternalRuleInvoker) Invoke(Rule, Resource) (string, []Violation, error) { *e++ noViolations := make([]Violation, 0) return "OK", noViolations, nil } var content = `Rules: - id: TEST1 message: Test message resource: aws_instance severity: WARNING assertions: - key: instance_type op: in value: t2.micro tags: - ec2 - id: TEST2 message: Test message resource: aws_s3_bucket severity: WARNING assertions: - key: name op: eq value: bucket1 tags: - s3 - id: TEST3 message: Test message resource: aws_ebs_volume severity: WARNING assertions: - key: size op: le value: 1000 value_type: integer tags: - ebs ` func MustParseRules(content string, t *testing.T) RuleSet { r, err := ParseRules(content) if err != nil { t.Error("Unable to parse:" + content) } return r } func TestParseRules(t *testing.T) { r := MustParseRules(content, t) if len(r.Rules) != 3 { t.Error("Expected to parse 3 rules") } } type FilterTestCase struct { Tags []string Ids []string IgnoreIds []string ExpectedRules []string } func TestFilterRules(t *testing.T) { var emptyTags []string var emptyIds []string testCases := map[string]FilterTestCase{ "allRules": FilterTestCase{emptyTags, emptyIds, emptyIds, []string{"TEST1", "TEST2", "TEST3"}}, "tags": FilterTestCase{[]string{"s3"}, emptyIds, emptyIds, []string{"TEST2"}}, "rules": FilterTestCase{emptyTags, []string{"TEST1"}, emptyIds, []string{"TEST1"}}, "both": FilterTestCase{[]string{"s3"}, []string{"TEST1"}, emptyIds, []string{"TEST1", "TEST2"}}, "overlap": FilterTestCase{[]string{"s3"}, []string{"TEST2"}, emptyIds, []string{"TEST2"}}, "exclude": FilterTestCase{emptyTags, emptyIds, []string{"TEST1"}, []string{"TEST2", "TEST3"}}, } for k, tc := range testCases { r := FilterRulesByTagAndID(MustParseRules(content, t).Rules, tc.Tags, tc.Ids, tc.IgnoreIds) if len(r) != len(tc.ExpectedRules) { t.Errorf("Expected %s to include %d rules not %d\n", k, len(tc.ExpectedRules), len(r)) } } } func TestFilterRulesByTagAndID(t *testing.T) { tags := []string{"s3"} ids := []string{"TEST3"} r := FilterRulesByTagAndID(MustParseRules(content, t).Rules, tags, ids, []string{}) if len(r) != 2 { t.Error("Expected filterRulesByTag to return 2 rules") } for _, rule := range r { if rule.ID != "TEST2" && rule.ID != "TEST3" { t.Error("Expected filterRulesByTagAndID to select correct rules") } } } var ruleWithMultipleFilters = `Rules: - id: TEST1 message: Test message resource: aws_instance severity: FAILURE assertions: - key: instance_type op: eq value: t2.micro - key: ami op: eq value: ami-000000 ` func TestRuleWithMultipleFilter(t *testing.T) { rules := MustParseRules(ruleWithMultipleFilters, t) resource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{"instance_type": "t2.micro", "ami": "ami-000000"}, Filename: "test.tf", } status, violations, err := CheckRule(rules.Rules[0], resource, mockExternalRuleInvoker()) if err != nil { t.Error("Error in CheckRule:" + err.Error()) } if status != "OK" { t.Error("Expecting multiple rule to match") } if len(violations) != 0 { t.Error("Expecting multiple rule to have zero violations") } } func TestMultipleFiltersWithSingleFailure(t *testing.T) { rules := MustParseRules(ruleWithMultipleFilters, t) resource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{"instance_type": "t2.micro", "ami": "ami-111111"}, Filename: "test.tf", } status, violations, err := CheckRule(rules.Rules[0], resource, mockExternalRuleInvoker()) if err != nil { t.Error("Error in CheckRule:" + err.Error()) } if status != "FAILURE" { t.Error("Expecting multiple rule to return FAILURE") } if len(violations) != 1 { t.Error("Expecting multiple rule to have one violation") } } func TestMultipleFiltersWithMultipleFailures(t *testing.T) { rules := MustParseRules(ruleWithMultipleFilters, t) resource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{"instance_type": "c3.medium", "ami": "ami-111111"}, Filename: "test.tf", } status, violations, err := CheckRule(rules.Rules[0], resource, mockExternalRuleInvoker()) if err != nil { t.Error("Error in CheckRule:" + err.Error()) } if status != "FAILURE" { t.Error("Expecting multiple rule to return FAILURE") } if len(violations) != 2 { t.Error("Expecting multiple rule to have two violations") } } var ruleWithValueFrom = `Rules: - id: FROM1 message: Test value_from severity: FAILURE resource: aws_instance assertions: - key: instance_type op: in value_from: bucket: config-rules-for-lambda key: instance-types ` func TestValueFrom(t *testing.T) { rules := MustParseRules(ruleWithValueFrom, t) resource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{"instance_type": "m3.medium"}, Filename: "test.tf", } resolved, violations := ResolveRules(rules.Rules, testValueSource()) if len(violations) != 0 { t.Errorf("Expecting ResolveRules to return 0 violations: %v", violations) } status, violations, err := CheckRule(resolved[0], resource, mockExternalRuleInvoker()) if err != nil { t.Error("Error in CheckRule:" + err.Error()) } if status != "OK" { t.Error("Expecting value_from to match") } if len(violations) != 0 { t.Error("Expecting value_from test to have 0 violations") } } func TestResolveRuleError(t *testing.T) { rules := MustParseRules(ruleWithValueFrom, t) _, violations := ResolveRules(rules.Rules, testValueSourceWithError()) if len(violations) != 1 { t.Errorf("Expecting ResolveRules to return 1 violations: %v", violations) } else { ruleID := violations[0].RuleID if ruleID != "RULE_RESOLVE" { t.Errorf("Expected RULE_RESOLVE violation, not %s", ruleID) } } } var ruleWithInvoke = `Rules: - id: FROM1 message: Test value_from severity: FAILURE resource: aws_instance invoke: url: http://localhost ` func TestInvokeRule(t *testing.T) { rules := MustParseRules(ruleWithInvoke, t) resource := Resource{ ID: "a_test_resource", Type: "aws_instance", Properties: map[string]interface{}{"instance_type": "m3.medium"}, Filename: "test.tf", } resolved, _ := ResolveRules(rules.Rules, testValueSource()) counter := mockExternalRuleInvoker() CheckRule(resolved[0], resource, counter) if *counter != 1 { t.Error("Expecting external rule engine to be invoked") } } ================================================ FILE: assertion/search.go ================================================ package assertion import ( "github.com/jmespath/go-jmespath" ) // SearchData applies a JMESPath to a JSON object func SearchData(expression string, data interface{}) (interface{}, error) { if len(expression) == 0 { return "null", nil } return jmespath.Search(expression, data) } ================================================ FILE: assertion/testdata/collection-assertions.yaml ================================================ --- description: Test collection assertions test_cases: - name: every_OK rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - every: key: "keys(@)" expressions: - key: "@" op: in value: Foo,Bar resource: id: collection_id type: example properties: Foo: - A - B - C Bar: - D - E result: OK - name: every_FAILURE rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - every: key: "keys(@)" expressions: - key: "@" op: in value: Foo,Bar resource: id: collection_id type: example properties: Foo: - A - B - C Bar: - D - E Baz: - F result: FAILURE - name: every_multiple_assertions_FAILURE rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - every: key: locations expressions: - key: city op: present - key: state op: present resource: id: collection_id type: example properties: locations: - city: Seattle state: WA - city: San Francisco result: FAILURE - name: some_OK rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - some: key: "keys(@)" expressions: - key: "@" op: in value: Foo,Bar resource: id: collection_id type: example properties: Foo: - A - B - C Baz: - D - E result: OK - name: some_FAILURE rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - some: key: "keys(@)" expressions: - key: "@" op: in value: Foo,Bar resource: id: collection_id type: example properties: Baz: - A result: FAILURE - name: none_OK rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - none: key: "keys(@)" expressions: - key: "@" op: in value: Foo,Bar resource: id: collection_id type: example properties: Baz: - A - B result: OK - name: none_with_multiple_assertions_OK rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - none: key: "ipPermissions[]" expressions: - key: "fromPort" op: eq value: 22 value_type: integer - key: "ipRanges[]" op: contains value: 0.0.0.0/0 resource: id: collection_id type: sample properties: ipPermissions: - fromPort: 80 ipRanges: - 0.0.0.0/0 result: OK - name: none_FAILURE rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - none: key: "keys(@)" expressions: - key: "@" op: in value: Foo,Bar resource: id: collection_id type: example properties: Foo: - A Bar: - B result: FAILURE - name: none_with_multiple_assertions_FAILURE rule: id: COLLECTION message: Invalid key severity: FAILURE resource: sample assertions: - none: key: "ipPermissions[]" expressions: - key: "fromPort" op: eq value: 22 value_type: integer - key: "ipRanges[]" op: contains value: 0.0.0.0/0 resource: id: collection_id type: sample properties: ipPermissions: - fromPort: 22 ipRanges: - 0.0.0.0/0 result: FAILURE - name: one_OK rule: id: COLLECTION message: Duplicate names severity: FAILURE resource: example assertions: - exactly-one: key: "tags[]" expressions: - key: name op: eq value: A resource: id: collection_id type: example properties: tags: - name: A - name: B result: OK - name: one_FAILURE rule: id: COLLECTION message: Duplicate names severity: FAILURE resource: example assertions: - exactly-one: key: "tags[]" expressions: - key: name op: eq value: B resource: id: collection_id type: example properties: tags: - name: A - name: B - name: B result: FAILURE ================================================ FILE: assertion/testdata/conditions.yaml ================================================ --- description: Test conditions test_cases: - name: conditions_false rule: id: CONDITIONS_1 message: Missing properties severity: FAILURE resource: sample conditions: - key: example.name eq: first assertions: - key: example op: has-properties value: name,id resource: id: p1 type: sample properties: example: name: first id: 1 result: OK - name: conditions_ignore rule: id: PROPERTIES_2 message: Ignore using condition severity: FAILURE resource: sample conditions: - key: example.name op: eq value: second assertions: - key: example op: has-properties value: name,id,description resource: id: p1 type: sample properties: example: name: first id: 1 result: OK - name: conditions_FAILURE rule: id: PROPERTIES_2 message: Missing properties severity: FAILURE resource: sample conditions: - key: example.name op: eq value: first assertions: - key: example op: has-properties value: name,id,description resource: id: p1 type: sample properties: example: name: first id: 1 result: FAILURE ================================================ FILE: assertion/testdata/default-severity.yaml ================================================ --- description: Test uses default severity test_cases: - name: default-severity-FAILURE rule: id: PROPERTIES_1 message: Missing properties resource: sample assertions: - key: example op: has-properties value: name,id resource: id: p1 type: sample properties: example: name: first result: FAILURE ================================================ FILE: assertion/testdata/has-properties.yaml ================================================ --- description: Test has-properties operator test_cases: - name: has-properties_OK rule: id: PROPERTIES_1 message: Missing properties severity: FAILURE resource: sample assertions: - key: example op: has-properties value: name,id resource: id: p1 type: sample properties: example: name: first id: 1 result: OK - name: has-properties_FAILURE rule: id: PROPERTIES_2 message: Missing properties severity: FAILURE resource: sample assertions: - key: example op: has-properties value: name,id,description resource: id: p1 type: sample properties: example: name: first id: 1 result: FAILURE ================================================ FILE: assertion/types.go ================================================ package assertion type ( // Resource describes a resource to be linted Resource struct { ID string `cty:"aws_instance"` Type string Category string // default is "resource", can be "data", "provider" for Terraform Properties interface{} Filename string LineNumber int } // RuleSet describes a collection of rules for a Linter RuleSet struct { Type string Description string Files []string Rules []Rule Version string Resources []ResourceConfig Columns []ColumnConfig Source string } // Rule is part of a RuleSet Rule struct { ID string Message string Severity string Resource string Resources []string ExceptResources []string `json:"except_resources"` Category string // default is "resource", can be "data", "provider", "module" for Terraform Conditions []Expression Assertions []Expression Except []string Tags []string Invoke InvokeRuleAPI } // Expression expression for a Rule Expression struct { Key string Op string Value string ValueType string `json:"value_type"` ValueFrom ValueFrom `json:"value_from"` Or []Expression Xor []Expression And []Expression Not []Expression Every CollectionExpression Some CollectionExpression None CollectionExpression ExactlyOne CollectionExpression `json:"exactly-one"` } // CollectionExpression assertion for every element of a collection CollectionExpression struct { Key string Expressions []Expression } // ValueFrom describes a external source for values ValueFrom struct { URL string Variable string } // InvokeRuleAPI describes an external API for linting a resource InvokeRuleAPI struct { URL string Payload string } // ResourceConfig describes how to discover resouces in a YAML file ResourceConfig struct { ID string Type string Key string } // ColumnConfig describes how to discover resources in a CSV file ColumnConfig struct { Name string } // ValidationReport summarizes validation for resources using rules ValidationReport struct { FilesScanned []string Violations []Violation ResourcesScanned []ScannedResource } // Violation has details for a failed assertion Violation struct { RuleID string ResourceID string ResourceType string Category string Status string RuleMessage string AssertionMessage string Filename string LineNumber int CreatedAt string } // ScannedResource has details for each resource scanned ScannedResource struct { ResourceID string ResourceType string RuleID string Status string Filename string LineNumber int } // ValueSource interface to fetch dynamic values ValueSource interface { GetValue(Expression) (string, error) } // ExternalRuleInvoker defines an interface for invoking an external API ExternalRuleInvoker interface { Invoke(Rule, Resource) (string, []Violation, error) } // MatchResult has a true/false result, but also includes a message for better reporting MatchResult struct { Match bool Message string } // Result returns a status, along with a message Result struct { Status string Message string } ) ================================================ FILE: assertion/util.go ================================================ package assertion import ( "encoding/json" "fmt" "path/filepath" "time" ) func unquoted(s string) string { if s[0] == '"' { return s[1 : len(s)-1] } return s } func quoted(s string) string { return fmt.Sprintf("\"%s\"", s) } func isAbsent(s string) bool { if s == "" || s == "null" || s == "[]" { return true } return false } func isPresent(s string) bool { return !isAbsent(s) } func isEmpty(data interface{}) bool { switch v := data.(type) { case nil: return true case string: return len(v) == 0 case []interface{}: return len(v) == 0 case []map[string]interface{}: return len(v) == 0 default: Debugf("isEmpty default: %v %T\n", data, data) return false } } func isArray(data interface{}) bool { switch data.(type) { case nil: return false case string: return false case []interface{}: return true case []map[string]interface{}: return true default: return false } } func listsIntersect(list1 []string, list2 []string) bool { for _, a := range list1 { for _, b := range list2 { if a == b { return true } } } return false } func jsonListsIntersect(s1 string, s2 string) bool { var a1 []string var a2 []string err := json.Unmarshal([]byte(s1), &a1) if err != nil { return false } err = json.Unmarshal([]byte(s2), &a2) if err != nil { return false } return listsIntersect(a1, a2) } // ShouldIncludeFile return true if a filename matches one of a list of patterns func ShouldIncludeFile(patterns []string, filename string) (bool, error) { if filename == "-" { // always permit stdin return true, nil } for _, pattern := range patterns { _, file := filepath.Split(filename) matched, err := filepath.Match(pattern, file) if err != nil { return false, err } if matched { return true, nil } } return false, nil } // FilterResourcesByType filters a list of resources that match a single resource type func FilterResourcesByType(resources []Resource, resourceType string, resourceCategory string) []Resource { if resourceType == "*" { return resources } filtered := make([]Resource, 0) for _, resource := range resources { if resource.Type == resourceType && categoryMatches(resourceCategory, resource.Category) { filtered = append(filtered, resource) } } return filtered } // FilterResourcesByTypes filters a list of resources that match a slice of resource types func FilterResourcesByTypes(resources []Resource, resourceTypes []string, resourceCategory string) []Resource { filtered := make([]Resource, 0) for _, resource := range resources { if SliceContains(resourceTypes, resource.Type) && categoryMatches(resourceCategory, resource.Category) { filtered = append(filtered, resource) } } return filtered } func categoryMatches(c1, c2 string) bool { if c1 == "" || c1 == "*" { return true } return c1 == c2 } // JSONStringify converts a JSON object into an indented string suitable for printing func JSONStringify(data interface{}) (string, error) { b, err := json.MarshalIndent(data, "", " ") if err != nil { return "", err } return string(b), nil } func currentTime() string { return time.Now().UTC().Format(time.RFC3339) } func SliceContains(list []string, value string) bool { for _, item := range list { if item == value { return true } } return false } // Exclude resources func ExcludeResourceTypes(resources []Resource, resourceTypes []string, resourceCategory string) []Resource { filtered := make([]Resource, 0) for _, resource := range resources { if !SliceContains(resourceTypes, resource.Type) && categoryMatches(resourceCategory, resource.Category) { filtered = append(filtered, resource) } } return filtered } // FilterResourcesForRule returns resources applicable to the given rule func FilterResourcesForRule(resources []Resource, rule Rule) []Resource { if len(rule.Resources) > 0 { Debugf("filtering rule resources on Resources slice") return FilterResourcesByTypes(resources, rule.Resources, rule.Category) } if rule.Resource != "" && rule.Resource != "*" { Debugf("filtering rule resources on Resource string") return FilterResourcesByType(resources, rule.Resource, rule.Category) } if len(rule.ExceptResources) > 0 { Debugf("filtering rule resources on ExceptResources slice") return ExcludeResourceTypes(resources, rule.ExceptResources, rule.Category) } // default is to match all resources return resources } ================================================ FILE: assertion/util_test.go ================================================ package assertion import ( "strings" "testing" ) func TestUnquotedWithoutQuotes(t *testing.T) { if unquoted("Foo") != "Foo" { t.Errorf("Unquoted for not quoted string fails") } } func TestUnquotedWithQuotes(t *testing.T) { if unquoted("\"Foo\"") != "Foo" { t.Errorf("Unquoted for quoted string fails") } } func TestIsAbsentEmptyString(t *testing.T) { if isAbsent("") != true { t.Errorf("isAbsent for empty string fails") } } func TestIsAbsentEmptyArray(t *testing.T) { if isAbsent("[]") != true { t.Errorf("isAbsent for empty array fails") } } func TestIsAbsentNull(t *testing.T) { if isAbsent("null") != true { t.Errorf("isAbsent for null fails") } } func TestIsAbsentFalse(t *testing.T) { if isAbsent("something") != false { t.Errorf("isAbsent for value fails") } } func TestIntersectTrue(t *testing.T) { a := []string{"foo", "bar"} b := []string{"bar", "baz"} if listsIntersect(a, b) != true { t.Errorf("listsIntersect should return true fails") } } func TestIntersectFalse(t *testing.T) { a := []string{"foo", "bar"} b := []string{"baz"} if listsIntersect(a, b) != false { t.Errorf("listsIntersect should return false fails") } } func TestJSONListsIntersectTrue(t *testing.T) { s1 := "[ \"foo\", \"bar\" ]" s2 := "[ \"baz\", \"bar\" ]" if jsonListsIntersect(s1, s2) != true { t.Errorf("JSONIntersect should return true") } } func TestShouldIncludeFile(t *testing.T) { patterns := []string{"*.tf", "*.yml"} include, err := ShouldIncludeFile(patterns, "instance.tf") if err != nil { t.Errorf("ShouldIncludeFile generated an unexpected error: %v", err) } if !include { t.Errorf("ShouldIncludeFile failed to include file with matching pattern") } } func TestShouldNotIncludeFile(t *testing.T) { patterns := []string{"*.tf", "*.yml"} include, err := ShouldIncludeFile(patterns, "instance.config") if err != nil { t.Errorf("ShouldIncludeFile generated an unexpected error: %v", err) } if include { t.Errorf("ShouldIncludeFile failed to exclude file with no matching pattern") } } func TestFilterShouldIncludeResources(t *testing.T) { resources := []Resource{ Resource{Type: "instance"}, Resource{Type: "volume"}, } filtered := FilterResourcesByType(resources, "instance", "*") if len(filtered) != 1 { t.Errorf("FilterResourcesByType expected to match one resource") } } func TestFilterShouldExcludeResources(t *testing.T) { resources := []Resource{ Resource{Type: "instance"}, Resource{Type: "volume"}, } filtered := FilterResourcesByType(resources, "database", "*") if len(filtered) != 0 { t.Errorf("FilterResourcesByType expected to match no resources") } } func TestFilterShouldIncludeAllResources(t *testing.T) { resources := []Resource{ Resource{Type: "instance"}, Resource{Type: "volume"}, } filtered := FilterResourcesByType(resources, "*", "*") if len(filtered) != len(resources) { t.Errorf("FilterResourcesByType expected to include all resources") } } func TestFilterShouldMatchCategoryForResources(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "template_file", Category: "data"}, } filtered := FilterResourcesByType(resources, "template_file", "data") if len(filtered) != 1 { t.Errorf("FilterResourcesByType expected to match one resource") } } func TestSliceContainsTrue(t *testing.T) { test := []string{"x", "y", "z"} isPresent := SliceContains(test, "x") if isPresent != true { t.Errorf("SliceContains expected to return true when a value is present") } } func TestSliceContainsFalse(t *testing.T) { test := []string{"x", "y", "z"} isPresent := SliceContains(test, "a") if isPresent != false { t.Errorf("SliceContains expected to return false when a value is not present") } } func TestFilterPluralShouldMatchMultipleResources(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } filtered := FilterResourcesByTypes(resources, []string{"instance", "bucket"}, "resource") if len(filtered) != 2 { t.Errorf("FilterResourcesByTypes expected to match multiple types") } } func TestFilterPluralShouldNotHaveUnlistedResources(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } resourceTypes := []string{"instance"} filtered := FilterResourcesByTypes(resources, resourceTypes, "resource") if len(filtered) != 1 { t.Errorf("FilterResourcesByTypes expected to match only %s", strings.Join(resourceTypes, ", ")) } } func TestFilterResourcesForRuleSlice(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } rule := Rule{ Resources: []string{ "instance", "bucket", }, } filtered := FilterResourcesForRule(resources, rule) if len(filtered) != 2 { t.Errorf("FilterResourcesForRule expected to return both resource types") } } func TestFilterResourcesForRuleString(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } rule := Rule{ Resource: "instance", } filtered := FilterResourcesForRule(resources, rule) if len(filtered) != 1 { t.Errorf("FilterResourcesForRule only expected to return one type") } } func TestFilterResourcesForWildcard(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } rule := Rule{ Resource: "*", } filtered := FilterResourcesForRule(resources, rule) if len(filtered) != 2 { t.Errorf("FilterResourcesForRule expected all resources to match") } } func TestFilterResourcesForDefault(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } rule := Rule{} filtered := FilterResourcesForRule(resources, rule) if len(filtered) != 2 { t.Errorf("FilterResourcesForRule expected all resources to match") } } func TestFilterExcludeResourcesForRuleString(t *testing.T) { resources := []Resource{ Resource{Type: "instance", Category: "resource"}, Resource{Type: "bucket", Category: "resource"}, } rule := Rule{ ExceptResources: []string{ "instance", "security_group", }, } filtered := FilterResourcesForRule(resources, rule) if len(filtered) != 1 { t.Errorf("FilterResourcesForRule expected to return one type") } if len(filtered) > 0 && filtered[0].Type != "bucket" { t.Errorf("FilterResourcesForRule expected to return bucket") } } ================================================ FILE: assertion/value.go ================================================ package assertion import ( "bytes" "errors" "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "io/ioutil" "net/http" "net/url" "strings" ) // StandardValueSource can fetch values from external sources type StandardValueSource struct { Variables map[string]string } // GetValue looks up external values when an Expression includes a ValueFrom attribute func (v StandardValueSource) GetValue(expression Expression) (string, error) { if expression.ValueFrom.URL != "" { Debugf("Getting value_from %s\n", expression.ValueFrom.URL) parsedURL, err := url.Parse(expression.ValueFrom.URL) if err != nil { return "", err } switch strings.ToLower(parsedURL.Scheme) { case "s3": return v.GetValueFromS3(parsedURL.Host, parsedURL.Path) case "http": return v.GetValueFromHTTP(expression.ValueFrom.URL) case "https": return v.GetValueFromHTTP(expression.ValueFrom.URL) default: return "", fmt.Errorf("Unsupported protocol for value_from: %s", parsedURL.Scheme) } } if expression.ValueFrom.Variable != "" { if value, ok := v.Variables[expression.ValueFrom.Variable]; ok { Debugf("Getting value_from variable %s: %s\n", expression.ValueFrom.Variable, value) return value, nil } Debugf("Getting value_from variable %s not found\n", expression.ValueFrom.Variable) return expression.ValueFrom.Variable, nil // or should this throw an error? } return expression.Value, nil } // GetValueFromS3 looks up external values for an Expression when the S3 protocol is specified func (v StandardValueSource) GetValueFromS3(bucket string, key string) (string, error) { region, err := getBucketRegion(bucket) if err != nil { message := fmt.Sprintf("Cannot get region for bucket %s: %s", bucket, err.Error()) return "", errors.New(message) } config := &aws.Config{Region: aws.String(region)} awsSession := session.New() s3Client := s3.New(awsSession, config) response, err := s3Client.GetObject(&s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) if err != nil { message := fmt.Sprintf("Cannot read bucket %s key %s: %s", bucket, key, err.Error()) return "", errors.New(message) } buf := new(bytes.Buffer) buf.ReadFrom(response.Body) value := strings.TrimSpace(buf.String()) Debugf("Value from bucket %s key %s in region %s: %s\n", bucket, key, region, value) return value, nil } func getBucketRegion(bucket string) (string, error) { awsSession := session.New() s3Client := s3.New(awsSession) location, err := s3Client.GetBucketLocation(&s3.GetBucketLocationInput{ Bucket: aws.String(bucket), }) if err != nil { return "us-east-1", err } if location.LocationConstraint == nil { // default region is us-east-1 return "us-east-1", nil } return *location.LocationConstraint, nil } // GetValueFromHTTP looks up external value for an Expression when the HTTP protocol is specified func (v StandardValueSource) GetValueFromHTTP(url string) (string, error) { httpResponse, err := http.Get(url) if err != nil { return "", err } if httpResponse.StatusCode != 200 { return "", err } defer httpResponse.Body.Close() body, err := ioutil.ReadAll(httpResponse.Body) if err != nil { return "", err } return strings.TrimSpace(string(body)), nil } ================================================ FILE: assertion/value_test.go ================================================ package assertion import ( "fmt" "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "testing" ) func TestCommandLineVariable(t *testing.T) { s := StandardValueSource{ Variables: map[string]string{"foo": "bar"}, } e := Expression{ ValueFrom: ValueFrom{Variable: "foo"}, } v, err := s.GetValue(e) if err != nil { t.Errorf("Expected GetValue to return without error: %v\n", err.Error()) } if v != "bar" { t.Errorf("Expected GetValue to find variable 'foo' with value 'bar', not '%s'\n", v) } } func TestValueFromHttp(t *testing.T) { cidrBlock := "0.0.0.0/0" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, cidrBlock) })) defer ts.Close() s := StandardValueSource{} e := Expression{ ValueFrom: ValueFrom{URL: ts.URL}, } v, err := s.GetValue(e) assert.Nil(t, err, "Expecting GetValue to not return an error") assert.Equal(t, cidrBlock, v, "Expecting CIDR value to be returned") } ================================================ FILE: cli/app.go ================================================ package main //go:generate packr -v import ( "encoding/json" "errors" "fmt" "io" "io/ioutil" "os" "path/filepath" "strings" "github.com/ghodss/yaml" "github.com/gobuffalo/packr" "github.com/stelligent/config-lint/assertion" "github.com/stelligent/config-lint/linter" ) var version string type ( // LinterOptions for applying rules LinterOptions struct { Tags []string RuleIDs []string IgnoreRuleIDs []string QueryExpression string SearchExpression string ExcludePatterns []string Variables map[string]string TerraformParser string } // ProfileOptions for default options from a project file ProfileOptions struct { Rules []string IDs []string IgnoreIDs []string `json:"ignore_ids"` Tags []string Query string Files []string Terraform bool Exceptions []RuleException Variables map[string]string ExcludePatterns []string `json:"exclude"` ExcludeFromFilenames []string `json:"exclude_from"` } // RuleException optional list allowing a project to ignore specific rules for specific resources RuleException struct { RuleID string ResourceCategory string ResourceType string ResourceID string Comments string } // CommandLineOptions for collecting options from the command line CommandLineOptions struct { RulesFilenames arrayFlags ExcludePatterns arrayFlags ExcludeFromFilenames arrayFlags Variables arrayFlags TerraformParser *string ProfileFilename *string TerraformBuiltInRules *bool Tags *string Ids *string IgnoreIds *string QueryExpression *string VerboseReport *bool SearchExpression *string Validate *bool Version *bool Debug *bool Args []string } // ReportWriter formats and displays a ValidationReport ReportWriter interface { WriteReport(assertion.ValidationReport, LinterOptions) } // DefaultReportWriter writes the report to Stdout DefaultReportWriter struct { Writer io.Writer } ) func main() { commandLineOptions := getCommandLineOptions() if *commandLineOptions.Version == true { fmt.Println(version) return } if *commandLineOptions.Debug == true { assertion.SetDebug(true) } if *commandLineOptions.Validate { exitCode, err := validateRules(commandLineOptions.Args, DefaultReportWriter{Writer: os.Stdout}) if err != nil { fmt.Println(err.Error()) } os.Exit(exitCode) } profileOptions, err := loadProfile(*commandLineOptions.ProfileFilename) if err != nil { fmt.Printf("Error loading profile: %v\n", err) os.Exit(-1) } rulesFilenames := loadFilenames(commandLineOptions.RulesFilenames, profileOptions.Rules) configFilenames := defaultToCurrentDirectory(loadFilenames(commandLineOptions.Args, profileOptions.Files)) useTerraformBuiltInRules := *commandLineOptions.TerraformBuiltInRules || profileOptions.Terraform if err != nil { fmt.Printf("Unable to load exclude patterns: %s\n", err) os.Exit(-1) } linterOptions, err := getLinterOptions(commandLineOptions, profileOptions) if err != nil { fmt.Printf("Failed to parse options: %v\n", err) os.Exit(-1) } ruleSets, err := loadRuleSets(rulesFilenames) if err != nil { fmt.Printf("Failed to load rules: %v\n", err) os.Exit(-1) } // Same rule set applies to both TerraformBuiltInRules and Terraform11BuiltInRules // loadBuiltInRuleSet can be called recursively against a directory, as done here, // or can be called against a single file, as done with lint-rule.yml if useTerraformBuiltInRules { builtInRuleSet, err := loadBuiltInRuleSet("terraform/") if err != nil { fmt.Printf("Failed to load built-in rules for Terraform: %v\n", err) os.Exit(-1) } ruleSets = append(ruleSets, builtInRuleSet) } if len(ruleSets) == 0 { fmt.Println("No rules") os.Exit(-1) } ruleSets = addExceptions(ruleSets, profileOptions.Exceptions) os.Exit(applyRules(ruleSets, configFilenames, linterOptions, DefaultReportWriter{Writer: os.Stdout})) } func addExceptions(ruleSets []assertion.RuleSet, exceptions []RuleException) []assertion.RuleSet { sets := []assertion.RuleSet{} for _, ruleSet := range ruleSets { sets = append(sets, addExceptionsToRuleSet(ruleSet, exceptions)) } return sets } func addExceptionsToRuleSet(ruleSet assertion.RuleSet, exceptions []RuleException) assertion.RuleSet { rules := []assertion.Rule{} for _, rule := range ruleSet.Rules { for _, e := range exceptions { if rule.ID == e.RuleID && resourceMatch(rule, e) && categoryMatch(rule, e) { rule.Except = append(rule.Except, e.ResourceID) } } rules = append(rules, rule) } ruleSet.Rules = rules return ruleSet } func resourceMatch(rule assertion.Rule, exception RuleException) bool { if assertion.SliceContains(rule.Resources, exception.ResourceType) || rule.Resource == exception.ResourceType { return true } return false } func categoryMatch(rule assertion.Rule, exception RuleException) bool { return rule.Category == exception.ResourceCategory || exception.ResourceCategory == "resources" || rule.Category == "" } func validateRules(filenames []string, w ReportWriter) (int, error) { builtInRuleSet, err := loadBuiltInRuleSet("lint-rules.yml") if err != nil { return -1, err } ruleSets := []assertion.RuleSet{builtInRuleSet} linterOptions := LinterOptions{ QueryExpression: "Violations[]", } return applyRules(ruleSets, filenames, linterOptions, w), nil } func loadRuleSets(args arrayFlags) ([]assertion.RuleSet, error) { rulesFilenames := yamlFilesOnly(getFilenames(args)) ruleSets := []assertion.RuleSet{} for _, rulesFilename := range rulesFilenames { rulesContent, err := ioutil.ReadFile(rulesFilename) if err != nil { return ruleSets, err } ruleSet, err := assertion.ParseRules(string(rulesContent)) if err != nil { return ruleSets, err } ruleSet.Source = rulesFilename ruleSets = append(ruleSets, ruleSet) } return ruleSets, nil } func isYamlFile(filename string) bool { configPatterns := []string{"*yml", "*.yaml"} match, _ := assertion.ShouldIncludeFile(configPatterns, filename) return match } func yamlFilesOnly(filenames []string) []string { configFiles := []string{} for _, filename := range filenames { match := isYamlFile(filename) if match { configFiles = append(configFiles, filename) } } return configFiles } // Takes a name of a rule YAML file or a directory containing YAML rules // Returns a RuleSet of all rules in that file or directory func loadBuiltInRuleSet(filename string) (assertion.RuleSet, error) { ruleSet := assertion.RuleSet{} box := packr.NewBox("./assets") assertion.Debugf("Looking for file %v in Box: %v\n", filename, box) var err error if isYamlFile(filename) && box.Has(filename) { ruleSet, err = addRuleSet(ruleSet, box, filename) if err != nil { assertion.Debugf("Failed to add RuleSet: %v\n", err) return assertion.RuleSet{}, err // returns empty rule set } } else if strings.HasSuffix(filename, "/") { filesInBox := box.List() if len(filesInBox) > 0 { // Get each file in that box for _, fileInBox := range filesInBox { // Check if file is YAML and starts with the folder name assertion.Debugf("Box File: %v\n", fileInBox) if isYamlFile(fileInBox) && strings.HasPrefix(fileInBox, filename) { assertion.Debugf("Adding rule set: %v\n", fileInBox) ruleSet, err = addRuleSet(ruleSet, box, fileInBox) if err != nil { assertion.Debugf("Failed to add RuleSet: %v\n", err) return assertion.RuleSet{}, err // returns empty rule set } } } } } else { return assertion.RuleSet{}, errors.New("File or directory doesnt exist") } return ruleSet, nil } func addRuleSet(ruleSet assertion.RuleSet, box packr.Box, filename string) (assertion.RuleSet, error) { // Get RuleSet from file newRuleSet, err := getRuleSet(box, filename) if err != nil { assertion.Debugf("Failed to get RuleSet: %v\n", err) return assertion.RuleSet{}, err // returns empty rule set } // Join with existing rule sets ruleSet, err = assertion.JoinRuleSets(ruleSet, newRuleSet) if err != nil { assertion.Debugf("Failed to join RuleSets: %v\n", err) return assertion.RuleSet{}, err // returns empty rule set } return ruleSet, nil } // Given a packr box and rule file in that box, // build and return a RuleSet func getRuleSet(box packr.Box, name string) (assertion.RuleSet, error) { rulesContent, err := box.FindString(name) if err != nil { assertion.Debugf("Failed to find filename string in box: %v\n", err) return assertion.RuleSet{}, err } ruleSet, err := assertion.ParseRules(string(rulesContent)) if err != nil { assertion.Debugf("Failed to parse rules from file: %v\n", err) return assertion.RuleSet{}, err } return ruleSet, nil } func applyRules(ruleSets []assertion.RuleSet, args arrayFlags, options LinterOptions, w ReportWriter) int { report := assertion.ValidationReport{ Violations: []assertion.Violation{}, FilesScanned: []string{}, ResourcesScanned: []assertion.ScannedResource{}, } tfParser := options.TerraformParser filenames := excludeFilenames(getFilenames(args), options.ExcludePatterns) vs := assertion.StandardValueSource{Variables: options.Variables} for _, ruleSet := range ruleSets { l, err := linter.NewLinter(ruleSet, vs, filenames, tfParser) if err != nil { fmt.Println(err) return -1 } if l != nil { if options.SearchExpression != "" { l.Search(ruleSet, options.SearchExpression, os.Stdout) } else { options := linter.Options{ Tags: options.Tags, RuleIDs: options.RuleIDs, IgnoreRuleIDs: options.IgnoreRuleIDs, } r, err := l.Validate(ruleSet, options) if err != nil { fmt.Println("Validate failed:", err) } report = linter.CombineValidationReports(report, r) } } } w.WriteReport(report, options) return generateExitCode(report) } func printReport(w io.Writer, report assertion.ValidationReport, queryExpression string) error { jsonData, err := json.MarshalIndent(report, "", " ") if err != nil { return err } if queryExpression != "" { var data interface{} err = yaml.Unmarshal(jsonData, &data) if err != nil { return err } v, err := assertion.SearchData(queryExpression, data) if err != nil { return err } s, err := assertion.JSONStringify(v) if err == nil && s != "null" { fmt.Fprintln(w, s) } } else { fmt.Fprintln(w, string(jsonData)) } return nil } type arrayFlags []string func (i *arrayFlags) String() string { if i != nil { return strings.Join(*i, ",") } return "" } func (i *arrayFlags) Set(value string) error { *i = append(*i, value) return nil } func generateExitCode(report assertion.ValidationReport) int { for _, v := range report.Violations { if v.Status == "FAILURE" { return -1 } } return 0 } func loadFilenames(commandLineFilenames []string, profileFilenames []string) []string { if len(commandLineFilenames) > 0 { return commandLineFilenames } if len(profileFilenames) > 0 { return profileFilenames } return []string{} } func defaultToCurrentDirectory(filenames []string) []string { if len(filenames) == 0 { return []string{"."} } return filenames } func excludeFilenames(filenames []string, excludePatterns []string) []string { assertion.Debugf("Exclude patterns: %v\n", excludePatterns) filteredFilenames := []string{} for _, filename := range filenames { if !excludeFilename(filename, excludePatterns) { filteredFilenames = append(filteredFilenames, filename) } } return filteredFilenames } func excludeFilename(filename string, excludePatterns []string) bool { for _, pattern := range excludePatterns { match, _ := filepath.Match(pattern, filename) if match { assertion.Debugf("Excluding file: %s using pattern: %s\n", filename, pattern) return true } } return false } func getFilenames(args []string) []string { filenames := []string{} for _, arg := range args { if arg == "-" { filenames = append(filenames, arg) continue } fi, err := os.Stat(arg) if err != nil { // append as is, error reported later when file cannot be opened filenames = append(filenames, arg) continue } mode := fi.Mode() if mode.IsDir() { filenames = append(filenames, getFilesInDirectory(arg)...) } else { filenames = append(filenames, arg) } } return filenames } func getFilesInDirectory(root string) []string { directoryFiles := []string{} err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { fmt.Printf("Error processing %s: %s\n", path, err) return err } if !info.IsDir() { directoryFiles = append(directoryFiles, path) } return nil }) if err != nil { fmt.Printf("Error walking directory %s: %s\n", root, err) } return directoryFiles } ================================================ FILE: cli/app_test.go ================================================ package main import ( "bytes" "testing" "github.com/gobuffalo/packr" "github.com/stelligent/config-lint/assertion" "github.com/stelligent/config-lint/linter" "github.com/stretchr/testify/assert" ) func TestLoadTerraformRules(t *testing.T) { _, err := loadBuiltInRuleSet("terraform/") if err != nil { t.Errorf("Cannot load built-in Terraform rules") } } func TestLoadValidateRules(t *testing.T) { _, err := loadBuiltInRuleSet("lint-rules.yml") if err != nil { t.Errorf("Cannot load built-in rules for -validate option") } } func TestExcludeAll(t *testing.T) { filenames := []string{"file1.tf", "file2.tf", "file3.tf"} patterns := []string{"*.tf"} filtered := excludeFilenames(filenames, patterns) if len(filtered) != 0 { t.Errorf("Expecting all files to be excluded, but files are %v", filtered) } } func TestExcludeSubdirectory(t *testing.T) { filenames := []string{"file1.tf", "foo/bar/secrets/database.yml"} patterns := []string{"foo/bar/secrets/*"} filtered := excludeFilenames(filenames, patterns) if len(filtered) != 1 { t.Errorf("Expecting secrets subdirectory to be excluded, but files are %v", filtered) } } func TestExcludeOnePattern(t *testing.T) { filenames := []string{"file1.tf", "file2.tf", "file3.tf"} patterns := []string{"*1.tf"} filtered := excludeFilenames(filenames, patterns) if len(filtered) != 2 { t.Errorf("Expecting one file to be excluded, but files are %v", filtered) } } func TestExcludeMultiplePattern(t *testing.T) { filenames := []string{"file1.tf", "file2.tf", "file3.tf"} patterns := []string{"*1.tf", "*2.tf"} filtered := excludeFilenames(filenames, patterns) if len(filtered) != 1 { t.Errorf("Expecting two files to be excluded, but files are %v", filtered) } } func TestExcludeFrom(t *testing.T) { excludeFromFilenames := []string{"./testdata/exclude-list"} patterns, err := loadExcludePatterns([]string{}, excludeFromFilenames) if err != nil { t.Errorf("Expecting loadExcludePatterns returned error: %s", err.Error()) } if len(patterns) != 2 { t.Errorf("Expecting to load 2 patterns from excludeFromFilenames, not %v", patterns) } if patterns[0] != "*1.tf" { t.Errorf("Expecting first pattern from file to be '*1.tf', not '%s'", patterns[0]) } if patterns[1] != "*2.tf" { t.Errorf("Expecting second pattern from file to be '*2.tf', not '%s'", patterns[1]) } } func TestProfileExceptions(t *testing.T) { filenames := []string{"./testdata/terraform.yml"} ruleSets, err := loadRuleSets(filenames) if err != nil { t.Errorf("Expecting loadRuleSets to not return error: %s", err.Error()) } profileExceptions := []RuleException{ { RuleID: "RULE_1", ResourceCategory: "resource", ResourceType: "aws_instance", Comments: "Testing", ResourceID: "my-special-resource", }, } ruleSets = addExceptions(ruleSets, profileExceptions) ruleExceptions := ruleSets[0].Rules[0].Except if len(ruleExceptions) != 1 { t.Errorf("Expecting Rule.Except to have one ID: %v", ruleExceptions) return } id := ruleExceptions[0] if id != "my-special-resource" { t.Errorf("Unexpected ResourceID found in Except: %s", id) } } func TestBuiltRules(t *testing.T) { ruleSet, err := loadBuiltInRuleSet("lint-rules.yml") if err != nil { t.Errorf("Expecting loadBuiltInRuleSet to not return error: %s", err.Error()) } vs := assertion.StandardValueSource{} // Get all rule files from the assets box box := packr.NewBox("./assets") allFilenames := box.List() var filenames []string for _, filename := range allFilenames { if isYamlFile(filename) && !isTestCase(filename) { filenames = append(filenames, "assets/"+filename) } } l, err := linter.NewLinter(ruleSet, vs, filenames, "") if err != nil { t.Errorf("Expecting NewLinter to not return error: %s", err.Error()) } options := linter.Options{} report, err := l.Validate(ruleSet, options) if err != nil { t.Errorf("Expecting Validate to not return error: %s", err.Error()) } if len(report.Violations) != 0 { t.Errorf("Expecting Validate for built in rules to not report any violations: %v", report.Violations) } } func TestPrintReport(t *testing.T) { r := assertion.ValidationReport{} var b bytes.Buffer err := printReport(&b, r, "") assert.Nil(t, err, "Expecting printReport to run without error") assert.Contains(t, b.String(), "FilesScanned\": null") assert.Contains(t, b.String(), "ResourcesScanned\": null") assert.Contains(t, b.String(), "Violations\": null") } func TestPrintReportWithQueryString(t *testing.T) { r := assertion.ValidationReport{ Violations: []assertion.Violation{ assertion.Violation{RuleMessage: "Houston, we have a problem"}, }, } var b bytes.Buffer err := printReport(&b, r, "Violations[]") assert.Nil(t, err, "Expecting printReport to run without error") assert.Contains(t, b.String(), "RuleMessage") assert.NotContains(t, b.String(), "Violations") assert.NotContains(t, b.String(), "FilesScanned") assert.NotContains(t, b.String(), "ResourcesScanned") } type MockReportWriter struct { Report assertion.ValidationReport } func (w MockReportWriter) WriteReport(r assertion.ValidationReport, o LinterOptions) { w.Report = r } func TestApplyRules(t *testing.T) { ruleSets := []assertion.RuleSet{ assertion.RuleSet{ Type: "JSON", }, } args := arrayFlags{} options := LinterOptions{} w := MockReportWriter{} exitCode := applyRules(ruleSets, args, options, w) assert.Equal(t, exitCode, 0, "Expecting applyRules to return 0") assert.Empty(t, w.Report.Violations, "Expecting empty report") } func TestValidateRules(t *testing.T) { filenames := []string{"./testdata/has-properties.yml"} w := MockReportWriter{} validateRules(filenames, w) assert.Empty(t, w.Report.Violations, "Expecting empty report for validateRules") } func TestResourceMatch(t *testing.T) { testRule := []assertion.Rule{ { ID: "RULE_1", Category: "resource", Resources: []string{"aws_instance", "aws_s3_bucket"}, }, { ID: "RULE_2", Category: "resource", Resource: "aws_s3_bucket", }, } profileExceptions := []RuleException{ { RuleID: "RULE_1", ResourceCategory: "resource", ResourceType: "aws_instance", Comments: "Testing", ResourceID: "my-special-resource", }, { RuleID: "RULE_2", ResourceCategory: "resources", ResourceType: "aws_s3_bucket", Comments: "Testing", ResourceID: "my-special-bucket", }, { RuleID: "RULE_2", ResourceCategory: "resources", ResourceType: "aws_vpc", Comments: "Should not match", ResourceID: "my-vpc", }, } assert.True(t, resourceMatch(testRule[0], profileExceptions[0]), "Expecting exception resource to be found in rule resources") assert.True(t, resourceMatch(testRule[1], profileExceptions[1]), "Expecting one to one match with exception resource and rule resource") assert.False(t, resourceMatch(testRule[1], profileExceptions[2]), "Expecting rule and exception to not match") } func TestLoadRuleSetsBadFilename(t *testing.T) { args := []string{"no-such-file.yml"} _, err := loadRuleSets(args) assert.NotNil(t, err, "LoadRuleSet with bad filename should return an error") } func TestLoadRuleSetsParseErrors(t *testing.T) { args := []string{"./testdata/syntax-errors.yml"} _, err := loadRuleSets(args) assert.NotNil(t, err, "Expecting rules file with syntax errors to fail") if err != nil { assert.Contains(t, err.Error(), "error unmarshaling JSON") } } func TestStdinFilename(t *testing.T) { filenames := getFilenames([]string{"-"}) assert.Len(t, filenames, 1, "getFilenames should file 1 file") assert.Equal(t, filenames[0], "-", "getFilenames should allow - for stdin") } func TestGetFilenamesUsingDirectory(t *testing.T) { filenames := getFilenames([]string{"./testdata/dirtest"}) assert.Len(t, filenames, 2) assert.Equal(t, "testdata/dirtest/a.yml", filenames[0]) assert.Equal(t, "testdata/dirtest/b.yml", filenames[1]) } func TestLoadFilenamesFromCommandLine(t *testing.T) { commandLineFilenames := []string{"command.yml"} profileFilenames := []string{"default.yml"} result := loadFilenames(commandLineFilenames, profileFilenames) assert.Equal(t, result, commandLineFilenames) } func TestLoadFilenamesFromProfile(t *testing.T) { commandLineFilenames := []string{} profileFilenames := []string{"default.yml"} result := loadFilenames(commandLineFilenames, profileFilenames) assert.Equal(t, result, profileFilenames) } func TestArrayFlags(t *testing.T) { var f arrayFlags assert.Equal(t, "", f.String(), "Default arrayFlags should return empty string") f.Set("first") f.Set("second") assert.Equal(t, arrayFlags{"first", "second"}, f, "Expecting arrayFlags to have two elements") } func TestLoadBuiltInRuleSetMissing(t *testing.T) { _, err := loadBuiltInRuleSet("missing.yml") assert.Contains(t, err.Error(), "File or directory doesnt exist", "loadBuiltInRuleSet should fail for missing file") } ================================================ FILE: cli/assets/lint-rules.yml ================================================ --- version: 1 description: Rules for config-lint type: LintRules files: - "*.yml" rules: - id: VALID_TYPE message: Not a valid linter type resource: LintRuleSet severity: FAILURE assertions: - key: type op: in value: Terraform,Terraform12,Kubernetes,LintRules,YAML,JSON,CSV - id: VALID_VERSION message: RuleSet must have a supported version resource: LintRuleSet severity: WARNING assertions: - key: version op: eq value: 1 - id: HAS_RULES message: RuleSet needs at least one rule resource: LintRuleSet severity: WARNING assertions: - key: rules op: not-empty - id: YAML_RULES_HAVE_RESOURCES_SECTION message: RuleSet for YAML required resources section resource: LintRuleSet severity: FAILURE conditions: - key: type op: eq value: YAML assertions: - key: resources op: present - id: EVERY_RULE_HAS_ID message: Event rule in rule set must have an id resource: LintRuleSet severity: FAILURE assertions: - every: key: rules expressions: - key: id op: present - id: ID_PRESENT message: Rule must have an ID resource: LintRule severity: FAILURE assertions: - key: id op: present - id: RESOURCE_PRESENT message: Rule must have a resource, resources or except_resources attribute severity: FAILURE resource: LintRule assertions: - or: - key: resource op: present - key: resources op: present - key: except_resources op: present tags: - resource - id: ASSERTIONS_OR_INVOKE message: Rule must have assertions or invoke resource: LintRule severity: FAILURE assertions: - or: - key: assertions op: present - key: invoke op: present - id: VALID_EXPRESSION message: "These are mutually exclusive in the same expression: key,or,xor,and,not,every,some,none" resource: LintRule severity: FAILURE assertions: - every: key: "assertions[]" expressions: - xor: - key: "@" op: has-properties value: key,op - key: "@" op: has-properties value: or - key: "@" op: has-properties value: xor - key: "@" op: has-properties value: and - key: "@" op: has-properties value: not - key: "@" op: has-properties value: every - key: "@" op: has-properties value: some - key: "@" op: has-properties value: none - key: "@" op: has-properties value: exactly-one ================================================ FILE: cli/assets/terraform/aws/api_gateway/api_gateway_domain_name/security_policy/rule.yml ================================================ --- version: 1 description: Terraform rules type: Terraform files: - "*.tf" - "*.tfvars" rules: - id: API_GW_DOMAIN_SECURITY_POLICY_TLS1_2 message: API Gateway domain name must use TLS 1.2 resource: aws_api_gateway_domain_name severity: FAILURE assertions: - key: security_policy op: eq value: "TLS_1_2" tags: - api_gateway ================================================ FILE: cli/assets/terraform/aws/api_gateway/api_gateway_domain_name/security_policy/tests/terraform12/security_policy.tf ================================================ # Test that an api_gateway_domain_name is using TLS 1.2 # https://www.terraform.io/docs/providers/aws/r/api_gateway_domain_name.html#security_policy provider "aws" { region = "us-east-1" } # PASS: security_policy is set to TLS 1.2 resource "aws_api_gateway_domain_name" "api_gw_domain_using_tls1_2" { domain_name = "api.example.com" endpoint_configuration { types = ["REGIONAL"] } security_policy = "TLS_1_2" } # FAIL: security_policy is not defined resource "aws_api_gateway_domain_name" "api_gw_security_policy_not_set" { domain_name = "api.example.com" endpoint_configuration { types = ["REGIONAL"] } } # FAIL: security_policy is set to TLS 1.0 resource "aws_api_gateway_domain_name" "api_gw_domain_using_tls1_0" { domain_name = "api.example.com" endpoint_configuration { types = ["REGIONAL"] } security_policy = "TLS_1_0" } ================================================ FILE: cli/assets/terraform/aws/api_gateway/api_gateway_domain_name/security_policy/tests/test.yml ================================================ --- version: 1 description: Terraform 11 and 12 tests type: Terraform files: - "*.tf" - "*.tfvars" tests: - ruleId: API_GW_DOMAIN_SECURITY_POLICY_TLS1_2 warnings: 0 failures: 2 tags: - "terraform12" ================================================ FILE: cli/assets/terraform/aws/batch/batch_job_definition/aws_secrets/rule.yml ================================================ --- version: 1 description: Terraform rules type: Terraform files: - "*.tf" - "*.tfvars" rules: - id: BATCH_JOB_AWS_ENVIRONMENT_SECRETS message: Environment for batch jobs should not include AWS secrets resource: aws_batch_job_definition severity: FAILURE # This rule fails if it finds a regex match for either the Access Key ID and/or the Secret Access Key assertions: - not: - some: key: "container_properties.environment[].value" expressions: # Check if the string starts with any known 4 character ACCESS_KEY sequence # and is 20 capital alpha-numeric characters long in total - key: "@" op: regex value: "^(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}$" - some: key: "container_properties.environment[].value" expressions: - and: # Check if the string is exactly 40 characters long - key: "@" op: regex value: "^.{40}$" # Check if the string contains only alpha-numeric-slash-plus characters with at least 1 / or + - key: "@" op: regex value: "^[a-zA-Z0-9/+]+[/+]+[a-zA-Z0-9/+]+$" tags: - batch ================================================ FILE: cli/assets/terraform/aws/batch/batch_job_definition/aws_secrets/tests/terraform12/aws_secrets.tf ================================================ # Test that AWS secrets are not being used in batch environment variables # https://www.terraform.io/docs/providers/aws/r/batch_job_definition.html#container_properties # Reference API for container_properties spec: https://docs.aws.amazon.com/batch/latest/APIReference/API_RegisterJobDefinition.html provider "aws" { region = "us-east-1" } # PASS: AWS secrets are not used in the env vars resource "aws_batch_job_definition" "batch_job_without_secrets" { name = "tf_test_batch_job_definition" type = "container" container_properties = <K7MDENG^bPxRfiCYEXAMPLEKEY" } ] } ] EOF } # Pass resource "aws_ecs_task_definition" "container_definitions_environment_aws_secrets_not_set_41_character_string" { family = "foo" container_definitions = <K7MDENG^bPxRfiCYEXAMPLEKEY" } ] } ] EOF } # Pass resource "aws_ecs_task_definition" "container_definitions_environment_aws_secrets_not_set_41_character_string" { family = "foo" container_definitions = < 0 { patterns := options.Files options.Files = []string{} for _, pattern := range patterns { matches, err := filepath.Glob(pattern) if err != nil { return options, err } options.Files = append(options.Files, matches...) } } return options, nil } func makeTagList(tags string, profileOptions []string) []string { if tags != "" { return strings.Split(tags, ",") } if len(profileOptions) != 0 { return profileOptions } return nil } func makeRulesList(ruleIDs string, profileOptions []string) []string { if ruleIDs != "" { return strings.Split(ruleIDs, ",") } if len(profileOptions) != 0 { return profileOptions } return nil } func makeQueryExpression(queryExpression string, verboseReport bool, profileOptions string) string { if queryExpression != "" { return queryExpression } // return complete report when -verbose option is used if verboseReport { return "" } if profileOptions != "" { return profileOptions } // default is to only report Violations return "Violations[]" } func parseVariables(vars []string) map[string]string { m := map[string]string{} for _, kv := range vars { parts := strings.Split(kv, "=") if len(parts) == 2 { m[parts[0]] = parts[1] } else { fmt.Printf("Cannot parse command line variable: %s\n", kv) } } return m } func mergeVariables(a, b map[string]string) map[string]string { if a == nil { return b } if b == nil { return map[string]string{} } for k, v := range b { a[k] = v } return a } func loadExcludePatterns(patterns []string, excludeFromFilenames []string) ([]string, error) { if len(excludeFromFilenames) == 0 { return patterns, nil } for _, filename := range excludeFromFilenames { lines, err := ioutil.ReadFile(filename) if err != nil { return patterns, err } for _, patternFromFile := range strings.Split(string(lines), "\n") { if patternFromFile != "" { assertion.Debugf("Pattern from file %s: %s\n", filename, patternFromFile) patterns = append(patterns, patternFromFile) } } } return patterns, nil } func validateParser(parser string) (string, error) { validOptions := []string{"", "tf11", "tf12"} for _, option := range validOptions { if parser == option { return parser, nil } } return "", fmt.Errorf("tf-parser \"%s\" is not valid. Choose from [\"tf11\", \"tf12\"].\n", parser) } ================================================ FILE: cli/options_test.go ================================================ package main import ( "testing" ) func emptyCommandLineOptions() CommandLineOptions { emptyString := "" verbose := false return CommandLineOptions{ Tags: &emptyString, Ids: &emptyString, IgnoreIds: &emptyString, QueryExpression: &emptyString, SearchExpression: &emptyString, VerboseReport: &verbose, TerraformParser: &emptyString, } } func TestCommandLineOnlyOptions(t *testing.T) { tags := "1,2,3" o := emptyCommandLineOptions() o.Tags = &tags p := ProfileOptions{} l, err := getLinterOptions(o, p) if err != nil { t.Errorf("getLinterOptions should not return error: %s\n", err.Error()) } if len(l.Tags) != 3 { t.Errorf("getLinterOptions should find 3 tags: %v\n", l.Tags) } } func TestProfileOnlyOptions(t *testing.T) { o := emptyCommandLineOptions() p := ProfileOptions{ Tags: []string{"1", "2", "3"}, } l, err := getLinterOptions(o, p) if err != nil { t.Errorf("getLinterOptions should not return error: %s\n", err.Error()) } if len(l.Tags) != 3 { t.Errorf("getLinterOptions should find 3 tags: %v\n", l.Tags) } } func TestCommandLineOverridesProfile(t *testing.T) { tags := "1,2,3,4" o := emptyCommandLineOptions() o.Tags = &tags p := ProfileOptions{ Tags: []string{"1", "2", "3"}, } l, err := getLinterOptions(o, p) if err != nil { t.Errorf("getLinterOptions should not return error: %s\n", err.Error()) } if len(l.Tags) != 4 { t.Errorf("getLinterOptions should find 4 tags: %v\n", l.Tags) } } func TestCommandLineVariables(t *testing.T) { o := emptyCommandLineOptions() o.Variables = []string{"namespace=web"} p := ProfileOptions{} l, err := getLinterOptions(o, p) if err != nil { t.Errorf("getLinterOptions should not return error: %s\n", err.Error()) } v, ok := l.Variables["namespace"] if !ok { t.Errorf("Expecting namespace variable to have a value\n") } else { if v != "web" { t.Errorf("Expecting namespace variable to be 'web', not '%s'\n", v) } } } func TestMergeVariables(t *testing.T) { o := emptyCommandLineOptions() o.Variables = []string{"namespace=web"} p := ProfileOptions{ Variables: map[string]string{"kind": "Pod"}, } l, err := getLinterOptions(o, p) if err != nil { t.Errorf("getLinterOptions should not return error: %s\n", err.Error()) } namespace, ok := l.Variables["namespace"] if !ok { t.Errorf("Expecting namespace variable to have a value\n") } else { if namespace != "web" { t.Errorf("Expecting namespace variable to be 'web', not '%s'\n", namespace) } } kind, ok := l.Variables["kind"] if !ok { t.Errorf("Expecting kind variable to have a value\n") } else { if kind != "Pod" { t.Errorf("Expecting kind variable to be 'Pod', not '%s'\n", kind) } } } func TestLoadProfile(t *testing.T) { p, err := loadProfile("./testdata/profile.yml") if err != nil { t.Errorf("Expecting loadProfile to run without error: %v\n", err.Error()) } if len(p.Tags) != 1 || p.Tags[0] != "iam" { t.Errorf("Expecting single tag in profile: %v\n", p.Tags) } } func TestProfileExclude(t *testing.T) { p, err := loadProfile("./testdata/profile.yml") if err != nil { t.Errorf("Expecting loadProfile to run without error: %v\n", err.Error()) } o := emptyCommandLineOptions() l, err := getLinterOptions(o, p) if err != nil { t.Errorf("Expecting getLinterOptions to run without error: %v\n", err.Error()) } if len(l.ExcludePatterns) != 3 { t.Errorf("Expecting 3 excludes in total using 'exclude' and 'exclude_from' in profile: %v\n", l.ExcludePatterns) } if l.ExcludePatterns[0] != "this_file_will_be_excluded.tf" { t.Errorf("Expecting 1st pattern using 'exclude' in profile to be 'this_file_will_be_excluded.tf', not '%s'", l.ExcludePatterns[0]) } if l.ExcludePatterns[1] != "*1.tf" { t.Errorf("Expecting 2nd pattern using 'exclude_from' in profile to be '*1.tf', not '%s'", l.ExcludePatterns[1]) } if l.ExcludePatterns[2] != "*2.tf" { t.Errorf("Expecting 3rd pattern using 'exclude_from' in profile to be '*2.tf', not '%s'", l.ExcludePatterns[2]) } } func TestValidateParser(t *testing.T) { parser, err := validateParser("") if err != nil { t.Errorf("Expected %s, got %v", parser, err) } parser, err = validateParser("tf11") if err != nil { t.Errorf("Expected %s, got %v", parser, err) } parser, err = validateParser("tf12") if err != nil { t.Errorf("Expected %s, got %v", parser, err) } parser, err = validateParser("tf13") if err == nil { t.Errorf("Expected %v, got nil", err) } } ================================================ FILE: cli/report_writer.go ================================================ package main import ( "fmt" "github.com/stelligent/config-lint/assertion" ) func (w DefaultReportWriter) WriteReport(report assertion.ValidationReport, options LinterOptions) { if options.SearchExpression == "" { err := printReport(w.Writer, report, options.QueryExpression) if err != nil { fmt.Println(err.Error()) } } } ================================================ FILE: cli/report_writer_test.go ================================================ package main import ( "bytes" "github.com/stelligent/config-lint/assertion" "github.com/stretchr/testify/assert" "testing" ) func TestReportWriter(t *testing.T) { var b bytes.Buffer w := DefaultReportWriter{Writer: &b} r := assertion.ValidationReport{ Violations: []assertion.Violation{ assertion.Violation{ RuleID: "RULE_1", }, }, } w.WriteReport(r, LinterOptions{}) assert.Contains(t, b.String(), "RULE_1") } ================================================ FILE: cli/terraform_test.go ================================================ package main import ( "fmt" "io/ioutil" "path/filepath" "strings" "testing" "github.com/ghodss/yaml" "github.com/gobuffalo/packr" "github.com/stelligent/config-lint/assertion" "github.com/stelligent/config-lint/linter" "github.com/stretchr/testify/assert" ) type TestSuite struct { Version string Description string Type string Files []string Tests []TestCase RootPath string } type TestCase struct { RuleId string Warnings int Failures int Tags []string } /* * Determine if the given filename is a test case */ func isTestCase(filename string) bool { if strings.Contains(filename, "test") && isYamlFile(filename) { return true } else { return false } } /* * Given filepath to a YAML test suite, return a TestSuite object */ func loadTestSuite(filename string) (TestSuite, error) { yamlFile, err := ioutil.ReadFile(filename) if err != nil { assertion.Debugf("Failed to load Test Suite file: %v\n %v\n", filename, err) return TestSuite{}, err } ts := TestSuite{} err = yaml.Unmarshal([]byte(yamlFile), &ts) if err != nil { assertion.Debugf("Failed to unmarshall YAML Test Suite: %v\n %v\n", filename, err) return TestSuite{}, err } ts.RootPath = strings.TrimSuffix(filename, "/tests/test.yml") return ts, nil } func getTestResources(directory string) ([]string, error) { var testResources []string box := packr.NewBox(directory) filesInBox := box.List() configPatterns := []string{"*.tf"} for _, f := range filesInBox { match, _ := assertion.ShouldIncludeFile(configPatterns, f) if match { absolutePath, err := filepath.Abs(directory + "/" + f) if err != nil { return []string{}, err } testResources = append(testResources, absolutePath) } } return testResources, nil } func contains(arr []string, str string) bool { for _, a := range arr { if a == str { return true } } return false } // Run each test case in a test suite func runTestSuite(t *testing.T, ts TestSuite) { // Load only the rule for this test suite ruleConfigPath := strings.Split(ts.RootPath, "config-lint/cli/assets/")[1] + "/rule.yml" ruleSet, err := loadBuiltInRuleSet(ruleConfigPath) if err != nil { assert.Nil(t, err, "Cannot load built-in Terraform rule") } for _, tc := range ts.Tests { options := linter.Options{ RuleIDs: []string{tc.RuleId}, } vs := assertion.StandardValueSource{} // validate the rule set if contains(tc.Tags, "terraform11") { // Load the test resources for this test suite testResourceDirectory := strings.Split(ts.RootPath, "config-lint/cli/")[1] + "/tests/terraform11/" testResources, err := getTestResources(testResourceDirectory) if err != nil { assert.Nil(t, err, "Cannot load built-in Terraform 11 test resources") } // Defining 'tf11' for the Parser type l, err := linter.NewLinter(ruleSet, vs, testResources, "tf11") report, err := l.Validate(ruleSet, options) assert.Nil(t, err, "Validate failed for file") warningViolationsReported := getViolationsString("WARNING", report.Violations) warningMessage := fmt.Sprintf("Expecting %d warnings for rule %s:\n %s", tc.Warnings, tc.RuleId, warningViolationsReported) assert.Equal(t, tc.Warnings, numberOfWarnings(report.Violations), warningMessage) failureViolationsReported := getViolationsString("FAILURE", report.Violations) failureMessage := fmt.Sprintf("Expecting %d failures for rule %s:\n %s", tc.Failures, tc.RuleId, failureViolationsReported) assert.Equal(t, tc.Failures, numberOfFailures(report.Violations), failureMessage) } if contains(tc.Tags, "terraform12") { // Load the test resources for this test suite testResourceDirectory := strings.Split(ts.RootPath, "config-lint/cli/")[1] + "/tests/terraform12/" testResources, err := getTestResources(testResourceDirectory) if err != nil { assert.Nil(t, err, "Cannot load built-in Terraform 12 test resources") } // Defining 'tf11' for the Parser type l, err := linter.NewLinter(ruleSet, vs, testResources, "tf12") report, err := l.Validate(ruleSet, options) assert.Nil(t, err, "Validate failed for file") warningViolationsReported := getViolationsString("WARNING", report.Violations) warningMessage := fmt.Sprintf("Expecting %d warnings for rule %s:\n %s", tc.Warnings, tc.RuleId, warningViolationsReported) assert.Equal(t, tc.Warnings, numberOfWarnings(report.Violations), warningMessage) failureViolationsReported := getViolationsString("FAILURE", report.Violations) failureMessage := fmt.Sprintf("Expecting %d failures for rule %s:\n %s", tc.Failures, tc.RuleId, failureViolationsReported) assert.Equal(t, tc.Failures, numberOfFailures(report.Violations), failureMessage) } } } /* * Given resource type and tags * Run all defined tests per resource type and subtype */ func RunBuiltinTests(t *testing.T, resourceType string) { // Get a list of all test cases box := packr.NewBox("./assets/" + resourceType) filesInBox := box.List() for _, file := range filesInBox { if isTestCase(file) { absolutePath, _ := filepath.Abs("./assets/" + resourceType + "/" + file) ts, err := loadTestSuite(absolutePath) if err != nil { assert.Nil(t, err, "Cannot load test case") } runTestSuite(t, ts) } } } // Run built in rules against Terraform parser func TestTerraformBuiltInRules(t *testing.T) { RunBuiltinTests(t, "terraform") } ================================================ FILE: cli/testdata/builtin/terraform12/test.tf ================================================ resource "aws_cloudtrail" "object_logging_enabled" { name = "tf-trail-foobar" s3_bucket_name = "nwm-cloudtrail-logs" s3_key_prefix = "prefix" include_global_service_events = false event_selector { read_write_type = "All" include_management_events = true data_resource { type = "AWS::S3::Object" values = ["arn:aws:s3:::"] } } } resource "aws_cloudtrail" "object_logging_enabled" { name = "tf-trail-foobar" s3_bucket_name = "nwm-cloudtrail-logs" s3_key_prefix = "prefix" include_global_service_events = false event_selector { read_write_type = "All" include_management_events = true data_resource { type = "wrong" values = ["arn:aws:s3:::"] } } } resource "aws_cloudtrail" "object_logging_enabled" { name = "tf-trail-foobar" s3_bucket_name = "nwm-cloudtrail-logs" s3_key_prefix = "prefix" include_global_service_events = false event_selector { read_write_type = "All" include_management_events = true } } ================================================ FILE: cli/testdata/dirtest/a.yml ================================================ --- comment: test file ================================================ FILE: cli/testdata/dirtest/b.yml ================================================ --- comment: test file ================================================ FILE: cli/testdata/exclude-list ================================================ *1.tf *2.tf ================================================ FILE: cli/testdata/profile-exceptions.yml ================================================ --- terraform: true files: - "*.tf" exceptions: - RuleID: IAM_ROLE_WILDCARD_ACTION ResourceCategory: resource ResourceType: aws_iam_role ResourceID: role2 Comments: Just because tags: - iam ================================================ FILE: cli/testdata/profile.yml ================================================ --- terraform: true files: - "*.tf" exceptions: - RuleID: ROLE_WILDCARD_ACTION ResourceCategory: resource ResourceType: aws_iam_role ResourceID: role2 Comments: Just because tags: - iam exclude: - this_file_will_be_excluded.tf exclude_from: - ./testdata/exclude-list ================================================ FILE: cli/testdata/smoketest_exceptions.tf ================================================ resource "aws_iam_role" "role2" { name = "role2" assume_role_policy = < # config-lint 1.x a tool to validate configuration files against custom specifications terraform | kubernetes | yaml | json | csv [GitHub](https://github.com/stelligent/config-lint) [Get Started](#config-lint) ================================================ FILE: docs/css/style.css ================================================ /* COVER */ section.cover { color: #fff; background:url(../img/bg-pattern.png),linear-gradient(to left,#f4842b,#7b4397) !important; } section.cover h1 { font-size: 5rem; font-weight: 600; } section.cover h1 a span { color: #fff; } /* Cover buttons */ section.cover .cover-main > p:last-child a { border: 1px solid #fff; color: #fff; } section.cover .cover-main > p:last-child a:last-child { background-color: #fff; color: var(--theme-color,#fff); } section.cover .cover-main > p:last-child a:last-child:hover { color: var(--theme-color,#fff); opacity: 0.8; } ================================================ FILE: docs/design.md ================================================ # Design ## Motivation * Static analysis of Terraform configuration files, similar to [cfn_nag](https://github.com/stelligent/cfn_nag) for CloudFormation templates * Analysis of Kubernetes spec files * Scanning of AWS resources already provisioned, via AWS API describe* calls * Processing of event data in AWS Config custom rules While the data source in each of these cases is different (files vs API calls vs event parameters), the application of the rules follows a similar pattern: * Extract a data element about a resource * Make assertions about the value or values found ## Goals ### Rules written in a DSL, rather than code Using a DSL in YAML has some advantages: * Common format that is easy to read * Easy to add new rules * Easy to scan an existing rule set The DSL was modeled after that found in [Cloud Custodian](https://github.com/capitalone/cloud-custodian). Instead of specifying filters to select resources, the DSL makes assertions about values discovered about resources. The DSL does have the ability to invoke HTTP endpoints. This is intended for logic that is too complex to specify in the DSL. ### Dynamic data In addition to using data embedded directly in a rule, the rules should be able to reference dynamic data. Typical examples are lists of IP addresses, and EC2 instance types. The DSL can reference HTTP endpoints or S3 objects for such dynamic data. This idea was also inspired by Cloud Custodian. ### Not aimed at remediation. The primary use case is as part of a CI/CD pipeline. If violations are detected, the exit code of the program is set so the pipeline can be terminated. The JSON output of the tool can be read by a other tools to trigger notifications or automatic remediation. ## Implementation The tool itself is written in go, and is a self contained binary. This simplifies its use in pipelines as well as for local development. ### Built-in rules The implementation includes a set of rules for Terraform that can be turned on with a command line option. These implement the same rules found in [cfn_nag](https://github.com/stelligent/cfn_nag) as well as those found in [terrascan](https://github.com/cesar-rodriguez/terrascan) ### Packages There are three packages in the repository: * cli * linter * assertion #### cli package This processes command line arguments and loads project files before using the linter package to do the actual linting. #### linter package Defines a linter interface, and provides a factory function to create linters that can discover resources that will be analyzed. Currently supported: * File based configurations - Terraform, Kubernetes, YAML, and self validation of config-lint rules files * API based congifurations - AWS Security Groups and IAM users The cli package in this repo uses the linter package. The linter package might also be used in tools that are not command line driven, such as a website or an AWS Lambda used for Config rules. Implementations of these can be found in the early commit history of this repository, but future work will be moved to separate repositories. The linter then uses the assertion package to analyze the collection of resources, and then generate a report. #### assertion Applies a set of rules to a collection of JSON objects and returns a report that includes any violations found. This package works on JSON objects and has no knowledge of how the resources were loaded. That work is done in the linter package. ================================================ FILE: docs/development.md ================================================ # Developing for config-lint ## VS Code Remote Development The preferred method of developing is to use the VS Code Remote development functionality. - Install the VS Code [Remote Development extension pack](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) - Open the repo in VS Code - When prompted "`Folder contains a dev container configuration file. Reopen folder to develop in a container`" click the "`Reopen in Container`" button - When opening in the future use the "`config-lint [Dev Container]`" option ### VS Code Dependencies There are a couple of dependencies that you need to configure locally before being able to fully utizlize the Remote Developemnt environment. - Requires `ms-vscode-remote.remote-containers` >= `0.101.0` - [Docker](https://www.docker.com/products/docker-desktop) - Needs to be installed in order to use the remote development container - [GPG](https://gpgtools.org) - Should to be installed in `~/.gnupg/` to be able to sign git commits with gpg - SSH - Should to be installed in `~/.ssh` to be able to use your ssh config and keys. ## Local Development ### Prerequisites - [Install golang](https://golang.org/doc/install) - Add the output of the following command to your PATH ``` echo "$(go env GOPATH)/bin" ``` ### Build Command Line tool ``` make all ``` The binary is located at `.release/config-lint` ### Tests Tests are located in the `assertion` directory. To run all tests: ``` make test ``` To run the Terraform builtin rules tests: ``` make testtf ``` More information about how to create and run tests can be found [here](tests.md). ### Linting To lint all files (using golint): ``` make lint ``` ### Releasing Merging to master will automatically cut a minor incremental release for any code changes. To create a new major release, you will need to merge a commit that includes the `#major` tag. Releases are created via GitHub Workflows. You can find more information about this [here](/github_workflow.md) ================================================ FILE: docs/example-rules.md ================================================ # Example Rules Add these rules to a YAML file, and pass the filename to config-lint using the -rules option. Each rule contains a list of assertions, and these assertions use operations that are [documented here](operations.md). * [Simple Expressions](#simple-expressions) * [Boolean Expressions](#boolean-expressions) * [Collection Expressions](#collection-expressions) * [Dynamic Values](#dynamic-values) * [Conditions](#conditions) * [Macros](#macros) ## simple-expressions To test that an AWS instance type has one of two values: ``` version: 1 description: Simple expression example type: Terraform files: - "*.tf" rules: - id: EC2_INSTANCE_TYPE message: Instance type should be t2.micro or m3.medium resource: aws_instance assertions: - key: instance_type op: in value: t2.micro,m3.medium severity: WARNING ``` ## boolean-expressions This could also be done by using the or operation with two different assertions: ``` version: 1 description: Boolean expression example type: Terraform files: - "*.tf" Rules: - id: EC2_INSTANCE_TYPE message: Instance type shouldG be t2.micro or m3.medium resource: aws_instance assertions: or: - key: instance_type op: eq value: t2.micro - key: instance_type op: eq value: m3.medium severity: WARNING ``` ## collection-expressions There are three operators that simplify working with collections: [every](operations.md#every), [some](operations.md#some) and [none](operations.md#none). You provide a JMESPath expression to extract the entire collection from the resource properties. Then a separate set of expressions are applied to each element of the collection. The expressions are the same as used for rule assertions. The every operator requires all elements to return true for the expression, the some operator requires at least one element to return true, and the none operator requires all of the elements to return false for the expression. ``` version: 1 description: Collection expression example type: YAML files: - "*.config" resources: - type: customer key: customers[] id: id rules: - id: CUSTOMER_LOCATIONS message: Every customer location needs an address and a zip_code resource: customer severity: FAILURE assertions: - every: key: locations assertions: - key: address op: present - key: zip_code op: present ``` ## dynamic-values Instead of including a list of values directly in the rules file, it can be retrieved from an S3 object at runtime. HTTP endpoints are also supported. ``` version: 1 description: Dynamic value example type: Terraform files: - "*.tf" rules: - id: EC2_INSTANCE_TYPE message: Instance type should be t2.micro or m3.medium resource: aws_instance assertions: - key: instance_type op: in value_from: s3://your-bucket/instance-types.txt severity: FAILURE ``` ## conditions Rules always have a condition based on the resource type, but you can add additional conditions. Here is an example from the internal rule set used for the -validate option. This rule will only apply when a resource of type LintRuleSet has a type equal to YAML. For that type of LintRuleSet, another attribute called resources must be present: ``` version: 1 description: Condition example type: YAML files: - *.config rules: - id: YAML_RULES_HAVE_RESOURCES_SECTION message: RuleSet for YAML required resources section resource: LintRuleSet severity: FAILURE conditions: - key: type op: eq value: YAML assertions: - key: resources op: present ``` ## macros Because the rules are specified in YAML format, it is possible to use anchors and aliases as a simple kind of macro language to eliminate duplicate expressions in a rule set. If you are familiar with Ruby on Rails configuration files, this might be familiar. Here is an example where the rules for different resource types check for the same attribute names. You can use the "&" to define an anchor. In this example there are three of these: &has_name, &has_description and &has_name_and_description (the first two are actually used to define the third one). Elsewhere in the file you can use the "*" (alias) and the "<<" (merge key) to insert these expressions into multiple rules, without copying the entire expression map. ``` version: 1 description: Macro example type: YAML files: - "*.config" # some anchors to keep rules DRY has_name: &has_name key: name op: present has_name: &has_description key: description op: present has_name_and_description: &has_name_and_description and: - <<: *has_name - <<: *has_description # resources to find in the config file resources: - type: widget key: widgets[] id: id - type: gadget key: gadgets[] id: name # rules to apply rules: - id: WIDGET_PROPERTIES message: Widget needs name and description severity: FAILURE resource: widget assertions: - <<: *has_name_and_description - id: GADGET_PROPERTIES message: Gadget needs name a description severity: FAILURE resource: gadget assertions: - <<: *has_name_and_description ``` This feature of YAML can even be used for partial expressions. The JMESPath expression for find a specific tag in a Terraform resource is not particularly friendly, so that could be hidden in an anchor called "has_tag". Then a rule assertion can reference that, and include the tag name that is required. ``` Version: 1 Description: Use an alias to hide a complicated JMESPath expression Type: Terraform Files: - "*.tf" has_tag: &has_tag key: "tags[]|[0].keys(@)" op: contains rules: - id: HAS_NAME_TAG message: Tags are required resource: aws_ebs_volume assertions: - <<: *has_tag value: Name ``` The assertions and operations were inspired by those in Cloud Custodian: https://cloud-custodian.github.io/cloud-custodian/docs/ ================================================ FILE: docs/faq.md ================================================ # FAQs 1. With the newest version of config-lint being able to handle configuration files written in Terraform v0.12 syntax, is it still backwards compatible with configuration files written in the Terraform v0.11? - Yes the new version of config-lint is able to handle parsing Terraform configuration files written in both v0.11 and v0.12 syntax. - To choose the between parsing Terraform 0.11 vs 0.12 syntax, you can pass in the flag option `-tfparser` followed by either `tf11` or `tf12`. For example: - `config-lint -tfparser tf12 -rules example_rule.yml example_config/example_file.tf` 2. I'm running into errors when trying to run the newest version of config-lint against configuration files written in Terraform v0.12 syntax. Where should be the first place to check for resolving this? - The first thing to check is to make sure you're passing in the correct `-tfparser` flag option. Depending on which Terraform syntax the configuration file is written in, refer to the FAQ #1 above for passing in the correct flag option values. - For configuration files that contain Terraform v0.12 syntax, you should confirm that whatever rule.yml file/files you pass in have the `type:` key set to `Terraform12`. For example in this rule.yml file: ``` version: 1 description: Rules for Terraform configuration files type: Terraform12 files: - "*.tf" rules: - id: AMI_SET message: Testing resource: aws_instance assertions: - key: ami op: eq value: ami-f2d3638a ``` ================================================ FILE: docs/github_workflow.md ================================================ # GitHub Workflows This project utilizes GitHub Workflows to run checks against pushed commits and to also control releases. ## Configs The configuration files for the Workflows are stored in the `.github/workflows/` directory. The Workflows are split up into 2 different types, `Build` and `Deploy`. More information about each can be found below: ### Build `.github/workflows/build.yml` There is a general catchall `Build` Workflow that is used against each push to the repository from any branch as long as the push **DOES NOT** contain a tag. This Workflow will download the `GO` module dependencies and run a `make test` against the pushed commit. This `Build` Workflow is attached to Pull Requests as a Status Check to ensure all the tests are passing before code can be merged. ### Deploy There are 2 `Deploy` Workflow types, each is tied to a specific release type, **`Stable`** or **`Beta`**. #### Stable Release `.github/workflows/build_and_deploy.yml` This Workflow will run on any push that contains a tag with `v*.*.*` but will ignore tags ending in `-beta`. This Workflow will download the `GO` module dependencies and run a `make test` against the pushed commit. Afterwards it will run the `release` stage and run `goreleaser release` utilizing the `.goreleaser.yml` configuration file. The `goreleaser` step will create release files and create an actual `Release` in GitHub. It will also update the [stelligent/homebrew-tap](https://github.com/stelligent/homebrew-tap) to use the latest stable version stored in the [Formula/config-lint.rb](https://github.com/stelligent/homebrew-tap/blob/master/Formula/config-lint.rb) file #### Beta Release `.github/workflows/beta_build_and_deploy.yml` This Workflow will run on any push that contains a tag with `v*.*.*-beta`. It is important to note that it must end in `-beta` for this beta release Workflow to trigger. This Workflow will download the `GO` module dependencies and run a `make test` against the pushed commit. Afterwards it will run the `beta release` stage and run `goreleaser release` utilizing the `.beta-goreleaser.yml` configuration file. The `goreleaser` step will create release files and create a `Pre-Release` in GitHub. It will also update the [stelligent/homebrew-tap](https://github.com/stelligent/homebrew-tap) to use the latest pre-release version stored in the [Formula/beta/config-lint.rb](https://github.com/stelligent/homebrew-tap/blob/master/Formula/beta/config-lint.rb) file. --- Some things to note within the `.beta-goreleaser.yml` file: ``` yaml release: prerelease: auto ``` * This allows GitHub to assign a `Pre-Release` labeled release since the semantic version ends in `-beta` ``` yaml brews: - ... folder: Formula/beta ``` * Storing the beta release in a new directory specifically for beta releases in homebrew. ================================================ FILE: docs/index.html ================================================ config-lint: a tool to validate configuration files against custom specifications.
================================================ FILE: docs/install.md ================================================ # config-lint installation guide ## Homebrew You can use [Homebrew](https://brew.sh/) to install the latest version: ``` bash brew tap stelligent/tap brew install config-lint ``` ## Docker You can pull the latest image from [DockerHub](https://hub.docker.com/r/stelligent/config-lint): ``` bash docker pull stelligent/config-lint ``` If you choose to install and run via `docker` you will need mount a directory to the running container so that it has access to your configuration files. ``` bash docker run -v /path/to/your/configs/:/foobar stelligent/config-lint -terraform /foobar/foo.tf # or docker run --mount src=/path/to/your/configs/,target=/foobar,type=bind stelligent/config-lint -terraform /foobar/foo.tf ``` If you are linting Kubernetes configuration files, you will need to reference the path to the Kubernetes rules accordingly. For example if the `pwd` has rules and configuration files: ``` docker run -v $(pwd):/foobar stelligent/config-lint -rules /foobar/path/to/rules/kubernetes.yml /foobar/path/to/configs ``` If you don't have your own set of rules that you want to run against your Kubernetes configuration files, you can copy or download the example set from [example-files/rules/kubernetes.yml](https://github.com/stelligent/config-lint/blob/master/example-files/rules/kubernetes.yml). ## Linux ``` # Install the latest version of config-lint curl -L https://github.com/stelligent/config-lint/releases/download/latest/config-lint_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin config-lint # See https://github.com/stelligent/config-lint/releases for release versions VERSION=v1.0.0 curl -L https://github.com/stelligent/config-lint/releases/download/${VERSION}/config-lint_Linux_x86_64.tar.gz | tar xz -C /usr/local/bin config-lint chmod +rx /usr/local/bin/config-lint ``` ## Windows Download the [latest Windows release](https://github.com/stelligent/config-lint/releases/latest) for your platform and add the binary to your Windows PATH. ================================================ FILE: docs/operations.md ================================================ # Operations The rules contain a list of expressions that use operations ## Assertion Operations | Operation | Description | |----------------------------------------|----------------------------| | [absent](#absent) | Absent | | [and](#and) | And | | [contains](#contains) | Contains | | [does-not-contain](#does-not-contain) | Does Not Contain | | [ends-with](#ends-with) | Ends With | | [eq](#eq) | Equal | | [empty](#empty) | Empty | | [every](#every) | Every | | [has-properties](#has-properties) | Has Properties | | [in](#in) | In | | [is-array](#is-array) | Is Array | | [is-false](#is-false) | Is False | | [is-not-array](#is-not-array) | Is Not Array | | [is-true](#is-true) | Is True | | [ne](#ne) | Not equal | | [none](#none) | None | | [not](#not) | Not | | [not-contains](#does-not-contain) | Does Not Contain | | [not-empty](#not-empty) | Not Empty | | [not-in](#not-in) | Not In | | [exactly-one](#exactly-one) | Exactly One | | [or](#or) | Or | | [present](#present) | Present | | [regex](#regex) | Regex | | [starts-with](#starts-with) | Starts With | | [some](#some) | Some | | [xor](#xor) | Xor | | [is-subnet](#is-subnet) | Is Subnet | | [is-private-ip](#is-private-ip) | Is Private IP | | [exposed-hosts](#exposed-hosts) | Number of hosts exposed to | ## eq Equal ###Example: ``` ... - id: VOLUME1 resource: aws_ebs_volume message: EBS Volumes must be encrypted severity: FAILURE assertions: - key: encrypted op: eq value: true ... ``` ## ne Not Equal Example: ``` ... - id: SG1 resource: aws_security_group message: Security group should not allow ingress from 0.0.0.0/0 severity: FAILURE assertions: - key: "ingress[].cidr_blocks[] | [0]" op: ne value: "0.0.0.0/0" ... ``` ## in In list of values ### Example: ``` ... - id: R1 message: Instance type should be t2.micro or m3.medium resource: aws_instance assertions: - key: instance_type op: in value: t2.micro,m3.medium severity: WARNING ... ``` ## not-in Not in list of values ## present Attribute is present ###Example: ``` ... - id: R6 message: Department tag is required resource: aws_instance assertions: - key: "tags[].Department | [0]" op: present severity: FAILURE ... ``` ## absent Attribute is not present ## empty Attribute is empty ## not-empty Attribute is not empty ## contains Attribute contains a substring, or array contains an element ## does-not-contain Attribute does not contain a substring, or array does not contain an element You can also use the 'not-contains' operator to do the same thing. ## regex Attribute matches a regular expression See [here](https://github.com/google/re2/wiki/Syntax) for regular expression syntax. ## and Logical and of a list of assertions ### Example: ``` ... - id: ANDTEST resource: aws_instance message: Should have both Project and Department tags severity: WARNING assertions: - and: - key: "tags[].Department | [0]" op: present - key: "tags[].Project | [0]" op: present tags: - and-test ... ``` ## or Logical or of a list of assertions ### Example: ``` ... - id: ORTEST resource: aws_instance message: Should have instance_type of t2.micro or m3.medium severity: WARNING assertions: - or: - key: instance_type op: eq value: t2.micro - key: instance_type op: eq value: m3.medium ... ``` ## xor Logical xor of a list of assertions. The assertion is true when exactly one test passes ### Example: ``` ... - id: ORTEST resource: lint_rule message: Can have value or value_from, but not both severity: WARNING assertions: - xor: - key: value op: present - key: value_from op: present ... ``` ## not Logical not of an assertion Example: ``` ... - id: NOTTEST resource: aws_instance message: Should not have instance type of c4.large severity: WARNING assertions: - not: - key: instance_type op: eq value: c4.large ... ``` ## has-properties Checks for the present of every property in a comma separated list. This could also be done using the [and](#and) expression, but this will often be more convenient. Example: ``` ... - id: VALID_ADDRESS message: Every address needs city, state and zip severity: FAILURE resource: address assertions: - key: address op: has-properties value: city,state,zip ... ``` ## every Select an array from a resource, and run assertions against each element. All of the sub assertions must pass for the test to pass. The key is a JMESPath expression that should return an array of objects. The key used in each sub assertion is relative to the selected objects. This provides a simple looping mechanism that is easier to write and understand than a complex JMESPath expression. Example: ``` ... - id: LOCATIONS_NEED_LAT_LONG message: Every location requires a latitude and longitude severity: FAILURE resource: sample assertions: - every: key: Location expressions: - key: latitude op: present - key: longitude op: present ... ``` ## some Select an array from a resource, and run assertions against each element. At least one sub assertion must pass for the test to pass. The key is a JMESPath expression that should return an array of objects. The key used in each sub assertion is relative to the selected objects. This provides a simple looping mechanism that is easier to write and understand than a complex JMESPath expression. Example: ``` ... - id: LOCATION_REQUIRES_LAT_LONG message: At least one location requires a latitude and longitude severity: FAILURE resource: sample assertions: - some: key: Location expressions: - key: latitude op: present - key: longitude op: present ... ``` ## none Select an array from a resource, and run assertions against each element. All of the sub assertions must fail for the test to pass. The key is a JMESPath expression that should return an array of objects. The key used in each sub assertion is relative to the selected objects. This provides a simple looping mechanism that is easier to write and understand than a complex JMESPath expression. Example: ``` ... - id: PORT_22_INGRESS message: No ingress for port 22 should be open to the world severity: FAILURE resource: sample assertions: - none: key: "ipPermissions[]" expressions: - key: "fromPort" op: eq value: 22 value_type: integer - key: "ipRanges[]" op: contains value: 0.0.0.0/0 ... ``` ## exactly-one Select an array from a resource, and run assertions against each element. Only one of the sub assertions should return true for the test to pass. The key is a JMESPath expression that should return an array of objects. The key used in each sub assertion is relative to the selected objects. This provides a simple looping mechanism that is easier to write and understand than a complex JMESPath expression. Example: ``` - id: ONLY_ONE_DEFAULT message: Default should be true for only one element severity: FAILURE resource: sample assertions: - exactly-one: key: "items[]" expressions: - key: "default" op: is-true ``` ## is-true Check that the data has a true value. Shorthand for using { op: "eq" , "value": true } Example: ``` ... - id: ENCRYPTION_TRUE message: Encryption should be true severity: FAILURE resource: ebs_volume assertions: - key: encrypted op: is-true ... ``` ## is-false Check that the data has a false value. Shorthand for using { op: "eq" , "value": false } Example: ``` ... - id: ALLOW_PUBLIC_ACCESS message: Should not allow public access severity: FAILURE resource: some_resource assertions: - key: public_access op: is-false ... ``` ## starts-with Check that a string value starts with a value Example: ``` ... - id: NAME_PREFIX message: Name should have a certain prefix severity: FAILURE resource: some_resource assertions: - key: name op: starts-with value: Foo ... ``` ## ends-with Check that a string value ends with a value Example: ``` ... - id: NAME_SUFFIX message: Name should have a certain suffix severity: FAILURE resource: some_resource assertions: - key: name op: ends-with value: Resource ... ``` ## is-array Check that an attribute is an array Example: ``` ... - id: TAGS_ARRAY message: Tags should be an array severity: FAILURE resource: some_resource assertions: - key: Tags[] op: is-array ... ``` ## is-not-array Check that an attribute is not an array Example: ``` ... - id: DESCRIPTION_NOT_AN_ARRAY message: Description should not be an array severity: FAILURE resource: some_resource assertions: - key: Name op: is-not-array ... ``` ## is-subnet Check whether a given IP or CIDR block is a subnet of a larger CIDR block Example: ``` ... - id: IP_IN_SUPERNET message: All ip_address values should be in the 10.0.0.0/8 supernet severity: FAILURE resource: some_resource assertions: - key: "ip_address" op: is-subnet value: "10.0.0.0/8" ... ``` ## is-private-ip Check whether a given IP is in RFC1918 address space. Example: ``` ... - id: IP_IN_PRIVATE_RANGE message: All ip_address values should be in private address space severity: FAILURE resource: some_resource assertions: - key: "ip_address" op: is-private-ip ... ``` ## max-host-count Checks how many hosts in a given CIDR range. This is useful for evaluating security group rules, for instance. Example: ``` ... - id: MAX_HOSTS_EXPOSED_PER_RULE message: All security group rules must expose less than 1016 hosts severity: FAILURE resource: aws_security_group_rule assertions: - every: key: "cidr_blocks" expressions: - key: "@" op: max-host-count value: 1016 ... ``` ================================================ FILE: docs/output.md ================================================ # Output from config-lint The program outputs a JSON string with the results. The JSON object has the following attributes: * FilesScanned - a list of the filenames evaluated * Violations - an object whose keys are the severity of any violations detected. The value for each key is an array with an entry for every violation of that severity. ## Using -query to limit the output You can limit the output by specifying a JMESPath expression for the -query command line option. For example, if you just wanted to see the ResourceId attribute for failed checks, you can do the following: ``` ./config-lint -rules example-files/rules/terraform.yml -query 'Violations.FAILURE[].ResourceId' example-files/config/* ``` ================================================ FILE: docs/profiles.md ================================================ # Profiles You can use a profile to control the default options. The -profile command line option takes a filename which contains a set of default values for various command line options. If there is a file in the working directory called `config-lint.yml`, it will be loaded automatically. All values in the profile are optional, and are overriden by anything specified on the command line. An example profile: ``` # A list of files containing rules for linting rules: - example-files/rules/generic-yaml.yml # A list of files to scan files: - example-files/config/*.config # An optional list of rules to check, the default is all rules ids: - RULE_1 - RULE_2 # An optional list of tags used to select what rules to apply, the default is all rules tags: - s3 # A list of resources and rules that should not be applied # This is useful if you want to turn off some rules for some resources, especially # when using built-in rules # (For custom rules files, you can use the Except attribute on a rule) exceptions: - RuleID: S3_BUCKET_ACL ResourceCategory: resource ResourceType: aws_s3_bucket ResourceID: simple_website Comments: This bucket hosts a public website ``` ================================================ FILE: docs/rule_development.md ================================================ # Developing rules for config-lint ## Developing new rules using -search Each rule requires a JMESPath key that it will use to search resources. Documentation for JMESPATH is here: http://jmespath.org/ The expressions can be tricky to get right, so this tool provides a -search option which takes a JMESPath expression. The expression is evaluated against all the resources in the files provided on the command line. The results are written to stdout. This example will scan the example terraform file and print the "ami" attribute for each resource: ``` ./config-lint -rules example-files/rules/terraform.yml -search 'ami' example-files/config/terraform.tf ``` If you specify -search, the rules files is only used to determine the type of configuration files. The files will *not* be scanned for violations. ================================================ FILE: docs/rules.md ================================================ # Rules File A YAML file that specifies what kinds of files to process, and what validations to perform. ## Attributes for the Rule Set |Name |Description | |-----------|------------------------------------------------------------------------------------| |version |Currently ignored | |description|Text description for the file, not currently used | |type |Terraform, Terraform12, Kubernetes, SecurityGroups, AWSConfig | |files |Filenames must match one of these patterns to be processed by this set of rules | |rules |A list of rules, see next section | ## Attributes for each Rule Each rule contains the following attributes: |Name |Description | |-----------------|------------------------------------------------------------------------------------| |id | A unique identifier for the rule | |message | A string to be printed when a validation error is detected | |resource | The resource type to which the rule will be applied | |resources | A list of resources types to which the rule will be applied | |except_resources | A list of resource types to exclude | |category | Optional value used for Terraform: resource(default), data, provider | |[conditions](conditions.md) | Expressions (in addition to resource) that determine if a rule should apply | |except | An optional list of resource ids that should not be validated | |severity | FAILURE, WARNING, NON_COMPLIANT | |assertions | A list of expressions used to detect validation errors, see next section | |invoke | Alternative to assertions for a custom external API call to validate, see below | |tags | Optional list of tags, command line has option to limit scans to a subset of tags | ## Attributes for each Expression Each expression contains the following attributes: |Name |Description | |-----------|------------------------------------------------------------------------------------| |key | JMES path used to find data in a resource | |[op](operations.md) | Operation to perform on the data return. [See here for valid operations](operations.md) | |value | Literal value needed for most operations | |[value_from](value_from.md) | Endpoint for loading values dynamically [See here for dynamic values](value_from.md) | ## Invoke external API for validation |Name | Description | |-----------|------------------------------------------------------------------------------------| |Url | HTTP endpoint to invoke | |Payload | Optional JMESPATH to use for payload, default is '@' | ================================================ FILE: docs/running.md ================================================ # Running config-lint The program has a set of built-in rules for scanning the following types of files: * [Terraform](terraform.md) The program can also read files from a separate YAML file, and can scan these types of files: * [Terraform](terraform.md) * Kubernetes * LintRules * YAML * JSON ## Example invocations ### Validate Terraform files with built-in rules ``` config-lint -terraform example-files/config ``` ### Validate Terraform files with custom rules ``` config-lint -rules examples-files/rules/terraform.yml example-files/config ``` ### Validate Kubernetes files ``` config-lint -rules example-files/rules/kubernetes.yml example-files/config ``` ### Validate LintRules files This type of linting allows the tool to lint its own rules. ``` config-lint -rules example-files/rules/lint-rules.yml example-files/rules ``` ### Validate a custom YAML file ``` config-lint -rules example-files/rules/generic-yaml.yml example-files/config/generic.config ``` ## Using STDIN You can use "-" for the filename if you want the configuration data read from STDIN. ``` cat example-files/resources/s3.tf | config-lint -terraform - ``` ## Exit Code If at least one rule with a severity of FAILURE was triggered the exit code will be 1, otherwise it will be 0. ## Options Here are all the different command line options that can be used with config-lint. You can also view them via the -help option. * -debug - Debug logging * -exclude value - Filename patterns to exclude * -exclude-from value - Filename containing patterns to exclude * -ids string - Run only the rules in this comma separated list * -ignore-ids string - Ignore the rules in this comma separated list * -profile string- Provide default options * -query string - JMESPath expression to query the results * -rules value - Rules file, can be specified multiple times * -search string - JMESPath expression to evaluation against the files * -tags string - Run only tests with tags in this comma separated list * -terraform - Use built-in rules for Terraform * -validate - Validate rules file * -var value - Variable values for rules with ValueFrom.Variable * -verbose - Output a verbose report * -version - Get program version * -tfparser - (Optional) Set the Terraform parser version. Options are `tf11` or `tf12`. By default, `tf12` will be used. ================================================ FILE: docs/sidebar.md ================================================ - [home](/) - [installation guide](install.md 'Installation guide') - [running config-lint](running.md 'Running config-lint') - [profiles](profiles.md 'Profiles to specify default config-lint options') - [output](output.md 'Understanding the output from config-lint') - [example rules](example-rules.md 'Some examples of config-lint rules') - [design](design.md 'Overview of config-lint design') - [rule development](rule_development.md 'Rule development') - [rules](rules.md 'Structure of config-lint rules files') - [tests](tests.md 'Testing config-lint rules') - [terraform](terraform.md 'Terraform linting') - [yaml](yaml.md 'Parsing an abitrary YAML file') - [operations](operations.md 'Rule operations') - [conditions](conditions.md 'Rule conditions') - [value_from](value_from.md 'Using dynamic values') - [development](development.md 'config-lint development information') - [github workflow](github_workflow.md 'GitHub workflows') - [FAQ](faq.md 'Frequently asked questions') ================================================ FILE: docs/terraform.md ================================================ # Terraform Linting ## Validate Terraform files with built-in rules There is a set of [built-in rules](/cli/assets/terraform) that cover some best practices for AWS resources. ``` config-lint -terraform ``` If you want to run most of the built-in rules, but not all, you can use a [profile](profiles.md) to exclude some rules or resources. The Terraform12 parser is fully backwards compatible with previous versions of Terraform. By default, Terraform files will be validated with Terraform 0.12 standards. If you wish to force a specific parser version, add the `-tfparser tf11|tf12` flag. This is useful if you have a lot of rules with `Type: Terraform` but your Terraform files include Terraform 12 syntax. ## Custom Terraform rules for your project or organization ``` config-lint -rules ``` You can specify the -rules option multiple times if you have multiple custom rule files. It is also possible to specify both the -terraform option as well as one or more -rules options, if you want the built-in rules as well as some custom rules. ### Categories The default category for resources that can be linter is "resource", which covers the most common use case. This is for things like aws_instances, or s3_buckets, etc. But all other block types are available for Terraform linting. * data * locals * module * output * provider * resource * terraform * variable ### Rule Structure Rules are divided into their respective resource directory starting under `assets/terraform`. Each rule is organized following the same tiered directory structure `{ Provider }} / {{ Major Family }} / {{ Resource Name }} / {{ Rule Name }} / rule.yml` where Major Family and Resource Name follow the same naming conventions defined by Terraform. For example, `cli/assets/terraform/aws/batch/batch_job_definition/container_properties_privileged/rule.yml`. The rule configuration itself must be named `rule.yml`. ``` └── terraform ├── aws │   ├── batch │   │   └── batch_job_definition │   │   └── container_properties_privileged │   │   ├── rule.yml │   │   └── tests │   │   ├── terraform11 │   │   │   └── container_properties_privileged.tf │   │   ├── terraform12 │   │   │   └── container_properties_privileged.tf │   │   └── test.yml ... ``` ### Rule Example ```yaml --- version: 1 description: Check for tags in Terraform file type: Terraform files: - "*.tf" rules: - id: REQUIRED_TAGS message: "A required tag is missing" resources: - aws_s3_bucket - aws_instance assertions: - key: tags[0] op: has-properties value: environment,cost_center - id: VALID_ENVIRONMENT_TAG message: "The environment tag is not valid" resources: - aws_s3_bucket - aws_instance assertions: - key: tags[0].environment op: in value: dev,prod,stage - id: VALID_COST_CENTER_TAG message: "Cost center must be a 4 digit number" resources: - aws_s3_bucket - aws_instance assertions: - key: tags[0].cost_center op: regex value: "^[0-9]{4}$" ``` ### Top level blocks Config-lint mainly works on [block arguments and expressions](https://www.terraform.io/docs/configuration/index.html#arguments-blocks-and-expressions) level (the inner part of Terraform blocks), however top level block types and names could linted using `__type__` and `__name__` keys. Here is an example to follow [Terraform best practices](https://www.terraform.io/docs/extend/best-practices/naming.html) for naming: ```yaml --- version: 1 description: Make sure Terraform top level blocks follow best practices. type: Terraform files: - "*.tf" rules: - id: TF_RESOURCE_NAMING_CONVENTION message: "Terraform resource block name should match the naming convention. Name should be: not more 64 chars, starts with letter, doesn't have dash, and ends with letter or number" severity: FAILURE category: resource assertions: - key: __name__ op: regex value: '^[a-z][a-z0-9_]{0,62}[a-z0-9]$' - id: TF_DATA_NAMING_CONVENTION message: "Terraform data block name should match the naming convention. Name should be: not more 64 chars, starts with letter, doesn't have dash, and ends with letter or number" severity: FAILURE category: data assertions: - key: __name__ op: regex value: '^[a-z][a-z0-9_]{0,62}[a-z0-9]$' ``` Another example, maybe you want to make sure there are no beta providers (e.g. [google-beta](https://www.terraform.io/docs/providers/google/guides/provider_versions.html#google-beta)) used in production: ```yaml rules: - id: TF_PROVIDER_NO_BETA message: "No beta feature providers should be used in production" severity: FAILURE category: data assertions: - not: - key: __type__ op: regex value: '.*?beta.*' ``` **Please note:** Not all blocks have `__type__` and `__name__` keys. It depends on the block itself. For example, `variable` blocks have name but not type. There are 4 groups in that regard: * **Type and name:** data, resource. * **Type only:** provider. * **Name only:** module, output, variable. * **No type and no name:** locals, terraform (they are linted using normal keys defined within them). ### Provider Example For providers, set the category to "provider" and the resource attribute to the name of the provider. ```yaml --- version: 1 description: Terraform provider example type: Terraform files: - "*.tf" rules: - id: NO_SECRETS_IN_AWS_PROVIDER category: provider resource: aws assertions: - key: access_key op: absent - key: secret_key op: absent ``` ### Module Example For modules, use "module" for category, and for resource use the "source" attribute. This allows checking of parameters being used when a module is referenced. ```yaml --- version: 1 description: Terraform module invocation example type: Terraform files: - "*.tf" rules: - id: MODULE_EXAMPLE message: category: module resource: "example/website" assertions: - key: num_servers op: present ``` ### Terraform 12 Example Note the `type: Terraform12` item below. Rules targeting templates with Terraform 12-specific features must use the Terraform12 type. ```yaml version: 1 description: Rules for Terraform configuration files type: Terraform12 files: - "*.tf" rules: - id: CIDR_SET message: Testing resource: aws_security_group assertions: - every: key: "ingress" expressions: # this says that it either must be a private IP, or not have IP regex (eg sg string, interpolation) - every: key: cidr_blocks expressions: - key: "@" op: contains value: "/24" ``` ### Evaluating Terraform 12 Dynamic Blocks Dynamic blocks are a new feature introduced in Terraform 12 that enables users to dynamically construct repeatable nested blocks such as ingress rules in an AWS Security Group. Writing rules for dynamic blocks is a little tricky, as the structure that Terraform parses the .tf file into is different than you may expect. This Terraform config will generate an `ingress` block for reach item in the `service_ports` list variable. ```hcl-terraform variable "service_ports" { default = [22, 80, 1433, 6379] } resource "aws_security_group" "example" { name = "example" dynamic "ingress" { for_each = var.service_ports content { from_port = ingress.value to_port = ingress.value protocol = "tcp" } } egress = "-1" } ``` The following rule will result in an error if port 22 (SSH) is included as an ingress for the security group. The JMESPATH expression refers to keys ("dynamic" and "for_each") that are generated by Terraform, rather than what is present in the configuration. ```yaml version: 1 description: Rules for Terraform configuration files type: Terraform12 files: - "dynamic_block.tf" rules: - id: NO_SSH_ACCESS message: Testing resource: aws_security_group assertions: - key: "dynamic[*].for_each[]" op: not-contains value: 22 ``` ### Testing Builtin Rules All rules need to be tested. All tests for a given rule will be included at the same rule path as the rule configuration itself and live under the `tests` folder. That test folder must include a configuration for the tests, named `test.yml`, and the resources required for testing. The test configuration file must follow the following format: ```yaml --- version: 1 description: Terraform 11 and 12 tests type: Terraform files: - "*.tf" - "*.tfvars" tests: - ruleId: RULE_1_TO_BE_TESTED warnings: 0 failures: 2 tags: - "terraform11" - "terraform12" - ruleId: RULE_2_TO_BE_TESTED warnings: 2 failures: 0 tags: - "terraform11" - ruleId: RULE_2_TO_BE_TESTED warnings: 3 failures: 0 tags: - "terraform12" ``` The `ruleId` must match the RuleID given in the rule configuration. Warnings or Failures will check against any resource files included under the named `tag` directory within the same tests directory. For example `RULE_1_TO_BE_TESTED` will run the rule against resources in both folders terraform11 and terraform12 and check for the same number of warnings and failures for both. Whereas `RULE_2_TO_BE_TESTED` will check for a different number of warnings for the two versions. ================================================ FILE: docs/tests.md ================================================ # Tests You can run the project tests by invoking the correct make command: * `make test` -> Runs all tests residing inside the config-lint project * `make testtf` -> Runs all tests defined within the `TestTerraformBuiltInRules` test function. This runs against terraform 0.11 first and then against terraform 0.12 ## Testing Best Practices It is best practice to always come up with **at least 2** scenarios for each test (ideally more if applicable). You want a test case that will *pass* and a test case that will *fail*. This covers the bare minimum to ensure that a rule and test case are working as expected. ## Terraform Terraform rules have their own set of tests that you can use to verify that a new rule or configuration is working as expected. As noted above, the `make testtf` command will run the tests defined within the `TestTerraformBuiltInRules` test function. ### Creating Terraform Tests To create a new test to validate a Terraform built in rule you need to do the following: * Add test case inside `TestTerraformBuiltInRules` function in the `cli/builtin_terraform_test.go` file. * Example: `{"aws/security_group/world_ingress.tf", "SG_WORLD_INGRESS", 2, 0},` will run the `SG_WORLD_INGRESS` rule against the contents of the `cli/testdata/builtin/terraform/aws/security_group/world_ingress.tf` file. It is expecting there to be **2** *Warnings* and **0** *Failures* * The test must follow the `struct` format for `BuiltInTestCase` ``` go type BuiltInTestCase struct { Filename string RuleID string WarningCount int FailureCount int } ``` * This test case must match a valid Rule `id` from within the `cli/assets/terraform.yml` file. ================================================ FILE: docs/value_from.md ================================================ # Dynamic Value In cases where a rule needs a dynamic value, instead of specifying "value", "value_from" can be used instead. This allows an external source, such as an S3 object, or an HTTP endpoint to provider the values. Or the value can be provided on the command line when config-lint is invoked. ## Using an S3 Bucket ### Example: ``` ... - id: VALUE_FROM_S3 message: Instance type should be in list from S3 object resource: aws_instance assertions: - key: instance_type op: in value_from: url: s3://my-bucket/allowed-instance-types ... ``` ## Using an HTTP endpoint ### Example: ``` ... - id: VALUE_FROM_HTTPS message: Instance type should be in list from https endpoint resource: aws_instance assertions: - key: instance_type op: in value_from: url: https://my-api-endpoint/dev/instance_types ... ``` ## Using a command line variable ### Example: ``` ... - id: VALUE_FROM_COMMAND_LINE message: Instance type should be in list from https endpoint resource: aws_instance assertions: - key: instance_type op: in value_from: variable: instance_types ... ``` When invoking the config-lint, include the -var option, as in this example: ``` config-lint -rules -var "instance_types=t2.small,c3.medium" ``` ================================================ FILE: docs/yaml.md ================================================ # Parsing an Arbitrary YAML file Set the Type to YAML, and provide a `resources` section to describe how to discover the resources in a YAML file. The other parsers have code that expects a specific file format, and it converts those into a collection of resource objects that will be linted. For arbitrary YAML files, the resources describes how resources should be loaded. Each resources has these attributes: * type: identifies the type of resource. Rules with a matching `resource` will be applied to these resources. * key: JMESPath expression that should return an array of objects from the YAML file. * id: a JMESPATH expression applied to each resource to extract its unique identifier. The rules section works the same as in any other linter. Example: ``` version: 1 description: Rules for generic YAML file type: YAML files: - "*.config" # For generic YAML linting, we need a list of resources # Each entry in the list describes the resource type, how to discover it in the file, and how to get its ID # The key attribute is a JMESPath expression that should return an array resources: - type: widget key: widgets[] id: id - type: gadget key: gadgets[] id: name - type: contraption key: other_stuff.contraptions[] id: ids.serial_number # include the root document in a single element array with a literal id - type: document key: '[@]' id: '`"Document"`' rules: - id: DOCUMENT_KEYS message: Unexpected document key severity: FAILURE resource: document assertions: - every: key: "keys(@)" assertions: - key: "@" op: in value: widgets,gadgets,other_stuff - id: WIDGET_NAME message: Widget needs a name severity: FAILURE resource: widget assertions: - key: name op: present - id: GADGET_COLOR message: Gadget has missing or invalid color severity: FAILURE resource: gadget assertions: - key: color op: in value: red,blue,green - id: GADGET_PROPERTIES message: Gadget has missing properties severity: FAILURE resource: gadget assertions: - key: "@" op: has-properties value: name,color - id: CONTRAPTION_SIZE message: Contraption size should be less than 1000 resource: contraption severity: FAILURE assertions: - key: size op: lt value: 1000 value_type: integer - id: CONTRAPTION_LOCATIONS message: Contraption location must have city resource: contraption severity: FAILURE assertions: - every: key: locations assertions: - key: city op: present ``` There is an example file [here](example-files/config/generic.config) that this rule file can scan. ================================================ FILE: example-files/config/cloudfront.tf ================================================ resource "aws_s3_bucket" "b" { bucket = "mybucket" acl = "private" tags { Name = "My bucket" } } resource "aws_cloudfront_distribution" "s3_distribution" { origin { domain_name = "${aws_s3_bucket.b.bucket_domain_name}" origin_id = "myS3Origin" s3_origin_config { origin_access_identity = "origin-access-identity/cloudfront/ABCDEFG1234567" } } enabled = true is_ipv6_enabled = true comment = "Some comment" default_root_object = "index.html" logging_config { include_cookies = false bucket = "mylogs.s3.amazonaws.com" prefix = "myprefix" } aliases = ["mysite.example.com", "yoursite.example.com"] default_cache_behavior { allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] cached_methods = ["GET", "HEAD"] target_origin_id = "myS3Origin" forwarded_values { query_string = false cookies { forward = "none" } } viewer_protocol_policy = "allow-all" min_ttl = 0 default_ttl = 3600 max_ttl = 86400 } price_class = "PriceClass_200" restrictions { geo_restriction { restriction_type = "whitelist" locations = ["US", "CA", "GB", "DE"] } } tags { Environment = "production" } viewer_certificate { cloudfront_default_certificate = true } } ================================================ FILE: example-files/config/elb.tf ================================================ resource "aws_elb" "bar" { name = "foobar-terraform-elb" availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"] access_logs { bucket = "foo" bucket_prefix = "bar" interval = 60 } listener { instance_port = 8000 instance_protocol = "http" lb_port = 80 lb_protocol = "http" } listener { instance_port = 8000 instance_protocol = "http" lb_port = 443 lb_protocol = "https" ssl_certificate_id = "arn:aws:iam::123456789012:server-certificate/certName" } health_check { healthy_threshold = 2 unhealthy_threshold = 2 timeout = 3 target = "HTTP:8000/" interval = 30 } instances = ["${aws_instance.foo.id}"] cross_zone_load_balancing = true idle_timeout = 400 connection_draining = true connection_draining_timeout = 400 tags { Name = "foobar-terraform-elb" } } resource "aws_elb" "elb_no_access_logs" { name = "foobar-terraform-elb" availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"] listener { instance_port = 8000 instance_protocol = "http" lb_port = 80 lb_protocol = "http" } listener { instance_port = 8000 instance_protocol = "http" lb_port = 443 lb_protocol = "https" ssl_certificate_id = "arn:aws:iam::123456789012:server-certificate/certName" } health_check { healthy_threshold = 2 unhealthy_threshold = 2 timeout = 3 target = "HTTP:8000/" interval = 30 } instances = ["${aws_instance.foo.id}"] cross_zone_load_balancing = true idle_timeout = 400 connection_draining = true connection_draining_timeout = 400 tags { Name = "foobar-terraform-elb" } } ================================================ FILE: example-files/config/generic.config ================================================ # widgets: - id: W1 name: Foo - id: W2 name: Bar - id: W3 key: Baz gadgets: - name: first_gadget color: red - name: second_gadget color: blue - name: third_gadget color: green - name: fourth_gadget color: yellow other_stuff: contraptions: - ids: serial_number: S1000 sku: S1234 size: 10 locations: - city: Seattle - city: San Francisco - ids: serial_number: S2000 sku: S5678 size: 20 locations: - city: New York - ids: serial_number: S3000 sku: S0101 size: 4000 locations: - city: Paris - city: Munich - city: Florence ================================================ FILE: example-files/config/iam.tf ================================================ resource "aws_iam_role" "iam_role_1" { name = "iam_role_1" assume_role_policy = < 0 { resourcePos := resourceItems[0].Val.Pos() assertion.Debugf("Position %s %s:%d\n", resourceID, filename, resourcePos.Line) return resourcePos.Line } return 0 } // Load parses an HCL file into a collection or Resource objects func (l TerraformResourceLoader) Load(filename string) (FileResources, error) { loaded := FileResources{ Resources: []assertion.Resource{}, } result, err := loadHCL(filename) if err != nil { return loaded, err } loaded.Variables = result.Variables loaded.Resources = append(loaded.Resources, getResources(filename, result.AST, result.Resources, "resource")...) loaded.Resources = append(loaded.Resources, getResources(filename, result.AST, result.Data, "data")...) loaded.Resources = append(loaded.Resources, getResources(filename, result.AST, addIDToProviders(result.Providers), "provider")...) loaded.Resources = append(loaded.Resources, getResources(filename, result.AST, addKeyToModules(result.Modules), "module")...) assertion.DebugJSON("loaded.Resources", loaded.Resources) return loaded, nil } // Providers do not have an name, so generate one to make the data format the same as resources func addIDToProviders(providers []interface{}) []interface{} { resources := []interface{}{} for _, provider := range providers { resources = append(resources, addIDToProvider(provider)) } return resources } func addIDToProvider(provider interface{}) interface{} { m := provider.(map[string]interface{}) for providerType, value := range m { m[providerType] = addIDToProviderValue(value) } return m } // Counter used to generate an ID for providers var Counter = 0 func addIDToProviderValue(value interface{}) interface{} { Counter++ m := map[string]interface{}{} key := fmt.Sprintf("%d", Counter) m[key] = value return []interface{}{m} } // use the source attribute of modules as the key func addKeyToModules(modules []interface{}) []interface{} { resources := map[string]interface{}{} for _, module := range modules { resources = addKeyToModule(resources, module) } return []interface{}{resources} } func addKeyToModule(resources map[string]interface{}, module interface{}) map[string]interface{} { m := module.(map[string]interface{}) for moduleName, valueList := range m { a := valueList.([]interface{}) for _, value := range a { properties := value.(map[string]interface{}) source := properties["source"].(string) inner := []interface{}{properties} outer := map[string]interface{}{} outer[moduleName] = inner existing, ok := resources[source] if ok { list := existing.([]interface{}) resources[source] = append(list, outer) } else { resources[source] = []interface{}{outer} } } } return resources } func getResources(filename string, ast *ast.File, objects []interface{}, category string) []assertion.Resource { resources := []assertion.Resource{} for _, resource := range objects { for resourceType, templateResources := range resource.(map[string]interface{}) { if templateResources != nil { for _, templateResource := range templateResources.([]interface{}) { for resourceID, templateResource := range templateResource.(map[string]interface{}) { properties := getProperties(templateResource) lineNumber := getResourceLineNumber(resourceType, resourceID, filename, ast) // Expose block ID so it could be linted e.g. // resource "aws_s3_bucket" "web" { ... } // The block name/id here is "web". properties["__name__"] = resourceID properties["__file__"] = filename properties["__dir__"] = filepath.Dir(filename) tr := assertion.Resource{ ID: resourceID, Type: resourceType, Category: category, Properties: properties, Filename: filename, LineNumber: lineNumber, } resources = append(resources, tr) } } } } } return resources } // PostLoad resolves variable expressions func (l TerraformResourceLoader) PostLoad(fr FileResources) ([]assertion.Resource, error) { for _, resource := range fr.Resources { resource.Properties = replaceVariables(resource.Properties, fr.Variables) } for _, resource := range fr.Resources { properties, err := parseJSONDocuments(resource.Properties) if err != nil { return fr.Resources, err } resource.Properties = properties } return fr.Resources, nil } func replaceVariables(templateResource interface{}, variables []Variable) interface{} { switch v := templateResource.(type) { case map[string]interface{}: return replaceVariablesInMap(v, variables) case string: return interpolate(v, variables) default: assertion.Debugf("replaceVariables cannot process type %T: %v\n", v, v) return templateResource } } func replaceVariablesInMap(templateResource map[string]interface{}, variables []Variable) interface{} { for key, value := range templateResource { switch v := value.(type) { case string: templateResource[key] = interpolate(v, variables) case map[string]interface{}: templateResource[key] = replaceVariablesInMap(v, variables) case []interface{}: templateResource[key] = replaceVariablesInList(v, variables) default: assertion.Debugf("replaceVariablesInMap cannot process type %T\n", v) } } return templateResource } func replaceVariablesInList(list []interface{}, variables []Variable) []interface{} { result := []interface{}{} for _, e := range list { result = append(result, replaceVariables(e, variables)) } return result } func parseJSONDocuments(resource interface{}) (interface{}, error) { properties := resource.(map[string]interface{}) for _, attribute := range []string{"assume_role_policy", "policy", "container_definitions", "access_policies", "access_policy", "container_properties"} { if policyAttribute, hasPolicyString := properties[attribute]; hasPolicyString { if policyString, isString := policyAttribute.(string); isString { var policy interface{} if policyString != "" && policyString != "UNDEFINED" { err := json.Unmarshal([]byte(policyString), &policy) if err != nil { assertion.Debugf("Unable to parse '%s' as JSON\n", policyString) assertion.Debugf("Error: %v\n", err) } } properties[attribute] = policy } } } return properties, nil } func getProperties(templateResource interface{}) map[string]interface{} { switch v := templateResource.(type) { case []interface{}: return v[0].(map[string]interface{}) default: return map[string]interface{}{} } } ================================================ FILE: linter/terraform_interpolate.go ================================================ package linter import ( "fmt" "github.com/hashicorp/hil" "github.com/hashicorp/hil/ast" "github.com/stelligent/config-lint/assertion" "io/ioutil" "regexp" "strconv" "strings" ) func makeList(variables []interface{}) []ast.Variable { list := []ast.Variable{} for _, v := range variables { list = append(list, makeVar(v)) } return list } func makeMap(m map[string]interface{}) map[string]ast.Variable { result := map[string]ast.Variable{} for k, v := range m { if stringValue, ok := v.(string); ok { result[k] = ast.Variable{Type: ast.TypeString, Value: stringValue} } } return result } func makeVar(v interface{}) ast.Variable { switch tv := v.(type) { case string: return ast.Variable{ Type: ast.TypeString, Value: tv, } case []interface{}: return ast.Variable{ Type: ast.TypeList, Value: makeList(tv), } case map[string]interface{}: m := ast.Variable{ Type: ast.TypeMap, Value: makeMap(tv), } return m default: return ast.Variable{ Type: ast.TypeString, Value: "", } } } func makeVarMap(variables []Variable) map[string]ast.Variable { m := map[string]ast.Variable{} for _, v := range variables { m[v.Name] = makeVar(v.Value) } return m } func interpolationFuncFile() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeString}, ReturnType: ast.TypeString, Variadic: false, Callback: func(inputs []interface{}) (interface{}, error) { b, err := ioutil.ReadFile(inputs[0].(string)) if err != nil { return "", nil } return strings.TrimSpace(string(b)), nil }, } } // The following functions are copied or adapted // from https://github.com/hashicorp/terraform/blob/master/config/interpolate_funcs.go // interpolationFuncLookup implements the "lookup" function that allows // dynamic lookups of map types within a Terraform configuration. func interpolationFuncLookup() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeMap, ast.TypeString}, ReturnType: ast.TypeString, Variadic: true, VariadicType: ast.TypeString, Callback: func(args []interface{}) (interface{}, error) { defaultValue := "" defaultValueSet := false if len(args) > 2 { defaultValue = args[2].(string) defaultValueSet = true } if len(args) > 3 { return "", fmt.Errorf("lookup() takes no more than three arguments") } index := args[1].(string) mapVar := args[0].(map[string]ast.Variable) v, ok := mapVar[index] if !ok { if defaultValueSet { return defaultValue, nil } else { return "", fmt.Errorf( "lookup failed to find '%s'", args[1].(string)) } } if v.Type != ast.TypeString { return nil, fmt.Errorf( "lookup() may only be used with flat maps, this map contains elements of %s", v.Type.Printable()) } return v.Value.(string), nil }, } } // interpolationFuncJoin implements the "join" function that allows // multi-variable values to be joined by some character. func interpolationFuncJoin() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeString}, Variadic: true, VariadicType: ast.TypeList, ReturnType: ast.TypeString, Callback: func(args []interface{}) (interface{}, error) { var list []string if len(args) < 2 { return nil, fmt.Errorf("not enough arguments to join()") } for _, arg := range args[1:] { for _, part := range arg.([]ast.Variable) { if part.Type != ast.TypeString { return nil, fmt.Errorf( "only works on flat lists, this list contains elements of %s", part.Type.Printable()) } list = append(list, part.Value.(string)) } } return strings.Join(list, args[0].(string)), nil }, } } // interpolationFuncConcat implements the "concat" function that concatenates // multiple lists. func interpolationFuncConcat() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeList}, ReturnType: ast.TypeList, Variadic: true, VariadicType: ast.TypeList, Callback: func(args []interface{}) (interface{}, error) { var outputList []ast.Variable for _, arg := range args { for _, v := range arg.([]ast.Variable) { switch v.Type { case ast.TypeString: outputList = append(outputList, v) case ast.TypeList: outputList = append(outputList, v) case ast.TypeMap: outputList = append(outputList, v) default: return nil, fmt.Errorf("concat() does not support lists of %s", v.Type.Printable()) } } } // we don't support heterogeneous types, so make sure all types match the first if len(outputList) > 0 { firstType := outputList[0].Type for _, v := range outputList[1:] { if v.Type != firstType { return nil, fmt.Errorf("unexpected %s in list of %s", v.Type.Printable(), firstType.Printable()) } } } return outputList, nil }, } } // interpolationFuncFormat implements the "format" function that does // string formatting. func interpolationFuncFormat() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeString}, Variadic: true, VariadicType: ast.TypeAny, ReturnType: ast.TypeString, Callback: func(args []interface{}) (interface{}, error) { format := args[0].(string) return fmt.Sprintf(format, args[1:]...), nil }, } } // interpolationFuncList creates a list from the parameters passed // to it. func interpolationFuncList() ast.Function { return ast.Function{ ArgTypes: []ast.Type{}, ReturnType: ast.TypeList, Variadic: true, VariadicType: ast.TypeAny, Callback: func(args []interface{}) (interface{}, error) { var outputList []ast.Variable for i, val := range args { switch v := val.(type) { case string: outputList = append(outputList, ast.Variable{Type: ast.TypeString, Value: v}) case []ast.Variable: outputList = append(outputList, ast.Variable{Type: ast.TypeList, Value: v}) case map[string]ast.Variable: outputList = append(outputList, ast.Variable{Type: ast.TypeMap, Value: v}) default: return nil, fmt.Errorf("unexpected type %T for argument %d in list", v, i) } } // we don't support heterogeneous types, so make sure all types match the first if len(outputList) > 0 { firstType := outputList[0].Type for i, v := range outputList[1:] { if v.Type != firstType { return nil, fmt.Errorf("unexpected type %s for argument %d in list", v.Type, i+1) } } } return outputList, nil }, } } // interpolationFuncReplace implements the "replace" function that does // string replacement. func interpolationFuncReplace() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeString, ast.TypeString, ast.TypeString}, ReturnType: ast.TypeString, Callback: func(args []interface{}) (interface{}, error) { s := args[0].(string) search := args[1].(string) replace := args[2].(string) // We search/replace using a regexp if the string is surrounded // in forward slashes. if len(search) > 1 && search[0] == '/' && search[len(search)-1] == '/' { re, err := regexp.Compile(search[1 : len(search)-1]) if err != nil { return nil, err } return re.ReplaceAllString(s, replace), nil } return strings.Replace(s, search, replace, -1), nil }, } } // interpolationFuncElement implements the "element" function that allows // a specific index to be looked up in a multi-variable value. Note that this will // wrap if the index is larger than the number of elements in the multi-variable value. func interpolationFuncElement() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeList, ast.TypeString}, ReturnType: ast.TypeString, Callback: func(args []interface{}) (interface{}, error) { list := args[0].([]ast.Variable) if len(list) == 0 { return nil, fmt.Errorf("element() may not be used with an empty list") } index, err := strconv.Atoi(args[1].(string)) if err != nil || index < 0 { return "", fmt.Errorf( "invalid number for index, got %s", args[1]) } resolvedIndex := index % len(list) v := list[resolvedIndex] if v.Type != ast.TypeString { return nil, fmt.Errorf( "element() may only be used with flat lists, this list contains elements of %s", v.Type.Printable()) } return v.Value, nil }, } } // interpolationFuncMap creates a map from the parameters passed // to it. func interpolationFuncMap() ast.Function { return ast.Function{ ArgTypes: []ast.Type{}, ReturnType: ast.TypeMap, Variadic: true, VariadicType: ast.TypeAny, Callback: func(args []interface{}) (interface{}, error) { outputMap := make(map[string]ast.Variable) if len(args)%2 != 0 { return nil, fmt.Errorf("requires an even number of arguments, got %d", len(args)) } var firstType *ast.Type for i := 0; i < len(args); i += 2 { key, ok := args[i].(string) if !ok { return nil, fmt.Errorf("argument %d represents a key, so it must be a string", i+1) } val := args[i+1] variable, err := hil.InterfaceToVariable(val) if err != nil { return nil, err } // Enforce map type homogeneity if firstType == nil { firstType = &variable.Type } else if variable.Type != *firstType { return nil, fmt.Errorf("all map values must have the same type, got %s then %s", firstType.Printable(), variable.Type.Printable()) } // Check for duplicate keys if _, ok := outputMap[key]; ok { return nil, fmt.Errorf("argument %d is a duplicate key: %q", i+1, key) } outputMap[key] = variable } return outputMap, nil }, } } func interpolationFuncMerge() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeMap}, ReturnType: ast.TypeMap, Variadic: true, VariadicType: ast.TypeMap, Callback: func(args []interface{}) (interface{}, error) { outputMap := make(map[string]ast.Variable) for _, arg := range args { for k, v := range arg.(map[string]ast.Variable) { outputMap[k] = v } } return outputMap, nil }, } } func Funcs() map[string]ast.Function { return map[string]ast.Function{ "concat": interpolationFuncConcat(), "element": interpolationFuncElement(), "file": interpolationFuncFile(), "format": interpolationFuncFormat(), "join": interpolationFuncJoin(), "list": interpolationFuncList(), "lookup": interpolationFuncLookup(), "map": interpolationFuncMap(), "merge": interpolationFuncMerge(), "replace": interpolationFuncReplace(), } } func interpolate(s string, variables []Variable) interface{} { if strings.Index(s, "${") == -1 { // no interpolation to be done return s } assertion.Debugf("interpolate: %s\n", s) config := &hil.EvalConfig{ GlobalScope: &ast.BasicScope{ VarMap: makeVarMap(variables), FuncMap: Funcs(), }, } tree, err := hil.Parse(s) if err != nil { assertion.Debugf("Parse error: %v\n", err) return s } result, err := hil.Eval(tree, config) if err != nil { assertion.Debugf("Eval error: %v\n", err) return s } assertion.Debugf("interpolation result: %v\n", result.Value) if stringValue, ok := result.Value.(string); ok { if stringValue == s { return stringValue // no changes, no need for recursive call } return interpolate(stringValue, variables) } return result.Value } ================================================ FILE: linter/terraform_interpolate_test.go ================================================ package linter import ( "github.com/stretchr/testify/assert" "testing" ) type interpolationTestCase struct { Input string Expected interface{} } func TestInterpolation(t *testing.T) { testCases := []interpolationTestCase{ {"${2+6}", "8"}, {"bucket_${var.environment}", "bucket_development"}, {"${var.environment == \"development\" ? \"YES\" : \"NO\"}", "YES"}, {"${local.count + local.count}", "202"}, {"${replace(var.template,var.user_pattern,var.user)}", "https://users/adam"}, {"${list(var.a, var.b, var.c)}", []interface{}{"one", "two", "three"}}, {"${element(list(var.a, var.b, var.c),2)}", "three"}, {"${join(var.pipe, list(var.a, var.b))}", "one|two"}, {"${concat(list(var.a,var.b), list(var.c))}", []interface{}{"one", "two", "three"}}, {"${format(\"id-%s\",var.a)}", "id-one"}, {"${map(var.k1,var.v1,var.k2,var.v2)}", map[string]interface{}{"key1": "value1", "key2": "value2"}}, {"${missing_func(1)}", "${missing_func(1)}"}, {"${module.required_tags.tags}", "${module.required_tags.tags}"}, {"${merge(map(\"NodeType\", \"Runner\"), var.tags)}", map[string]interface{}{"NodeType": "Runner", "Name": "Web"}}, {"{\"version\":\"$LATEST\"}", "{\"version\":\"$LATEST\"}"}, {"echo $PWD", "echo $PWD"}, } vars := []Variable{ {Name: "var.environment", Value: "development"}, {Name: "local.count", Value: "101"}, {Name: "var.template", Value: "https://users/USER_ID"}, {Name: "var.user_pattern", Value: "USER_ID"}, {Name: "var.user", Value: "adam"}, {Name: "var.a", Value: "one"}, {Name: "var.b", Value: "two"}, {Name: "var.c", Value: "three"}, {Name: "var.pipe", Value: "|"}, {Name: "var.k1", Value: "key1"}, {Name: "var.k2", Value: "key2"}, {Name: "var.v1", Value: "value1"}, {Name: "var.v2", Value: "value2"}, {Name: "var.tags", Value: map[string]interface{}{"Name": "Web"}}, } for _, tc := range testCases { result := interpolate(tc.Input, vars) assert.Equal(t, tc.Expected, result) } } ================================================ FILE: linter/terraform_test.go ================================================ package linter import ( "os" "testing" "github.com/stelligent/config-lint/assertion" "github.com/stretchr/testify/assert" ) func TestTerraformLinter(t *testing.T) { options := Options{ Tags: []string{}, RuleIDs: []string{}, } filenames := []string{"./testdata/resources/terraform_instance.tf"} linter := FileLinter{Filenames: filenames, ValueSource: TestingValueSource{}, Loader: TerraformResourceLoader{}} ruleSet := loadRulesForTest("./testdata/rules/terraform_instance.yml", t) report, err := linter.Validate(ruleSet, options) assert.Nil(t, err, "Expecting Validate to run without error") assert.Equal(t, len(report.ResourcesScanned), 1, "Unexpected number of resources scanned") assert.Equal(t, len(report.FilesScanned), 1, "Unexpected number of files scanned") assertViolationsCount("TestTerraformLinter ", 0, report.Violations, t) } func loadResourcesToTest(t *testing.T, filename string) []assertion.Resource { loader := TerraformResourceLoader{} loaded, err := loader.Load(filename) assert.Nil(t, err, "Expecting Load to run without error") resources, err := loader.PostLoad(loaded) assert.Nil(t, err, "Expecting PostLoad to run without error") return resources } func getResourceTags(r assertion.Resource) map[string]interface{} { properties := r.Properties.(map[string]interface{}) tags := properties["tags"].([]interface{}) return tags[0].(map[string]interface{}) } func TestTerraformVariable(t *testing.T) { resources := loadResourcesToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, len(resources), 1, "Expecting 1 resource") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["ami"], "ami-f2d3638a", "Unexpected value for simple variable") } func TestTerraformVariableWithNoDefault(t *testing.T) { resources := loadResourcesToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, len(resources), 1, "Expecting 1 resource") tags := getResourceTags(resources[0]) assert.Equal(t, tags["department"], "", "Unexpected value for variable with no default") } func TestTerraformFunctionCall(t *testing.T) { resources := loadResourcesToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, len(resources), 1, "Expecting 1 resource") tags := getResourceTags(resources[0]) assert.Equal(t, tags["environment"], "test", "Unexpected value for lookup function") } func TestTerraformListVariable(t *testing.T) { resources := loadResourcesToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, len(resources), 1, "Expecting 1 resource") tags := getResourceTags(resources[0]) assert.Equal(t, tags["comment"], "bar", "Unexpected value for list variable") } func TestTerraformLocalVariable(t *testing.T) { resources := loadResourcesToTest(t, "./testdata/resources/uses_local_variables.tf") assert.Equal(t, len(resources), 1, "Expecting 1 resource") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, "myprojectbucket", properties["name"], "Unexpected value for name attribute") } func TestTerraformVariablesFromEnvironment(t *testing.T) { os.Setenv("TF_VAR_instance_type", "c4.large") resources := loadResourcesToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["instance_type"], "c4.large", "Unexpected value for instance_type") os.Setenv("TF_VAR_instance_type", "") } func TestTerraformFileFunction(t *testing.T) { resources := loadResourcesToTest(t, "./testdata/resources/reference_file.tf") assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["bucket"], "example", "Unexpected value for bucket property") } func TestTerraformVariablesInDifferentFile(t *testing.T) { options := Options{ Tags: []string{}, RuleIDs: []string{}, } filenames := []string{ "./testdata/resources/defines_variables.tf", "./testdata/resources/reference_variables.tf", } linter := FileLinter{Filenames: filenames, ValueSource: TestingValueSource{}, Loader: TerraformResourceLoader{}} ruleSet := loadRulesForTest("./testdata/rules/terraform_instance.yml", t) report, err := linter.Validate(ruleSet, options) assert.Nil(t, err, "Expecting Validate to run without error") assert.Equal(t, len(report.ResourcesScanned), 1, "Unexpected number of resources") assert.Equal(t, len(report.FilesScanned), 2, "Unexpected number of files scanned") assertViolationsCount("TestTerraformVariablesInDifferentFile ", 0, report.Violations, t) } type TestingValueSource struct{} func (s TestingValueSource) GetValue(a assertion.Expression) (string, error) { if a.ValueFrom.URL != "" { return "TEST", nil } return a.Value, nil } func TestTerraformDataLoader(t *testing.T) { loader := TerraformResourceLoader{} loaded, err := loader.Load("./testdata/resources/terraform_data.tf") assert.Nil(t, err, "Expecting Load to run without error") assert.Equal(t, len(loaded.Resources), 1, "Unexpected number of resources") } type terraformLinterTestCase struct { ConfigurationFilename string RulesFilename string ExpectedViolationCount int ExpectedViolationRuleID string } func TestTerraformLinterCases(t *testing.T) { testCases := map[string]terraformLinterTestCase{ "ParseError": { "./testdata/resources/terraform_syntax_error.tf", "./testdata/rules/terraform_provider.yml", 1, "FILE_LOAD", }, "Provider": { "./testdata/resources/terraform_provider.tf", "./testdata/rules/terraform_provider.yml", 1, "AWS_PROVIDER", }, "DataObject": { "./testdata/resources/terraform_data.tf", "./testdata/rules/terraform_data.yml", 1, "DATA_NOT_CONTAINS", }, "PoliciesWithVariables": { "./testdata/resources/policy_with_variables.tf", "./testdata/rules/policy_variable.yml", 0, "", }, "HereDocWithExpression": { "./testdata/resources/policy_with_expression.tf", "./testdata/rules/policy_variable.yml", 0, "", }, "Policies": { "./testdata/resources/terraform_policy.tf", "./testdata/rules/terraform_policy.yml", 1, "TEST_POLICY", }, "PolicyInvalidJSON": { "./testdata/resources/terraform_policy_invalid_json.tf", "./testdata/rules/terraform_policy.yml", 0, "", }, "PolicyEmpty": { "./testdata/resources/terraform_policy_empty.tf", "./testdata/rules/terraform_policy.yml", 0, "", }, "Module": { "./testdata/resources/terraform_module.tf", "./testdata/rules/terraform_module.yml", 1, "MODULE_DESCRIPTION", }, "BatchPrivileged": { "./testdata/resources/batch_privileged.tf", "./testdata/rules/batch_definition.yml", 1, "BATCH_DEFINITION_PRIVILEGED", }, "CloudfrontAccessLogs": { "./testdata/resources/cloudfront_access_logs.tf", "./testdata/rules/cloudfront_access_logs.yml", 0, "", }, "PublicEC2": { "./testdata/resources/ec2_public.tf", "./testdata/rules/ec2_public.yml", 0, "", }, "ElastiCacheRest": { "./testdata/resources/elasticache_encryption_rest.tf", "./testdata/rules/elasticache_encryption_rest.yml", 1, "ELASTICACHE_ENCRYPTION_REST", }, "ElastiCacheTransit": { "./testdata/resources/elasticache_encryption_transit.tf", "./testdata/rules/elasticache_encryption_transit.yml", 1, "ELASTICACHE_ENCRYPTION_TRANSIT", }, "NeptuneClusterEncryption": { "./testdata/resources/neptune_db_encryption.tf", "./testdata/rules/neptune_db_encryption.yml", 1, "NEPTUNE_DB_ENCRYPTION", }, "RdsPublic": { "./testdata/resources/rds_publicly_available.tf", "./testdata/rules/rds_publicly_available.yml", 0, "", }, "KinesisKms": { "./testdata/resources/kinesis_kms_stream.tf", "./testdata/rules/kinesis_kms_stream.yml", 1, "KINESIS_STREAM_KMS", }, "DmsEncryption": { "./testdata/resources/dms_endpoint_encryption.tf", "./testdata/rules/dms_endpoint_encryption.yml", 0, "", }, "EmrClusterLogs": { "./testdata/resources/emr_cluster_logs.tf", "./testdata/rules/emr_cluster_logs.yml", 1, "AWS_EMR_CLUSTER_LOGGING", }, "KmsKeyRotation": { "./testdata/resources/kms_key_rotation.tf", "./testdata/rules/kms_key_rotation.yml", 1, "AWS_KMS_KEY_ROTATION", }, "SagemakerEndpoint": { "./testdata/resources/sagemaker_endpoint_encryption.tf", "./testdata/rules/sagemaker_endpoint_encryption.yml", 1, "SAGEMAKER_ENDPOINT_ENCRYPTION", }, "SagemakerNotebook": { "./testdata/resources/sagemaker_notebook_encryption.tf", "./testdata/rules/sagemaker_notebook_encryption.yml", 1, "SAGEMAKER_NOTEBOOK_ENCRYPTION", }, } for name, tc := range testCases { options := Options{ Tags: []string{}, RuleIDs: []string{}, } filenames := []string{tc.ConfigurationFilename} linter := FileLinter{Filenames: filenames, ValueSource: TestingValueSource{}, Loader: TerraformResourceLoader{}} ruleSet := loadRulesForTest(tc.RulesFilename, t) report, err := linter.Validate(ruleSet, options) if err != nil { t.Errorf("Expecting %s to return without an error: %s", name, err.Error()) } if len(report.FilesScanned) != 1 { t.Errorf("TestTerraformLinterCases scanned %d files, expecting 1", len(report.FilesScanned)) } if len(report.Violations) != tc.ExpectedViolationCount { t.Errorf("%s returned %d violations, expecting %d", name, len(report.Violations), tc.ExpectedViolationCount) t.Errorf("Violations: %v", report.Violations) } if tc.ExpectedViolationRuleID != "" { assertViolationByRuleID(name, tc.ExpectedViolationRuleID, report.Violations, t) } } } ================================================ FILE: linter/terraform_v12.go ================================================ package linter import ( "strconv" "strings" "github.com/hashicorp/hcl/v2" "github.com/stelligent/config-lint/assertion" "github.com/stelligent/config-lint/linter/tf12parser" "github.com/zclconf/go-cty/cty" ) type ( // Terraform12ResourceLoader converts Terraform configuration files into JSON objects Terraform12ResourceLoader struct{} // Terraform12LoadResult collects all the returns value for parsing an HCL string Terraform12LoadResult struct { Resources []assertion.Resource Data []interface{} Providers []interface{} Modules []interface{} Variables []Variable AST *hcl.File } ) var ( blockTypes = []string{ "data", "locals", "module", "output", "provider", "resource", "terraform", "variable", } blockLabelSyntax = map[string][]string{ "TypeAndName": []string{"data", "resource"}, "TypeOnly": []string{"provider"}, "NameOnly": []string{"module", "output", "variable"}, "NoTypeNoName": []string{"locals", "terraform"}, } ) // Load parses an HCLv2 file into a collection or Resource objects //TODO: This should be unused, but can't remove due to the interface, I think? func (l Terraform12ResourceLoader) Load(filename string) (FileResources, error) { loaded := FileResources{ Resources: []assertion.Resource{}, } result, err := loadHCLv2([]string{filename}) if err != nil { return loaded, err } loaded.Resources = result.Resources assertion.DebugJSON("loaded.Resources", loaded.Resources) return loaded, nil } func (l Terraform12ResourceLoader) LoadMany(filenames []string) (FileResources, error) { loaded := FileResources{ Resources: []assertion.Resource{}, } result, err := loadHCLv2(filenames) if err != nil { return loaded, err } loaded.Resources = result.Resources assertion.DebugJSON("loaded.Resources", loaded.Resources) return loaded, nil } func loadHCLv2(paths []string) (Terraform12LoadResult, error) { result := Terraform12LoadResult{ Resources: []assertion.Resource{}, Data: []interface{}{}, Providers: []interface{}{}, Modules: []interface{}{}, Variables: []Variable{}, } parser := *tf12parser.New() blocks, err := parser.ParseMany(paths) if err != nil { return result, err } // Get all Terraform blocks of a given type and append to the slice of Resources for _, blockType := range blockTypes { result.Resources = append(result.Resources, getBlocksOfType(blocks, blockType)...) } for _, resource := range result.Resources { properties, err := parseJSONDocuments(resource.Properties) if err != nil { return result, err } resource.Properties = properties } assertion.Debugf("LoadHCL Variables: %v\n", result.Variables) return result, nil } // Retrieves Terraform blocks of a specific type // and places them in a slice of assertion.Resources. func getBlocksOfType(blocks tf12parser.Blocks, blockCategory string) []assertion.Resource { var blockType string var blockName string var resources []assertion.Resource tfBlocks := blocks.OfType(blockCategory) i := 0 for _, block := range tfBlocks { properties := attributesToMap(*block) // Terraform has labels between 0 and 2 for each block e.g `locals`, `provider`, and `resource`, // and labels could be fixed types or configurable names. // Thus this code checks which label should be used as type and as a name/id. If the block doesn't have // a unique name, then its name/id assigned to an auto-incrementing integer. if assertion.SliceContains(blockLabelSyntax["TypeAndName"], blockCategory) { blockType = block.Labels()[0] blockName = block.Labels()[1] properties["__type__"] = blockType properties["__name__"] = blockName } else if assertion.SliceContains(blockLabelSyntax["TypeOnly"], blockCategory) { blockType = block.Labels()[0] blockName = strconv.Itoa(i) i++ properties["__type__"] = blockType properties["__name__"] = blockName } else if assertion.SliceContains(blockLabelSyntax["NameOnly"], blockCategory) { // A special handling for module to add its source as a type. if blockCategory == "module" { blockType = block.GetAttribute("source").Value().AsString() } else { blockType = blockCategory } blockName = block.Labels()[0] properties["__name__"] = blockName } else if assertion.SliceContains(blockLabelSyntax["NoTypeNoName"], blockCategory) { blockType = blockCategory blockName = strconv.Itoa(i) i++ } resource := assertion.Resource{ ID: blockName, Type: blockType, Category: blockCategory, Properties: properties, Filename: block.Range().Filename, LineNumber: block.Range().StartLine, } resources = append(resources, resource) } return resources } func attributesToMap(block tf12parser.Block) map[string]interface{} { propertyMap := make(map[string]interface{}) allBlocks := block.AllBlocks() for _, currentBlock := range allBlocks { var toAppend []interface{} toAppend = append(toAppend, attributesToMap(*currentBlock)) if propertyMap[currentBlock.Type()] == nil { propertyMap[currentBlock.Type()] = toAppend } else { v := propertyMap[currentBlock.Type()].([]interface{}) v = append(v, toAppend[0]) propertyMap[currentBlock.Type()] = v } } attributes := block.GetAttributes() for _, attribute := range attributes { value := attribute.Value() if value.Type().IsTupleType() { innerArray := make([]interface{}, 0) iter := value.ElementIterator() for iter.Next() { _, iterValue := iter.Element() if iterValue.CanIterateElements() { iterateElements(propertyMap, attribute.Name(), iterValue) } else { innerArray = append(innerArray, ctyValueToString(iterValue)) } } propertyMap[attribute.Name()] = innerArray } else if value.CanIterateElements() { iterateElements(propertyMap, attribute.Name(), value) } else { setValue(propertyMap, attribute.Name(), ctyValueToString(value)) } } return propertyMap } func iterateElements(propertyMap map[string]interface{}, name string, value cty.Value) { var innerArray []interface{} innerMap := make(map[string]interface{}) innerArray = append(innerArray, innerMap) propertyMap[name] = innerArray iter := value.ElementIterator() for iter.Next() { key, value := iter.Element() if value.CanIterateElements() { iterateElements(innerMap, ctyValueToString(key), value) } else { setValue(innerMap, ctyValueToString(key), ctyValueToString(value)) } } } func setValue(m map[string]interface{}, name string, value string) { environmentVariable := getVariableFromEnvironment(name) if environmentVariable == "" { m[name] = value } else { m[name] = environmentVariable } } func ctyValueToString(value cty.Value) string { // In case the value is nil but the type is not necessarily , ~~return an empty string~~ // Update: return an actual string. // We cannot evaluate tf generated values in tf12, such as referenced arn, but we still want to be able to check for it if value.IsNull() || !value.IsKnown() { return "UNDEFINED" } else { switch value.Type() { case cty.NilType: return "" case cty.Bool: if value.True() { return "true" } else { return "false" } case cty.String: return strings.Trim(value.AsString(), "\n") case cty.Number: if value.RawEquals(cty.PositiveInfinity) || value.RawEquals(cty.NegativeInfinity) { panic("cannot convert infinity to string") } return value.AsBigFloat().Text('f', -1) default: panic("unsupported primitive type") //return "" } } } // PostLoad resolves variable expressions func (l Terraform12ResourceLoader) PostLoad(inputResources FileResources) ([]assertion.Resource, error) { return inputResources.Resources, nil } ================================================ FILE: linter/terraform_v12_test.go ================================================ package linter import ( "fmt" "io/ioutil" "log" "os" "path/filepath" "strconv" "testing" "github.com/stelligent/config-lint/assertion" "github.com/stretchr/testify/assert" ) func TestTerraformV12Linter(t *testing.T) { options := Options{ Tags: []string{}, RuleIDs: []string{}, } filenames := []string{"./testdata/resources/terraform_instance.tf"} linter := FileLinter{Filenames: filenames, ValueSource: TestingValueSource{}, Loader: Terraform12ResourceLoader{}} ruleSet := loadRulesForTest("./testdata/rules/terraform_instance.yml", t) report, err := linter.Validate(ruleSet, options) assert.Nil(t, err, "Expecting Validate to run without error") assert.Equal(t, len(report.ResourcesScanned), 1, "Unexpected number of resources scanned") assert.Equal(t, len(report.FilesScanned), 1, "Unexpected number of files scanned") assertViolationsCount("TestTerraformLinter ", 0, report.Violations, t) } func loadResources12ToTest(t *testing.T, filename string) []assertion.Resource { loader := Terraform12ResourceLoader{} loaded, err := loader.Load(filename) assert.Nil(t, err, "Expecting Load to run without error") resources, err := loader.PostLoad(loaded) assert.Nil(t, err, "Expecting PostLoad to run without error") return resources } func filterByCategory(t *testing.T, resources []assertion.Resource, categoryName string) []assertion.Resource { result := []assertion.Resource{} for _, resource := range resources { if resource.Category == categoryName { result = append(result, resource) } } return result } func TestSingleResourceType(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, 1, len(resources), "Expecting 1 resource") assert.Equal(t, "aws_instance", resources[0].Type) assert.Equal(t, "first", resources[0].ID) } func TestResourceDependency(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/tf12_resource_dependency.tf") assert.Equal(t, 2, len(resources), "Expecting 1 resource") } //The idea of this test is to confirm a particular difference between the original parser and the new //I know it's not clear. - MN func TestTupleType(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/multi_level.tf") assert.Equal(t, 1, len(resources), "Expecting 1 resource") statement := resources[0].Properties.(map[string]interface{})["statement"] principals := statement.([]interface{})[0].(map[string]interface{})["principals"] identifiers := principals.([]interface{})[0].(map[string]interface{})["identifiers"] value, ok := identifiers.([]interface{}) assert.True(t, ok) _, ok = value[0].(string) assert.True(t, ok) } func TestMultipleBlocksOfSameType12(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/multiple_blocks_same.tf") assert.Equal(t, 1, len(resources), "Expecting 1 resource") properties := resources[0].Properties.(map[string]interface{}) ebsBlocks := properties["ebs_block_device"] assert.Len(t, ebsBlocks, 2) } func TestInnerObjects12(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_inner_objects.tf") assert.Equal(t, 2, len(resources), "Expecting 2 resource") properties := resources[1].Properties.(map[string]interface{}) artifactStore := properties["artifact_store"].([]interface{})[0].(map[string]interface{}) encryptionKey := artifactStore["encryption_key"] assert.NotNil(t, encryptionKey) } func TestTerraform12Variable(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, 1, len(resources), "Expecting 1 resource") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, "ami-f2d3638a", properties["ami"], "Unexpected value for simple variable") } func TestTerraform12VariableWithNoDefault(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Expecting 1 resource") tags := getResourceTags(resources[0]) assert.Equal(t, "UNDEFINED", tags["department"], "Unexpected value for variable with no default") } func TestTerraform12FunctionCall(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Expecting 1 resource") tags := getResourceTags(resources[0]) assert.Equal(t, "test", tags["environment"], "Unexpected value for lookup function") } func TestTerraform12ListVariable(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Expecting 1 resource") tags := getResourceTags(resources[0]) assert.Equal(t, tags["comment"], "bar", "Unexpected value for list variable") } func TestTerraform12LocalVariable(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/uses_local_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Expecting 1 resource") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, "myprojectbucket", properties["name"], "Unexpected value for name attribute") } func TestTerraform12VariablesFromEnvironment(t *testing.T) { os.Setenv("TF_VAR_instance_type", "c4.large") allResources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["instance_type"], "c4.large", "Unexpected value for instance_type") os.Setenv("TF_VAR_instance_type", "") } func TestTerraform12FileFunction(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/reference_file.tf") assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["bucket"], "example", "Unexpected value for bucket property") } func TestTerraform12VariablesInDifferentFile(t *testing.T) { options := Options{ Tags: []string{}, RuleIDs: []string{}, } filenames := []string{ "./testdata/resources/defines_variables.tf", "./testdata/resources/reference_variables.tf", } linter := FileLinter{Filenames: filenames, ValueSource: TestingValueSource{}, Loader: Terraform12ResourceLoader{}} ruleSet := loadRulesForTest("./testdata/rules/terraform_instance.yml", t) report, err := linter.Validate(ruleSet, options) assert.Nil(t, err, "Expecting Validate to run without error") assert.Equal(t, 1, len(report.ResourcesScanned), "Unexpected number of resources") assert.Equal(t, 2, len(report.FilesScanned), "Unexpected number of files scanned") assertViolationsCount("TestTerraformVariablesInDifferentFile ", 0, report.Violations, t) } func TestTerraform12DataLoader(t *testing.T) { loader := TerraformResourceLoader{} loaded, err := loader.Load("./testdata/resources/terraform_data.tf") assert.Nil(t, err, "Expecting Load to run without error") assert.Equal(t, len(loaded.Resources), 1, "Unexpected number of resources") } func TestTerraform12ResourceLineNumber(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, 30, resources[0].LineNumber) } func TestTerraform12ResourceFileName(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/uses_variables.tf") assert.Equal(t, "./testdata/resources/uses_variables.tf", resources[0].Filename) } func TestTerraform12DataLineNumber(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_data.tf") assert.Equal(t, 1, resources[0].LineNumber) } func TestTerraform12DataFileName(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_data.tf") assert.Equal(t, "./testdata/resources/terraform_data.tf", resources[0].Filename) } func TestTerraform12ProviderLineNumber(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_provider.tf") assert.Equal(t, 1, resources[0].LineNumber) } func TestTerraform12ProviderFileName(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_provider.tf") assert.Equal(t, "./testdata/resources/terraform_provider.tf", resources[0].Filename) } func TestTerraform12ModuleLineNumber(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_module.tf") assert.Equal(t, 1, resources[0].LineNumber) } func TestTerraform12ModuleFileName(t *testing.T) { resources := loadResources12ToTest(t, "./testdata/resources/terraform_module.tf") assert.Equal(t, "./testdata/resources/terraform_module.tf", resources[0].Filename) } // String build message for violations. Debug helper // TODO move to class. remove copy pasted function in tt 12 test func getViolationsString(violations []assertion.Violation) string { var violationsReported string for count, v := range violations { violationsReported += strconv.Itoa(count+1) + ". Violation:" violationsReported += "\n\tRule Message: " + v.RuleMessage violationsReported += "\n\tRule Id: " + v.RuleID violationsReported += "\n\tResource ID: " + v.ResourceID violationsReported += "\n\tResource Type: " + v.ResourceType violationsReported += "\n\tCategory: " + v.Category violationsReported += "\n\tStatus: " + v.Status violationsReported += "\n\tAssertion Message: " + v.AssertionMessage violationsReported += "\n\tFilename: " + v.Filename violationsReported += "\n\tLine Number: " + strconv.Itoa(v.LineNumber) violationsReported += "\n\tCreated At: " + v.CreatedAt + "\n" } return violationsReported } func TestTerraform12LinterCases(t *testing.T) { testCases := map[string]terraformLinterTestCase{ "ParseError": { "./testdata/resources/terraform_syntax_error.tf", "./testdata/rules/terraform_provider.yml", 1, "FILE_LOAD", }, "Provider": { "./testdata/resources/terraform_provider.tf", "./testdata/rules/terraform_provider.yml", 1, "AWS_PROVIDER", }, "DataObject": { "./testdata/resources/terraform_data.tf", "./testdata/rules/terraform_data.yml", 1, "DATA_NOT_CONTAINS", }, "PoliciesWithVariables": { "./testdata/resources/policy_with_variables.tf", "./testdata/rules/policy_variable.yml", 0, "", }, "HereDocWithExpression": { "./testdata/resources/policy_with_expression.tf", "./testdata/rules/policy_variable.yml", 0, "", }, "Policies": { "./testdata/resources/terraform_policy.tf", "./testdata/rules/terraform_policy.yml", 1, "TEST_POLICY", }, "PolicyInvalidJSON": { "./testdata/resources/terraform_policy_invalid_json.tf", "./testdata/rules/terraform_policy.yml", 0, "", }, "PolicyEmpty": { "./testdata/resources/terraform_policy_empty.tf", "./testdata/rules/terraform_policy.yml", 0, "", }, "BatchPrivileged": { "./testdata/resources/batch_privileged.tf", "./testdata/rules/batch_definition.yml", 1, "BATCH_DEFINITION_PRIVILEGED", }, "PublicEC2": { "./testdata/resources/ec2_public.tf", "./testdata/rules/ec2_public.yml", 0, "", }, "CloudfrontAccessLogs": { "./testdata/resources/cloudfront_access_logs.tf", "./testdata/rules/cloudfront_access_logs.yml", 0, "", }, "Module": { "./testdata/resources/terraform_module.tf", "./testdata/rules/terraform_module.yml", 1, "MODULE_DESCRIPTION", }, "ElastiCacheRest": { "./testdata/resources/elasticache_encryption_rest.tf", "./testdata/rules/elasticache_encryption_rest.yml", 1, "ELASTICACHE_ENCRYPTION_REST", }, "ElastiCacheTransit": { "./testdata/resources/elasticache_encryption_transit.tf", "./testdata/rules/elasticache_encryption_transit.yml", 1, "ELASTICACHE_ENCRYPTION_TRANSIT", }, "NeptuneClusterEncryption": { "./testdata/resources/neptune_db_encryption.tf", "./testdata/rules/neptune_db_encryption.yml", 1, "NEPTUNE_DB_ENCRYPTION", }, "RdsPublic": { "./testdata/resources/rds_publicly_available.tf", "./testdata/rules/rds_publicly_available.yml", 0, "", }, "KinesisKms": { "./testdata/resources/kinesis_kms_stream.tf", "./testdata/rules/kinesis_kms_stream.yml", 1, "KINESIS_STREAM_KMS", }, "DmsEncryption": { "./testdata/resources/dms_endpoint_encryption.tf", "./testdata/rules/dms_endpoint_encryption.yml", 0, "", }, "EmrClusterLogs": { "./testdata/resources/emr_cluster_logs.tf", "./testdata/rules/emr_cluster_logs.yml", 1, "AWS_EMR_CLUSTER_LOGGING", }, "KmsKeyRotation": { "./testdata/resources/kms_key_rotation.tf", "./testdata/rules/kms_key_rotation.yml", 1, "AWS_KMS_KEY_ROTATION", }, "SagemakerEndpoint": { "./testdata/resources/sagemaker_endpoint_encryption.tf", "./testdata/rules/sagemaker_endpoint_encryption.yml", 1, "SAGEMAKER_ENDPOINT_ENCRYPTION", }, "SagemakerNotebook": { "./testdata/resources/sagemaker_notebook_encryption.tf", "./testdata/rules/sagemaker_notebook_encryption.yml", 1, "SAGEMAKER_NOTEBOOK_ENCRYPTION", }, "TF12Variables": { "./testdata/resources/uses_tf12_variables.tf", "./testdata/rules/terraform_v12_variables.yml", 0, "", }, "TF12ForLoop": { "./testdata/resources/tf12_for_loop.tf", "./testdata/rules/tf12_for_loop.yml", 0, "", }, "TF12NullValue": { "./testdata/resources/nullable_value.tf", "./testdata/rules/nullable_value.yml", 0, "", }, "TF12DynamicBlock": { "./testdata/resources/dynamic_block.tf", "./testdata/rules/dynamic_block.yml", 1, "NO_SSH_ACCESS", }, "TF12Tagging": { "./testdata/resources/tagging.tf", "./testdata/rules/tagging.yml", 5, "TAG_VALID", }, "TF12ExplicitChar": { "./testdata/resources/explicit_chars.tf", "./testdata/rules/explicit_chars.yml", 1, "CHECK_FOR_COLON", }, } for name, tc := range testCases { options := Options{ Tags: []string{}, RuleIDs: []string{}, } filenames := []string{tc.ConfigurationFilename} linter := FileLinter{Filenames: filenames, ValueSource: TestingValueSource{}, Loader: Terraform12ResourceLoader{}} ruleSet := loadRulesForTest(tc.RulesFilename, t) report, err := linter.Validate(ruleSet, options) if err != nil { t.Errorf("Expecting %s to return without an error: %s", name, err.Error()) } if len(report.FilesScanned) != 1 { t.Errorf("TestTerraformLinterCases scanned %d files, expecting 1", len(report.FilesScanned)) } if len(report.Violations) != tc.ExpectedViolationCount { t.Errorf("%s returned %d violations, expecting %d", name, len(report.Violations), tc.ExpectedViolationCount) violationsReported := getViolationsString(report.Violations) t.Errorf("\nViolations: %v", violationsReported) } if tc.ExpectedViolationRuleID != "" { assertViolationByRuleID(name, tc.ExpectedViolationRuleID, report.Violations, t) } } } func TestTerraform12FileFunctionMultiLineContent(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/reference_file_multi_line.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 2, "Unexpected number of resources found") properties_1 := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties_1["test_value"], "multi\nline\nexample", "Unexpected value for bucket property") properties_2 := resources[1].Properties.(map[string]interface{}) assert.Equal(t, properties_2["test_value2"], properties_1["test_value"], "Unexpected value for bucket property") } func TestTerraform12FileFunctionResourceFileAbsolutePath(t *testing.T) { absolutePath, _ := filepath.Abs("./testdata/resources/reference_file.tf") resources := loadResources12ToTest(t, absolutePath) assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["bucket"], "example", "Unexpected value for bucket property") } func TestTerraform12FileFunctionTemplateFileFunction(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/template_file_function_basic.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["bucket"], "bucket-foo-example-bar", "Unexpected value for bucket property") } func TestTerraform12FileFunctionTemplateFileForLoop(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/template_file_function_for_loop.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["test_value"], "testing:foo\ntesting:bar", "Unexpected value for bucket property") } func TestTerraform12FileFunctionTemplateFileConditional(t *testing.T) { allResources := loadResources12ToTest(t, "./testdata/resources/template_file_function_conditional.tf") resources := filterByCategory(t, allResources, "resource") assert.Equal(t, len(resources), 2, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["test_value"], "Foo", "Unexpected value for bucket property") properties2 := resources[1].Properties.(map[string]interface{}) assert.Equal(t, properties2["test_value2"], "Bar", "Unexpected value for bucket property") } func TestTerraform12FileFunctionReferenceFileAbsoultePath(t *testing.T) { path, _ := os.Getwd() var err error var tempResourceFile *os.File var tempReferenceFile *os.File var tempResourceDir string var tempReferenceDir string tempResourceDir, err = ioutil.TempDir(path, "tf_resource") if err != nil { log.Fatal(err) } tempReferenceDir, err = ioutil.TempDir(tempResourceDir, "tf_reference") if err != nil { log.Fatal(err) } tempResourceFile, err = ioutil.TempFile(tempResourceDir, "test_resource.tf") if err != nil { log.Fatal(err) } tempReferenceFile, err = ioutil.TempFile(tempReferenceDir, "test_reference.txt") if err != nil { log.Fatal(err) } // tempReferenceFile.Name() is returned as the Absolute Path of the temp reference file tf12ResourceContent := fmt.Sprintf(`resource "aws_s3_bucket" "a_bucket" { bucket = "${file("%v")}" } `, tempReferenceFile.Name()) tf12ReferenceContent := (`example `) err = ioutil.WriteFile(tempResourceFile.Name(), []byte(tf12ResourceContent), 0644) if err != nil { log.Fatal(err) } err = ioutil.WriteFile(tempReferenceFile.Name(), []byte(tf12ReferenceContent), 0644) if err != nil { log.Fatal(err) } resources := loadResources12ToTest(t, tempResourceFile.Name()) assert.Equal(t, len(resources), 1, "Unexpected number of resources found") properties := resources[0].Properties.(map[string]interface{}) assert.Equal(t, properties["bucket"], "example", "Unexpected value for bucket property") os.RemoveAll(tempResourceDir) os.RemoveAll(tempReferenceDir) } ================================================ FILE: linter/testdata/data/bucket_name ================================================ example ================================================ FILE: linter/testdata/data/multi_line_content ================================================ multi line example ================================================ FILE: linter/testdata/data/reference_relative.tf ================================================ resource "aws_s3_bucket" "a_bucket" { bucket = "${file("bucket_name")}" } ================================================ FILE: linter/testdata/data/template_file_example_basic ================================================ bucket-${var1}-example-${var2} ================================================ FILE: linter/testdata/data/template_file_example_conditional ================================================ %{ if test_var == "Alpha" } Foo %{ else } Bar %{ endif ~} ================================================ FILE: linter/testdata/data/template_file_example_for_loop ================================================ %{ for word in words ~} testing:${word} %{ endfor ~} ================================================ FILE: linter/testdata/resources/batch_privileged.tf ================================================ resource "aws_batch_job_definition" "test" { name = "tf_test_batch_job_definition" type = "container" container_properties = <