| 1 | # Plan-time tests. mock_provider: no account, no credentials. |
| 2 | mock_provider "aws" {} |
| 3 | |
| 4 | override_data { |
| 5 | target = data.aws_security_group.client |
| 6 | values = { vpc_id = "vpc-0aaaaaaaaaaaaaaaa" } |
| 7 | } |
| 8 | |
| 9 | override_data { |
| 10 | target = data.aws_vpc.client |
| 11 | values = { default = true } |
| 12 | } |
| 13 | |
| 14 | variables { |
| 15 | name = "carlos-test-scanner" |
| 16 | ami_id = "ami-0123456789abcdef0" |
| 17 | client_security_group_id = "sg-0123456789abcdef0" |
| 18 | region = "eu-west-1" |
| 19 | } |
| 20 | |
| 21 | run "the_fence" { |
| 22 | command = plan |
| 23 | assert { |
| 24 | condition = length(aws_security_group.scanner.ingress) == 1 |
| 25 | error_message = "exactly one way in" |
| 26 | } |
| 27 | assert { |
| 28 | condition = alltrue([ |
| 29 | for i in aws_security_group.scanner.ingress : |
| 30 | i.from_port == 3310 && i.to_port == 3310 && i.protocol == "tcp" |
| 31 | && i.security_groups == toset(["sg-0123456789abcdef0"]) |
| 32 | && try(length(i.cidr_blocks), 0) == 0 && try(length(i.ipv6_cidr_blocks), 0) == 0 |
| 33 | ]) |
| 34 | error_message = "3310 from the client security group only, never a CIDR" |
| 35 | } |
| 36 | assert { |
| 37 | condition = aws_security_group.scanner.vpc_id == "vpc-0aaaaaaaaaaaaaaaa" |
| 38 | error_message = "the group lands in the client group's VPC" |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | run "the_box" { |
| 43 | command = plan |
| 44 | assert { |
| 45 | condition = aws_instance.scanner.metadata_options[0].http_tokens == "required" |
| 46 | error_message = "IMDSv2 only" |
| 47 | } |
| 48 | assert { |
| 49 | condition = aws_instance.scanner.root_block_device[0].encrypted |
| 50 | error_message = "encrypted root volume" |
| 51 | } |
| 52 | assert { |
| 53 | condition = aws_instance.scanner.instance_type == "t4g.medium" |
| 54 | error_message = "t4g.medium by default" |
| 55 | } |
| 56 | assert { |
| 57 | # base64 is 4 characters per 3 bytes, so 21800 characters is under 16 KiB. |
| 58 | condition = length(base64gzip(local.init_script)) < 21800 |
| 59 | error_message = "EC2 refuses user_data over 16 KiB, and the script is sent gzipped to stay well under it" |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | run "the_role_cannot_read_parameters" { |
| 64 | command = plan |
| 65 | assert { |
| 66 | condition = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Effect == "Deny" |
| 67 | error_message = "an explicit Deny, because the managed SSM policy allows GetParameter* on *" |
| 68 | } |
| 69 | assert { |
| 70 | condition = toset(jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Action) == toset([ |
| 71 | "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath", |
| 72 | ]) |
| 73 | error_message = "all four parameter-read actions" |
| 74 | } |
| 75 | assert { |
| 76 | condition = jsondecode(aws_iam_role_policy.no_parameters.policy).Statement[0].Resource == "*" |
| 77 | error_message = "on every parameter: this box is allowed none" |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | run "the_engine_config" { |
| 82 | command = plan |
| 83 | assert { |
| 84 | condition = strcontains(local.init_script, "setkey MaxFileSize 100M") && strcontains(local.init_script, "setkey MaxScanSize 100M") |
| 85 | error_message = "both limits 100M: equal is what makes a trimmed member trip the scan budget" |
| 86 | } |
| 87 | assert { |
| 88 | condition = strcontains(local.init_script, "setkey AlertExceedsMax yes") |
| 89 | error_message = "limits must alert" |
| 90 | } |
| 91 | assert { |
| 92 | condition = strcontains(local.init_script, "setkey StreamMaxLength 25M") |
| 93 | error_message = "StreamMaxLength is stream_max_bytes, written in M so a debconf rewrite cannot turn it into 25 TiB" |
| 94 | } |
| 95 | assert { |
| 96 | condition = strcontains(local.init_script, "echo 'clamav-daemon clamav-daemon/debconf boolean false' | debconf-set-selections") |
| 97 | error_message = "package upgrades never regenerate clamd.conf" |
| 98 | } |
| 99 | assert { |
| 100 | condition = alltrue([ |
| 101 | strcontains(local.init_script, "cat > /etc/apparmor.d/local/usr.sbin.clamd <<'EOF'"), |
| 102 | strcontains(local.init_script, "\n/var/tmp/ rw,\n/var/tmp/** krw,\n"), |
| 103 | strcontains(local.init_script, "apparmor_parser -r /etc/apparmor.d/usr.sbin.clamd"), |
| 104 | strcontains(local.init_script, "setkey TemporaryDirectory /var/tmp"), |
| 105 | ]) |
| 106 | error_message = "clamd's AppArmor profile allows its TemporaryDirectory, /var/tmp, or every scan errors" |
| 107 | } |
| 108 | assert { |
| 109 | condition = strcontains(local.init_script, "sed -i '/^TCPSocket[[:space:]]/d;/^TCPAddr[[:space:]]/d' \"$conf\"") |
| 110 | error_message = "any TCPSocket or TCPAddr line the package wrote is deleted" |
| 111 | } |
| 112 | assert { |
| 113 | condition = !strcontains(local.init_script, "setkey TCPSocket") && !strcontains(local.init_script, "setkey TCPAddr") |
| 114 | error_message = "clamd itself never listens on TCP; the gated proxy does" |
| 115 | } |
| 116 | assert { |
| 117 | condition = output.stream_max_bytes == 26214400 |
| 118 | error_message = "the console's CARLOS_CLAMD_MAX_BYTES comes from this output" |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | run "small_box_with_concurrent_reload_is_refused" { |
| 123 | command = plan |
| 124 | variables { |
| 125 | instance_type = "t4g.small" |
| 126 | } |
| 127 | expect_failures = [aws_instance.scanner] |
| 128 | } |
| 129 | |
| 130 | run "small_box_without_concurrent_reload_is_fine" { |
| 131 | command = plan |
| 132 | variables { |
| 133 | instance_type = "t4g.small" |
| 134 | concurrent_database_reload = false |
| 135 | } |
| 136 | assert { |
| 137 | condition = strcontains(local.init_script, "setkey ConcurrentDatabaseReload no") |
| 138 | error_message = "the reload setting reaches clamd.conf" |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | run "stream_limit_too_small_for_the_fixtures" { |
| 143 | command = plan |
| 144 | variables { |
| 145 | stream_max_bytes = 1048576 |
| 146 | } |
| 147 | expect_failures = [var.stream_max_bytes] |
| 148 | } |
| 149 | |
| 150 | run "stream_limit_above_the_scan_budget" { |
| 151 | command = plan |
| 152 | variables { |
| 153 | stream_max_bytes = 209715200 |
| 154 | } |
| 155 | expect_failures = [var.stream_max_bytes] |
| 156 | } |
| 157 | |
| 158 | run "stream_limit_not_a_whole_mib" { |
| 159 | command = plan |
| 160 | variables { |
| 161 | stream_max_bytes = 26214401 |
| 162 | } |
| 163 | expect_failures = [var.stream_max_bytes] |
| 164 | } |
| 165 | |
| 166 | run "the_units" { |
| 167 | command = plan |
| 168 | assert { |
| 169 | condition = alltrue([ |
| 170 | for line in [ |
| 171 | "DefaultDependencies=no", |
| 172 | "Requires=scanner-selftest.service", |
| 173 | "BindsTo=scanner-selftest.service", |
| 174 | "After=scanner-selftest.service", |
| 175 | "ListenStream=@PRIVATE_IP@:3310", |
| 176 | "FreeBind=yes", |
| 177 | ] : strcontains(file("${path.module}/files/scanner-tcp.socket"), "\n${line}\n") |
| 178 | ]) |
| 179 | error_message = "the socket opts out of default dependencies, is bound to the self-test and listens on the private address only" |
| 180 | } |
| 181 | assert { |
| 182 | condition = alltrue([ |
| 183 | for line in [ |
| 184 | "Requires=scanner-selftest.service", |
| 185 | "BindsTo=scanner-selftest.service", |
| 186 | "After=scanner-selftest.service", |
| 187 | ] : strcontains(file("${path.module}/files/scanner-tcp.service"), "\n${line}\n") |
| 188 | ]) |
| 189 | error_message = "the proxy is bound to the self-test, so connections in flight are cut with it" |
| 190 | } |
| 191 | assert { |
| 192 | condition = alltrue([ |
| 193 | for line in [ |
| 194 | "BindsTo=clamav-daemon.service", |
| 195 | "After=clamav-daemon.service", |
| 196 | "Wants=scanner-tcp.socket", |
| 197 | "Type=oneshot", |
| 198 | "RemainAfterExit=yes", |
| 199 | "PrivateTmp=yes", |
| 200 | ] : strcontains(file("${path.module}/files/scanner-selftest.service"), "\n${line}\n") |
| 201 | ]) |
| 202 | error_message = "the self-test follows clamd, asks for the gate, stays active after a pass, and keeps its fixtures in a private /var/tmp" |
| 203 | } |
| 204 | assert { |
| 205 | # Comments may sit between the heredoc line and [Unit]; the Wants= line |
| 206 | # must be the first line of the [Unit] section. |
| 207 | condition = can(regex("10-scanner\\.conf <<EOF\\n(#[^\\n]*\\n)*\\[Unit\\]\\nWants=scanner-tcp\\.socket\\n", local.init_script)) |
| 208 | error_message = "every start of clamd, an automatic restart included, asks for the gate again" |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | run "the_selftest_classifier" { |
| 213 | command = plan |
| 214 | assert { |
| 215 | condition = !strcontains(file("${path.module}/files/scanner-selftest"), "*\"Heuristics.Limits.Exceeded\"*") |
| 216 | error_message = "no substring glob: it accepted MaxScanTime and lookalikes such as Win.Heuristics.Limits.Exceeded.Fake as a limit" |
| 217 | } |
| 218 | assert { |
| 219 | condition = strcontains(file("${path.module}/files/scanner-selftest"), "Heuristics.Limits.Exceeded.MaxScanTime) echo timeout ;;") |
| 220 | error_message = "a scan timeout is its own class, which no fixture wants" |
| 221 | } |
| 222 | assert { |
| 223 | condition = strcontains(file("${path.module}/files/scanner-selftest"), "Heuristics.Limits.Exceeded | Heuristics.Limits.Exceeded.*) echo limit ;;") |
| 224 | error_message = "a limit is the heuristic itself or a name under it, dot included, as internal/clamd reads it" |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | run "logging_cannot_kill_init" { |
| 229 | command = plan |
| 230 | assert { |
| 231 | condition = !strcontains(local.init_script, "tee /dev/console") |
| 232 | error_message = "a failed console write would exit under set -e with no FAILED marker" |
| 233 | } |
| 234 | assert { |
| 235 | condition = strcontains(local.init_script, "{ echo \"scanner-init: $*\" > /dev/console; } 2>/dev/null || true") |
| 236 | error_message = "the console copy of each line is best effort" |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | run "freshclam_uses_its_packaged_config" { |
| 241 | command = plan |
| 242 | assert { |
| 243 | condition = !strcontains(local.init_script, "freshclam --config-file") && !strcontains(local.init_script, "/run/freshclam") |
| 244 | error_message = "Debian's AppArmor profile confines freshclam to its config under /etc/clamav: a config passed from anywhere else cannot be read and freshclam fails at once" |
| 245 | } |
| 246 | assert { |
| 247 | condition = strcontains(local.init_script, "until freshclam; do") |
| 248 | error_message = "the first signatures must come from plain freshclam with the packaged config" |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | run "another_vpc_needs_a_subnet" { |
| 253 | command = plan |
| 254 | override_data { |
| 255 | target = data.aws_vpc.client |
| 256 | values = { default = false } |
| 257 | } |
| 258 | expect_failures = [aws_instance.scanner] |
| 259 | } |
| 260 | |