| 1 | # đŸ¤– A CARLOS scanner host: one box running clamd for a deployment's console. |
| 2 | # |
| 3 | # The console reads each object from S3 and streams it here. This box reads |
| 4 | # nothing for itself: its role has Session Manager and nothing else, so a |
| 5 | # compromised clamd cannot fetch a tenant object or a key. |
| 6 | # |
| 7 | # What makes it more than "install clamav" is in scanner-init.sh.tftpl and |
| 8 | # files/: TCP 3310 is open only while a self-test has passed against the clamd |
| 9 | # process running now. See README.md. |
| 10 | |
| 11 | data "aws_security_group" "client" { |
| 12 | id = var.client_security_group_id |
| 13 | } |
| 14 | |
| 15 | data "aws_vpc" "client" { |
| 16 | id = data.aws_security_group.client.vpc_id |
| 17 | } |
| 18 | |
| 19 | locals { |
| 20 | # Fixed in this release. The self-test's fixtures and expected answers are |
| 21 | # sized for it; making it a variable means deriving both. |
| 22 | scan_limit = "100M" |
| 23 | |
| 24 | small_box = can(regex("^t4g\\.(nano|micro|small)$", var.instance_type)) |
| 25 | |
| 26 | init_script = templatefile("${path.module}/scanner-init.sh.tftpl", { |
| 27 | region = var.region |
| 28 | stream_max_mib = var.stream_max_bytes / 1048576 |
| 29 | scan_limit = local.scan_limit |
| 30 | memory_max = var.memory_max |
| 31 | memory_high = var.memory_high |
| 32 | concurrent_reload = var.concurrent_database_reload ? "yes" : "no" |
| 33 | selftest_b64 = base64encode(file("${path.module}/files/scanner-selftest")) |
| 34 | selftest_unit_b64 = base64encode(file("${path.module}/files/scanner-selftest.service")) |
| 35 | tcp_socket_b64 = base64encode(file("${path.module}/files/scanner-tcp.socket")) |
| 36 | tcp_service_b64 = base64encode(file("${path.module}/files/scanner-tcp.service")) |
| 37 | }) |
| 38 | } |
| 39 | |
| 40 | resource "aws_security_group" "scanner" { |
| 41 | name = var.name |
| 42 | description = "clamd, reachable from the console box only" |
| 43 | vpc_id = data.aws_security_group.client.vpc_id |
| 44 | |
| 45 | # The whole fence. clamd authenticates nobody, so nothing but the console's |
| 46 | # own group may reach it, and never a CIDR. |
| 47 | ingress { |
| 48 | description = "clamd INSTREAM from the console" |
| 49 | from_port = 3310 |
| 50 | to_port = 3310 |
| 51 | protocol = "tcp" |
| 52 | security_groups = [var.client_security_group_id] |
| 53 | } |
| 54 | |
| 55 | # Signatures (freshclam) and packages (apt). No SSH ingress exists: admin |
| 56 | # access is SSM Session Manager. |
| 57 | egress { |
| 58 | from_port = 0 |
| 59 | to_port = 0 |
| 60 | protocol = "-1" |
| 61 | cidr_blocks = ["0.0.0.0/0"] |
| 62 | } |
| 63 | |
| 64 | tags = { Name = var.name } |
| 65 | } |
| 66 | |
| 67 | resource "aws_iam_role" "scanner" { |
| 68 | name = var.name |
| 69 | assume_role_policy = jsonencode({ |
| 70 | Version = "2012-10-17" |
| 71 | Statement = [{ |
| 72 | Effect = "Allow" |
| 73 | Principal = { Service = "ec2.amazonaws.com" } |
| 74 | Action = "sts:AssumeRole" |
| 75 | }] |
| 76 | }) |
| 77 | } |
| 78 | |
| 79 | resource "aws_iam_role_policy_attachment" "scanner_ssm" { |
| 80 | role = aws_iam_role.scanner.name |
| 81 | policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" |
| 82 | } |
| 83 | |
| 84 | # AmazonSSMManagedInstanceCore grants ssm:GetParameter* on "*". Left alone, |
| 85 | # this box could read the deployment's scan tag key and its release key. An |
| 86 | # explicit Deny beats the managed Allow and names only the parameter-read |
| 87 | # actions, so Session Manager is untouched. Same shape as region-host's |
| 88 | # host_ssm_parameter_scope, with no parameter allowed at all. |
| 89 | resource "aws_iam_role_policy" "no_parameters" { |
| 90 | name = "${var.name}-no-parameters" |
| 91 | role = aws_iam_role.scanner.id |
| 92 | policy = jsonencode({ |
| 93 | Version = "2012-10-17" |
| 94 | Statement = [ |
| 95 | { |
| 96 | Effect = "Deny" |
| 97 | Action = [ |
| 98 | "ssm:GetParameter", |
| 99 | "ssm:GetParameters", |
| 100 | "ssm:GetParameterHistory", |
| 101 | "ssm:GetParametersByPath", |
| 102 | ] |
| 103 | Resource = "*" |
| 104 | }, |
| 105 | ] |
| 106 | }) |
| 107 | } |
| 108 | |
| 109 | resource "aws_iam_instance_profile" "scanner" { |
| 110 | name = var.name |
| 111 | role = aws_iam_role.scanner.name |
| 112 | } |
| 113 | |
| 114 | resource "aws_instance" "scanner" { |
| 115 | ami = var.ami_id |
| 116 | instance_type = var.instance_type |
| 117 | subnet_id = var.subnet_id |
| 118 | vpc_security_group_ids = [aws_security_group.scanner.id] |
| 119 | iam_instance_profile = aws_iam_instance_profile.scanner.name |
| 120 | |
| 121 | # Gzipped: the script carries the self-test and three units, and EC2 caps |
| 122 | # user_data at 16 KiB. cloud-init unpacks it. |
| 123 | user_data_base64 = base64gzip(local.init_script) |
| 124 | user_data_replace_on_change = false |
| 125 | |
| 126 | metadata_options { |
| 127 | http_endpoint = "enabled" |
| 128 | http_tokens = "required" |
| 129 | http_put_response_hop_limit = 1 |
| 130 | } |
| 131 | |
| 132 | root_block_device { |
| 133 | volume_size = 20 |
| 134 | volume_type = "gp3" |
| 135 | encrypted = true |
| 136 | } |
| 137 | |
| 138 | tags = { Name = var.name } |
| 139 | |
| 140 | # A newer image or an edited script never replaces the box by itself. |
| 141 | # Changing either, including stream_max_bytes, is an explicit |
| 142 | # `tofu apply -replace=module.<name>.aws_instance.scanner`. A replaced box |
| 143 | # has a new private IP: update the console's CARLOS_CLAMD_ADDR. |
| 144 | lifecycle { |
| 145 | ignore_changes = [ami, user_data, user_data_base64] |
| 146 | |
| 147 | precondition { |
| 148 | condition = var.subnet_id != null || data.aws_vpc.client.default |
| 149 | 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." |
| 150 | } |
| 151 | |
| 152 | precondition { |
| 153 | condition = !(local.small_box && var.concurrent_database_reload) |
| 154 | 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)." |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |