diff --git a/.github/workflows/security-contracts.yaml b/.github/workflows/security-contracts.yaml new file mode 100644 index 0000000..c71ccf5 --- /dev/null +++ b/.github/workflows/security-contracts.yaml @@ -0,0 +1,40 @@ +name: Security Contracts + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: Validate security contracts + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: ansible/requirements.txt + + - name: Install YAML parser + run: python -m pip install --requirement ansible/requirements.txt + + - name: Test Terraform deployment security boundary + run: python tests/test_terraform_security_contract.py + + - name: Test OpenSSH hardening contract + run: python tests/test_ssh_hardening.py diff --git a/.github/workflows/terraform-ci.yaml b/.github/workflows/terraform-ci.yaml index 087c3d3..27da62f 100644 --- a/.github/workflows/terraform-ci.yaml +++ b/.github/workflows/terraform-ci.yaml @@ -11,10 +11,18 @@ on: permissions: contents: read +env: + # The root module intentionally leaves its cloud block deployment-neutral. + # These non-routable CI identities satisfy static initialization only because + # this workflow disables backend access. + TF_CLOUD_ORGANIZATION: ci-validation + TF_WORKSPACE: ci-validation + jobs: validate: name: Validate Terraform runs-on: ubuntu-24.04 + timeout-minutes: 15 defaults: run: diff --git a/.gitignore b/.gitignore index 78e7733..0632178 100644 --- a/.gitignore +++ b/.gitignore @@ -9,36 +9,26 @@ crash.log crash.*.log -# Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject -# to change depending on the environment. +# Exclude all .tfvars files, which are likely to contain environment-specific +# or sensitive values. *.tfvars *.tfvars.json -# Ignore override files as they are usually used to override resources locally and so -# are not checked in +# Ignore local override files. override.tf override.tf.json *_override.tf *_override.tf.json -# Ignore transient lock info files created by terraform apply +# Ignore transient lock information. .terraform.tfstate.lock.info -# Include override files you do wish to add to version control using negated pattern -# !example_override.tf +# Saved plans can contain sensitive values and must never be committed. +*tfplan* -# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan -# example: *tfplan* - -# Ignore CLI configuration files +# Ignore CLI configuration files. .terraformrc terraform.rc -# Optional: ignore graph output files generated by `terraform graph` -# *.dot - -# Optional: ignore plan files saved before destroying Terraform configuration -# Uncomment the line below if you want to ignore planout files. -# planout \ No newline at end of file +# Optional local graph output. +*.dot diff --git a/README.md b/README.md index 9829e04..e523ce8 100644 --- a/README.md +++ b/README.md @@ -5,36 +5,53 @@ A self-managed, single-node Kubernetes cluster running on a cloud VM. This is intentionally a single-node architecture. It is not highly available: the control plane and workloads share one failure domain. -The cloud infrastructure is provisioned with Terraform and the cluster is -built with kubeadm and containerd. +The cloud infrastructure is provisioned with Terraform and the cluster is built +with kubeadm, containerd, and Cilium. -This repository contains the infrastructure, Kubernetes configuration, and -operational documentation for the cluster. Live production inventory, -credentials, and execution state are intentionally kept outside this public -repository. +## Repository boundary -## Architecture +This public repository contains reusable Terraform, Ansible roles, architecture +contracts, tests, and operational documentation. The private deployment +repository owns production resource identity, HCP Terraform selection, +credentials, operator access CIDRs, saved plans, and deployment history. -- Single cloud VM -- Kubernetes control plane and workloads on the same node -- Terraform-managed infrastructure -- kubeadm-managed Kubernetes -- containerd runtime -- Deterministic, separately invoked Cilium lifecycle +No production SSH key, inventory, account name, workspace name, or operator +network is intended to live in this repository. -The [Kubernetes and Cilium network architecture](docs/network-architecture.md) -defines the IPv4 address plan, initial VXLAN and cluster-pool IPAM design, -deferred features, and reusable Ansible configuration contract. Its -machine-readable companion is normative architecture data rather than a Helm -values file. - -The Ansible lifecycle separates repeatable node convergence from Kubernetes -control-plane bootstrap. Bootstrap uses kubeadm only on a confirmed-fresh host -and validates existing cluster state on later runs. The dedicated Cilium -playbook remains a separate, subsequent lifecycle step and performs end-to-end -network validation. See [the Ansible guide](ansible/README.md) for invocation -and recovery details. +## Lifecycle -## Status +1. Terraform provisions the Hetzner server, retained Primary IPv4, SSH-only + firewall, and initial administration key. +2. `playbooks/node.yml` converges the Debian host, enforces key-only SSH, + installs containerd, and installs pinned Kubernetes tooling. +3. `playbooks/bootstrap.yml` initializes only a confirmed-fresh kubeadm control + plane and validates healthy existing state on later runs. +4. `playbooks/cilium.yml` installs or validates the digest-pinned Cilium release + and proves Pod, Service, DNS, egress, and NetworkPolicy behavior. -Work in progress. +The [Kubernetes and Cilium network architecture](docs/network-architecture.md) +defines the address plan, initial VXLAN and cluster-pool IPAM design, deferred +features, and reusable Ansible input contract. Its machine-readable companion is +normative architecture data rather than a Helm values file. + +See the [Ansible guide](ansible/README.md), the +[Terraform root-module guide](infra/hetzner/README.md), and the +[security model](docs/security.md) for invocation, trust boundaries, and +remaining operational work. + +## Verification + +Pull requests run Terraform formatting and validation, immutable-workflow +policy checks, Python security and architecture contracts, Ansible lint, +playbook syntax checks, package fixtures, and kubeadm/Cilium lifecycle tests. +Renovate tracks standard dependency files and reviewed custom pins without +automerge. + +## Current status + +The infrastructure, node-convergence, kubeadm-bootstrap, and Cilium lifecycles +are implemented and have passed production deployment and idempotence checks. +The project remains intentionally single-node and still requires dedicated +backup/restore, operating-system maintenance, Kubernetes upgrade, Cilium +upgrade, and non-root automation-user procedures before it should host +irreplaceable workloads. diff --git a/ansible/roles/base/handlers/main.yml b/ansible/roles/base/handlers/main.yml new file mode 100644 index 0000000..7d520d1 --- /dev/null +++ b/ansible/roles/base/handlers/main.yml @@ -0,0 +1,14 @@ +--- +- name: Validate the effective OpenSSH configuration + ansible.builtin.command: + argv: + - /usr/sbin/sshd + - -t + changed_when: false + listen: Validate and reload OpenSSH + +- name: Reload the OpenSSH daemon + ansible.builtin.systemd_service: + name: ssh + state: reloaded + listen: Validate and reload OpenSSH diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml index a0974ce..19e073e 100644 --- a/ansible/roles/base/tasks/main.yml +++ b/ansible/roles/base/tasks/main.yml @@ -25,6 +25,119 @@ The base role requires systemd. Detected {{ ansible_facts.service_mgr }}. +- name: Inspect the OpenSSH daemon + ansible.builtin.stat: + path: /usr/sbin/sshd + get_checksum: false + get_mime: false + get_attributes: false + register: base_sshd_binary + +- name: Require the OpenSSH daemon + ansible.builtin.assert: + that: + - base_sshd_binary.stat.exists + - base_sshd_binary.stat.isreg | default(false) + - base_sshd_binary.stat.executable | default(false) + fail_msg: >- + The base role requires the OpenSSH daemon at /usr/sbin/sshd so it can + enforce key-only administrative access. + +- name: Create the OpenSSH configuration directory + ansible.builtin.file: + path: /etc/ssh/sshd_config.d + state: directory + owner: root + group: root + mode: "0755" + +# This project still uses root for production automation. The role therefore +# retains root SSH, but only through a pre-provisioned public key. The 00 prefix +# makes these first-value-wins directives precede cloud-image drop-ins. +- name: Harden OpenSSH authentication and forwarding + ansible.builtin.copy: + content: |- + # Managed by the single-node-kubernetes base role. + PermitRootLogin prohibit-password + AuthenticationMethods publickey + PubkeyAuthentication yes + PasswordAuthentication no + KbdInteractiveAuthentication no + HostbasedAuthentication no + GSSAPIAuthentication no + PermitEmptyPasswords no + PermitUserEnvironment no + PermitUserRC no + MaxAuthTries 3 + LoginGraceTime 30 + AllowAgentForwarding no + AllowTcpForwarding local + AllowStreamLocalForwarding local + GatewayPorts no + PermitTunnel no + X11Forwarding no + ClientAliveInterval 300 + ClientAliveCountMax 2 + LogLevel VERBOSE + dest: /etc/ssh/sshd_config.d/00-single-node-kubernetes.conf + owner: root + group: root + mode: "0644" + validate: /usr/sbin/sshd -t -f %s + notify: Validate and reload OpenSSH + +- name: Read the effective OpenSSH policy + ansible.builtin.command: + argv: + - /usr/sbin/sshd + - -T + - -C + - user=root,host=localhost,addr=127.0.0.1 + register: base_sshd_effective_policy + changed_when: false + +- name: Require the effective OpenSSH policy + ansible.builtin.assert: + that: + - item in base_sshd_effective_policy.stdout_lines + fail_msg: >- + The effective OpenSSH configuration did not contain the required + directive {{ item }}. Check the main sshd_config Include order and any + conflicting earlier directives before reloading the daemon. + loop: + - authenticationmethods publickey + - pubkeyauthentication yes + - passwordauthentication no + - kbdinteractiveauthentication no + - hostbasedauthentication no + - gssapiauthentication no + - permitemptypasswords no + - permituserenvironment no + - permituserrc no + - maxauthtries 3 + - logingracetime 30 + - allowagentforwarding no + - allowtcpforwarding local + - allowstreamlocalforwarding local + - gatewayports no + - permittunnel no + - x11forwarding no + - clientaliveinterval 300 + - clientalivecountmax 2 + - loglevel VERBOSE + loop_control: + label: "{{ item }}" + +- name: Require key-only root SSH in the effective policy + ansible.builtin.assert: + that: + - >- + base_sshd_effective_policy.stdout + is search('(?m)^permitrootlogin (?:prohibit-password|without-password)$') + fail_msg: >- + The effective OpenSSH policy must permit root only through public-key + authentication. + - name: Check for the cgroup v2 controllers file ansible.builtin.stat: path: /sys/fs/cgroup/cgroup.controllers diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..2294675 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,59 @@ +# Security model + +This project treats the public infrastructure repository as reusable source and +the private deployment repository as the production trust boundary. + +## Implemented controls + +- GitHub Actions and Docker actions are pinned to immutable commits or image + digests. +- Terraform providers are constrained and locked. +- Public-source deployments use an exact Git commit that must already be merged + into the public `main` branch. +- Terraform plans are saved and applied without replanning. +- The permanent cloud firewall exposes only SSH from explicit IPv4 `/32` + administration addresses. +- Production automation creates a separate runner-scoped SSH firewall and + deletes it through unconditional cleanup. +- SSH host keys are checked and deployment private keys are written with + restrictive permissions. +- OpenSSH permits root only through public-key authentication; password, + keyboard-interactive, host-based, GSSAPI, agent-forwarding, and remote + forwarding paths are disabled. +- Kubernetes, containerd, runc, Helm, Cilium, validation images, and downloaded + test artifacts use reviewed version and integrity pins. +- kubeadm bootstrap and Cilium deployment use fail-closed state classification, + host-local lifecycle locks, runtime validation, and second-run idempotence + checks. +- Privileged kubeconfigs remain on the control-plane host. + +## Trust boundaries + +The private deployment repository and its production environment own +credentials, account and workspace selection, operator access CIDRs, production +resource identity, and deployment history. The public repository must not +contain those values. + +The Kubernetes node remains a single failure and security domain. A compromise +of the host, root account, control plane, or local etcd can compromise the +entire cluster. + +## Residual risks requiring explicit lifecycle work + +- Production automation still uses root over SSH. Key-only authentication is + enforced, but migration to a dedicated automation user with reviewed sudo + policy remains preferable. +- Kubernetes Secrets are not yet protected by an encryption-at-rest provider. +- API-server audit policy and centralized audit-log retention are not yet + configured. +- Operating-system security updates and reboot coordination require a dedicated + maintenance workflow so unattended changes do not unexpectedly interrupt the + single-node control plane. +- Local etcd, PKI, and workload data need tested backup, restore, and off-host + retention procedures before important workloads depend on the cluster. +- Upstream package signing keys and same-origin checksum manifests still depend + on upstream release infrastructure and TLS. Higher-assurance deployments + should mirror reviewed artifacts into a controlled repository. + +These items should be handled as separately tested operational migrations rather +than hidden inside normal convergence. diff --git a/infra/hetzner/README.md b/infra/hetzner/README.md new file mode 100644 index 0000000..9a6bcd9 --- /dev/null +++ b/infra/hetzner/README.md @@ -0,0 +1,45 @@ +# Hetzner Terraform root module + +This root module describes one single-node Kubernetes host while deliberately +remaining independent of a particular production account or operator. + +The private deployment repository supplies the following values: + +- `TF_CLOUD_ORGANIZATION` and `TF_WORKSPACE` select the HCP Terraform state. +- `TF_VAR_deployment` is a JSON object containing the non-secret resource + identity, image, location, and server type. +- `TF_VAR_admin_ssh_public_key` contains one Ed25519 public key. +- `TF_VAR_ssh_source_cidrs` contains one or more unique, canonical IPv4 `/32` + administration networks. + +Example local validation input: + +```sh +export TF_CLOUD_ORGANIZATION=example +export TF_WORKSPACE=single-node-kubernetes +export TF_VAR_deployment='{ + "environment":"development", + "system_name":"single-node-kubernetes", + "server_name":"k8s-dev-01", + "primary_ipv4_name":"k8s-dev-01-ipv4", + "ssh_key_name":"development-admin", + "location":"nbg1", + "server_type":"cx23", + "image_name":"debian-13", + "image_architecture":"x86" +}' +export TF_VAR_admin_ssh_public_key='ssh-ed25519 AAAA... operator@example' +export TF_VAR_ssh_source_cidrs='["192.0.2.10/32"]' +terraform init +terraform plan +``` + +The `hcloud_server.k8s_01`, `hcloud_primary_ip.k8s_01`, firewall, and SSH-key +resource addresses intentionally retain their existing Terraform identities. +Changing the deployment object therefore changes resource arguments rather +than silently creating an unrelated state graph. + +Hetzner injects the selected SSH key only when a server is created. Rotating the +Terraform SSH-key resource does not update an existing host's +`authorized_keys`; key rotation must use a separately reviewed host-access +procedure before removing the previous key. diff --git a/infra/hetzner/firewall.tf b/infra/hetzner/firewall.tf index 9868c79..5945a3c 100644 --- a/infra/hetzner/firewall.tf +++ b/infra/hetzner/firewall.tf @@ -1,5 +1,5 @@ resource "hcloud_firewall" "k8s_01" { - name = "k8s-01" + name = var.deployment.server_name labels = local.resource_labels rule { diff --git a/infra/hetzner/keys/admin.pub b/infra/hetzner/keys/admin.pub deleted file mode 100644 index 7b562b0..0000000 --- a/infra/hetzner/keys/admin.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA3D2qdnTm03wbE0SaodXS6n+9wS1JJWSOOL9B4nZlCN jared@puckbaby diff --git a/infra/hetzner/keys/ansible.pub b/infra/hetzner/keys/ansible.pub deleted file mode 100644 index 72115f7..0000000 --- a/infra/hetzner/keys/ansible.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHaeiRFK9J+bx/0QPbfjFRw3JXu/M40/RV+CgRltVtBl github-actions-ansible@single-node-kubernetes-live diff --git a/infra/hetzner/primary_ip.tf b/infra/hetzner/primary_ip.tf index 5925209..f712255 100644 --- a/infra/hetzner/primary_ip.tf +++ b/infra/hetzner/primary_ip.tf @@ -1,7 +1,7 @@ resource "hcloud_primary_ip" "k8s_01" { - name = "k8s-01-ipv4" + name = var.deployment.primary_ipv4_name type = "ipv4" - location = "nbg1" + location = var.deployment.location auto_delete = false labels = local.resource_labels } diff --git a/infra/hetzner/server.tf b/infra/hetzner/server.tf index 2e4c2ce..3d43747 100644 --- a/infra/hetzner/server.tf +++ b/infra/hetzner/server.tf @@ -1,21 +1,21 @@ locals { resource_labels = { - environment = "production" - system = "single-node-kubernetes" + environment = var.deployment.environment + system = var.deployment.system_name managed_by = "terraform" } } -data "hcloud_image" "debian_13_x86" { - name = "debian-13" - with_architecture = "x86" +data "hcloud_image" "debian" { + name = var.deployment.image_name + with_architecture = var.deployment.image_architecture } resource "hcloud_server" "k8s_01" { - name = "k8s-01" - location = "nbg1" - server_type = "cx23" - image = data.hcloud_image.debian_13_x86.id + name = var.deployment.server_name + location = var.deployment.location + server_type = var.deployment.server_type + image = data.hcloud_image.debian.id ssh_keys = [hcloud_ssh_key.admin.id] firewall_ids = [hcloud_firewall.k8s_01.id] diff --git a/infra/hetzner/ssh.tf b/infra/hetzner/ssh.tf index aca4572..f2c3419 100644 --- a/infra/hetzner/ssh.tf +++ b/infra/hetzner/ssh.tf @@ -1,4 +1,4 @@ resource "hcloud_ssh_key" "admin" { - name = "admin" - public_key = file("${path.module}/keys/admin.pub") + name = var.deployment.ssh_key_name + public_key = trimspace(var.admin_ssh_public_key) } diff --git a/infra/hetzner/variables.tf b/infra/hetzner/variables.tf index 129733b..00cdd5b 100644 --- a/infra/hetzner/variables.tf +++ b/infra/hetzner/variables.tf @@ -1,16 +1,85 @@ +variable "deployment" { + description = "Non-secret deployment identity and Hetzner placement supplied by the private deployment repository." + type = object({ + environment = string + system_name = string + server_name = string + primary_ipv4_name = string + ssh_key_name = string + location = string + server_type = string + image_name = string + image_architecture = string + }) + nullable = false + + validation { + condition = alltrue([ + for value in [ + var.deployment.environment, + var.deployment.system_name, + var.deployment.server_name, + var.deployment.primary_ipv4_name, + var.deployment.ssh_key_name, + var.deployment.location, + var.deployment.server_type, + var.deployment.image_name, + var.deployment.image_architecture, + ] : length(trimspace(value)) > 0 + ]) + error_message = "deployment values must be non-empty strings." + } + + validation { + condition = alltrue([ + for value in [ + var.deployment.environment, + var.deployment.system_name, + var.deployment.server_name, + var.deployment.primary_ipv4_name, + var.deployment.ssh_key_name, + var.deployment.location, + var.deployment.server_type, + var.deployment.image_name, + var.deployment.image_architecture, + ] : can(regex("^[a-z0-9][a-z0-9.-]{0,62}$", value)) + ]) + error_message = "deployment values must use lowercase letters, numbers, dots, and internal hyphens only." + } +} + +variable "admin_ssh_public_key" { + description = "Initial Ed25519 administrator public key installed when the server is created." + type = string + nullable = false + + validation { + condition = can(regex( + "^ssh-ed25519 [A-Za-z0-9+/]+={0,2}( [^\\r\\n]+)?$", + trimspace(var.admin_ssh_public_key), + )) + error_message = "admin_ssh_public_key must contain one OpenSSH Ed25519 public key." + } +} + variable "ssh_source_cidrs" { - description = "IPv4 CIDRs permitted to connect to the host over SSH." + description = "Unique IPv4 /32 CIDRs permitted to connect to the host over SSH." type = list(string) - sensitive = true nullable = false validation { condition = ( length(var.ssh_source_cidrs) > 0 && + length(distinct(var.ssh_source_cidrs)) == length(var.ssh_source_cidrs) && alltrue([ - for cidr in var.ssh_source_cidrs : can(cidrnetmask(cidr)) + for cidr in var.ssh_source_cidrs : try( + length(regexall(":", cidr)) == 0 && + tonumber(split("/", cidr)[1]) == 32 && + cidrhost(cidr, 0) == split("/", cidr)[0], + false, + ) ]) ) - error_message = "ssh_source_cidrs must contain at least one valid IPv4 CIDR." + error_message = "ssh_source_cidrs must contain one or more unique, canonical IPv4 /32 CIDRs." } } diff --git a/infra/hetzner/versions.tf b/infra/hetzner/versions.tf index 0fd2c74..223b1c8 100644 --- a/infra/hetzner/versions.tf +++ b/infra/hetzner/versions.tf @@ -8,11 +8,8 @@ terraform { } } - cloud { - organization = "jaredjakacky" - - workspaces { - name = "single-node-kubernetes" - } - } + # The private deployment repository supplies TF_CLOUD_ORGANIZATION and + # TF_WORKSPACE. Keeping this block empty prevents public source from owning a + # production account or workspace identity. + cloud {} } diff --git a/tests/test_ssh_hardening.py b/tests/test_ssh_hardening.py new file mode 100644 index 0000000..2e14e3c --- /dev/null +++ b/tests/test_ssh_hardening.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +"""Validate the base role's fail-closed OpenSSH security contract.""" + +from __future__ import annotations + +import pathlib +from typing import Any + +import yaml + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parent.parent +BASE_ROLE = REPOSITORY_ROOT / "ansible" / "roles" / "base" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def load_yaml(path: pathlib.Path) -> Any: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def named_task(tasks: list[dict[str, Any]], name: str) -> dict[str, Any]: + matches = [task for task in tasks if task.get("name") == name] + require(len(matches) == 1, f"expected one task named {name!r}, found {len(matches)}") + return matches[0] + + +def parse_directives(content: str) -> dict[str, str]: + directives: dict[str, str] = {} + for line_number, raw_line in enumerate(content.splitlines(), 1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + name, separator, value = line.partition(" ") + require(separator == " " and value.strip(), f"invalid sshd directive on line {line_number}") + require(name not in directives, f"duplicate sshd directive: {name}") + directives[name] = value.strip() + return directives + + +def main() -> None: + tasks = load_yaml(BASE_ROLE / "tasks" / "main.yml") + handlers = load_yaml(BASE_ROLE / "handlers" / "main.yml") + require(isinstance(tasks, list), "base tasks must be a YAML sequence") + require(isinstance(handlers, list), "base handlers must be a YAML sequence") + + hardening = named_task(tasks, "Harden OpenSSH authentication and forwarding") + arguments = hardening.get("ansible.builtin.copy") + require(isinstance(arguments, dict), "OpenSSH hardening must use ansible.builtin.copy") + require( + arguments.get("dest") == "/etc/ssh/sshd_config.d/00-single-node-kubernetes.conf", + "OpenSSH hardening destination drifted", + ) + require(arguments.get("owner") == "root", "OpenSSH policy must be root-owned") + require(arguments.get("group") == "root", "OpenSSH policy must be root-grouped") + require(arguments.get("mode") == "0644", "OpenSSH policy mode must be 0644") + require( + arguments.get("validate") == "/usr/sbin/sshd -t -f %s", + "OpenSSH policy must be syntax-checked before installation", + ) + require( + hardening.get("notify") == "Validate and reload OpenSSH", + "OpenSSH policy must notify the validated reload handler", + ) + + content = arguments.get("content") + require(isinstance(content, str), "OpenSSH policy content must be a string") + expected = { + "PermitRootLogin": "prohibit-password", + "AuthenticationMethods": "publickey", + "PubkeyAuthentication": "yes", + "PasswordAuthentication": "no", + "KbdInteractiveAuthentication": "no", + "HostbasedAuthentication": "no", + "GSSAPIAuthentication": "no", + "PermitEmptyPasswords": "no", + "PermitUserEnvironment": "no", + "PermitUserRC": "no", + "MaxAuthTries": "3", + "LoginGraceTime": "30", + "AllowAgentForwarding": "no", + "AllowTcpForwarding": "local", + "AllowStreamLocalForwarding": "local", + "GatewayPorts": "no", + "PermitTunnel": "no", + "X11Forwarding": "no", + "ClientAliveInterval": "300", + "ClientAliveCountMax": "2", + "LogLevel": "VERBOSE", + } + require(parse_directives(content) == expected, "OpenSSH hardening directives drifted") + + effective_read = named_task(tasks, "Read the effective OpenSSH policy") + require( + effective_read.get("ansible.builtin.command", {}).get("argv") + == [ + "/usr/sbin/sshd", + "-T", + "-C", + "user=root,host=localhost,addr=127.0.0.1", + ], + "effective OpenSSH policy must be evaluated for the root SSH context", + ) + require(effective_read.get("changed_when") is False, "sshd policy read must not change state") + + effective_assert = named_task(tasks, "Require the effective OpenSSH policy") + assertions = effective_assert.get("loop") + require(isinstance(assertions, list), "effective OpenSSH assertions must use a literal loop") + for required in ( + "authenticationmethods publickey", + "passwordauthentication no", + "kbdinteractiveauthentication no", + "allowtcpforwarding local", + "allowagentforwarding no", + ): + require(required in assertions, f"effective OpenSSH proof is missing {required!r}") + + root_assert = named_task(tasks, "Require key-only root SSH in the effective policy") + require( + "permitrootlogin" in str(root_assert.get("ansible.builtin.assert", {}).get("that", "")), + "effective root-login policy is not asserted", + ) + + require( + [handler.get("name") for handler in handlers] + == ["Validate the effective OpenSSH configuration", "Reload the OpenSSH daemon"], + "OpenSSH handlers must validate before reloading", + ) + validate_handler, reload_handler = handlers + require( + validate_handler.get("ansible.builtin.command", {}).get("argv") + == ["/usr/sbin/sshd", "-t"], + "effective OpenSSH validation command drifted", + ) + require(validate_handler.get("changed_when") is False, "sshd validation must not change state") + require( + reload_handler.get("ansible.builtin.systemd_service") == {"name": "ssh", "state": "reloaded"}, + "OpenSSH must be reloaded through Debian's ssh service", + ) + require( + all(handler.get("listen") == "Validate and reload OpenSSH" for handler in handlers), + "OpenSSH handlers must share the reviewed listen topic", + ) + + print("OpenSSH hardening contract passed") + + +if __name__ == "__main__": + main() diff --git a/tests/test_terraform_security_contract.py b/tests/test_terraform_security_contract.py new file mode 100644 index 0000000..ff93948 --- /dev/null +++ b/tests/test_terraform_security_contract.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +"""Validate the public Terraform root module's deployment trust boundary.""" + +from __future__ import annotations + +import pathlib + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parent.parent +TERRAFORM_ROOT = REPOSITORY_ROOT / "infra" / "hetzner" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def read(path: str) -> str: + return (TERRAFORM_ROOT / path).read_text(encoding="utf-8") + + +def main() -> None: + terraform_files = { + path.name: path.read_text(encoding="utf-8") + for path in sorted(TERRAFORM_ROOT.glob("*.tf")) + } + combined = "\n".join(terraform_files.values()) + + require( + not list((TERRAFORM_ROOT / "keys").glob("*.pub")), + "operator SSH public keys must not be committed to the public repository", + ) + for forbidden in ( + '"production"', + '"k8s-01"', + '"k8s-01-ipv4"', + '"nbg1"', + '"cx23"', + '"jaredjakacky"', + "${path.module}/keys/", + ): + require( + forbidden not in combined, + f"public Terraform contains deployment-specific value {forbidden!r}", + ) + + variables = read("variables.tf") + require('variable "deployment"' in variables, "typed deployment input is absent") + require( + 'variable "admin_ssh_public_key"' in variables, + "administrator public-key input is absent", + ) + require( + 'variable "ssh_source_cidrs"' in variables, + "SSH source CIDR input is absent", + ) + for proof in ( + "system_name", + "image_name", + "image_architecture", + "length(distinct(var.ssh_source_cidrs))", + 'tonumber(split("/", cidr)[1]) == 32', + "cidrhost(cidr, 0)", + "^ssh-ed25519 ", + ): + require(proof in variables, f"Terraform input validation is missing {proof!r}") + + require( + "var.deployment.server_name" in read("server.tf"), + "server identity is not supplied by the deployment object", + ) + require( + "var.deployment.primary_ipv4_name" in read("primary_ip.tf"), + "Primary IP identity is not supplied by the deployment object", + ) + require( + "var.deployment.ssh_key_name" in read("ssh.tf") + and "var.admin_ssh_public_key" in read("ssh.tf"), + "SSH key resource does not consume the reviewed inputs", + ) + require( + "var.ssh_source_cidrs" in read("firewall.tf"), + "firewall does not consume the reviewed source CIDRs", + ) + + versions = read("versions.tf") + require("cloud {}" in versions, "Terraform cloud block must remain environment-neutral") + require("organization =" not in versions, "HCP organization must not be public source") + require("workspaces {" not in versions, "HCP workspace must not be public source") + + gitignore = (REPOSITORY_ROOT / ".gitignore").read_text(encoding="utf-8") + require( + any(line.strip() == "*tfplan*" for line in gitignore.splitlines()), + "saved Terraform plans must be ignored by Git", + ) + + print("Terraform deployment security contract passed") + + +if __name__ == "__main__": + main()