Skip to the content.

Automated OS Hardening & Security Audit

Terraform provisions a five-instance EC2 fleet, then a cross-distro Bash toolchain hardens SSH, host firewalls, patching, and compliance reporting. The result is an end-to-end security automation project with least-privilege IAM, remote Terraform state, and Markdown audit reports uploaded to S3.

View the repository · Read the README

Architecture Diagram

What This Project Demonstrates

1. Architecture

A single VPC and public subnet host the fleet: three Ubuntu 22.04 instances and two Amazon Linux 2023 instances. Inbound SSH is restricted to one trusted IP and one hardened port, enforced independently at the AWS security group, host firewall, and SSH daemon layers.

Instances authenticate to S3 through an IAM instance profile, so no static AWS credentials are stored in the codebase or on the hosts.

2. Infrastructure Provisioning

Terraform is organized into four modules: network, security-group, compliance-bucket, and ec2-fleet. The root module wires them together while state is stored remotely in a versioned S3 bucket with DynamoDB locking.

The fleet is defined once as a typed map and created with for_each, so each instance has a stable identity:

resource "aws_instance" "fleet" {
  for_each = var.instances
  ami      = each.value.ami
  ...
}

Using for_each keyed by instance name avoids the churn that can happen with count, where removing one item shifts later indexes and can recreate unrelated instances.

EC2 instance list in AWS console EC2 instance list from CLI

3. IAM Design

The automation user, project2-automation, runs under a scoped policy rather than AdministratorAccess. Permissions were added only when a real AccessDenied error required them, then tightened to the narrowest useful resource scope.

Key points:

This exposed a practical AWS provider detail: aws_iam_role performs read checks such as ListRolePolicies, ListAttachedRolePolicies, and ListInstanceProfilesForRole during create and destroy operations, so a role-management policy needs those list permissions even when the automation does not directly list roles for its own workflow.

4. SSH Hardening

scripts/harden.sh runs the same logical workflow on every instance, branching only where Ubuntu and Amazon Linux use different system tools.

Root SSH login and password authentication are disabled through idempotent check-then-modify logic against sshd_config. Existing directives are corrected in place, and missing directives are appended only once.

Two-Stage Port Migration

Moving SSH away from port 22 is the riskiest step, so the script does it in two stages:

  1. harden_ssh_port adds port 2222 while keeping port 22 active.
  2. After a fresh connection succeeds on 2222, remove_legacy_ssh_port removes port 22.

Ubuntu hardening script run Ubuntu port 22 removal

The same sequence works on Amazon Linux through the firewalld branch:

Amazon Linux hardening script run Amazon Linux SSH verification on port 2222 Amazon Linux port 22 removal

5. Host Firewall

harden_firewall uses each distribution’s native firewall tool.

Area Ubuntu Amazon Linux
Tool UFW firewalld
Install check command -v ufw command -v firewall-cmd
Default policy deny incoming, allow outgoing public zone
Allowed port 2222/tcp 2222/tcp

One Amazon Linux detail matters: firewalld’s public zone includes a default ssh service rule for port 22. The script removes that service rule with firewall-cmd --permanent --remove-service=ssh so port 22 is actually closed at the host layer.

6. Automatic Security Patching

Amazon Linux dnf-automatic patching configured and verified

7. Compliance Reporting

scripts/generate_report.sh is read-only by design. It verifies system state independently from harden.sh, then writes a Markdown report and uploads it to S3.

Each check maps back to a specific hardening control:

check_ssh_port() {
  if sudo grep -q "^Port 2222$" /etc/ssh/sshd_config && ! sudo grep -q "^Port 22$" /etc/ssh/sshd_config; then
    add_check_result "SSH migrated to hardened port 2222 only" "PASS"
  ...
}

During testing, this caught two instances where harden.sh had run but remove_legacy_ssh_port had not, leaving port 22 bound alongside port 2222. The report flagged the drift and prompted the follow-up fix.

Compliance report generation Compliance report content

8. Elastic IPs

Instances without Elastic IPs receive a new public IP after stop/start, which makes iterative testing painful. The project assigns one Elastic IP per instance, keyed to the same for_each map as the EC2 resources:

resource "aws_eip" "fleet" {
  for_each = var.instances
  instance = aws_instance.fleet[each.key].id
  domain   = "vpc"
}

This keeps each instance’s public IP stable through stop/start cycles.

9. Known Limitations