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
================================================
[](https://img.shields.io/github/v/release/stelligent/config-lint?color=%233D9970)
[](https://github.com/stelligent/config-lint/workflows/Build%20%26%20Deploy/badge.svg)
[](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 = <