# 🤖 A CARLOS scanner host: one box running clamd for a deployment's console. # # The console reads each object from S3 and streams it here. This box reads # nothing for itself: its role has Session Manager and nothing else, so a # compromised clamd cannot fetch a tenant object or a key. # # What makes it more than "install clamav" is in scanner-init.sh.tftpl and # files/: TCP 3310 is open only while a self-test has passed against the clamd # process running now. See README.md. data "aws_security_group" "client" { id = var.client_security_group_id } data "aws_vpc" "client" { id = data.aws_security_group.client.vpc_id } locals { # Fixed in this release. The self-test's fixtures and expected answers are # sized for it; making it a variable means deriving both. scan_limit = "100M" small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type)) init_script = templatefile("${path.module}/scanner-init.sh.tftpl", { region = var.region stream_max_mib = var.stream_max_bytes / 1048576 scan_limit = local.scan_limit memory_max = var.memory_max memory_high = var.memory_high concurrent_reload = var.concurrent_database_reload ? "yes" : "no" selftest_b64 = base64encode(file("${path.module}/files/scanner-selftest")) selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service")) tcp_socket_b64 = base64encode(file("${path.module}/files/scanner-tcp.socket")) tcp_service_b64 = base64encode(file("${path.module}/files/scanner-tcp.service")) }) } resource "aws_security_group" "scanner" { name = var.name description = "clamd, reachable from the console box only" vpc_id = data.aws_security_group.client.vpc_id # The whole fence. clamd authenticates nobody, so nothing but the console's # own group may reach it, and never a CIDR. ingress { description = "clamd INSTREAM from the console" from_port = 3310 to_port = 3310 protocol = "tcp" security_groups = [var.client_security_group_id] } # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin # access is SSM Session Manager. egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = var.name } } resource "aws_iam_role" "scanner" { name = var.name assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } Action = "sts:AssumeRole" }] }) } resource "aws_iam_role_policy_attachment" "scanner_ssm" { role = aws_iam_role.scanner.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } # AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone, # this box could read the deployment's scan tag key and its release key. An # explicit Deny beats the managed Allow and names only the parameter-read # actions, so Session Manager is untouched. Same shape as region-host's # host_ssm_parameter_scope, with no parameter allowed at all. resource "aws_iam_role_policy" "no_parameters" { name = "${var.name}-no-parameters" role = aws_iam_role.scanner.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Deny" Action = [ "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath", ] Resource = "*" }, ] }) } resource "aws_iam_instance_profile" "scanner" { name = var.name role = aws_iam_role.scanner.name } resource "aws_instance" "scanner" { ami = var.ami_id instance_type = var.instance_type subnet_id = var.subnet_id vpc_security_group_ids = [aws_security_group.scanner.id] iam_instance_profile = aws_iam_instance_profile.scanner.name # Gzipped: the script carries the self-test and three units, and EC2 caps # user_data at 16 KiB. cloud-init unpacks it. user_data_base64 = base64gzip(local.init_script) user_data_replace_on_change = false metadata_options { http_endpoint = "enabled" http_tokens = "required" http_put_response_hop_limit = 1 } root_block_device { volume_size = 20 volume_type = "gp3" encrypted = true } tags = { Name = var.name } # A newer image or an edited script never replaces the box by itself. # Changing either, including stream_max_bytes, is an explicit # `tofu apply -replace=module..aws_instance.scanner`. A replaced box # has a new private IP: update the console's CARLOS_CLAMD_ADDR. lifecycle { ignore_changes = [ami, user_data, user_data_base64] precondition { condition = var.subnet_id != null || data.aws_vpc.client.default error_message = "The client security group is not in the default VPC, so the scanner cannot use the default subnet: its security group and its instance would be in different VPCs. Pass subnet_id naming a subnet in the client group's VPC." } precondition { condition = !(local.small_box && var.concurrent_database_reload) error_message = "A t4g.small or smaller cannot hold two signature databases at once. Either use t4g.medium, or set concurrent_database_reload = false (scanning pauses during each reload)." } } }