Audit and Secure a Small Business Network
Deploy DNS/DHCP and web services, then audit and harden them with nmap.
Introduction
30 Second Summary
Every time you load a web page at work, print a document, or access a shared file, you're relying on network services that someone had to set up and secure. When those services break or get compromised, the sysadmin is the first person called.
In this project, you will deploy a company network using Docker, then probe it with nmap to uncover security gaps. You'll fix a real vulnerability and build a Bash script that automates the entire security audit.
What You'll Build
Run a single command from your audit workstation and watch a structured security report fill your terminal, telling you exactly which network checks passed, which warned, and which failed.
By the end of this project, you'll have:
- A running company network where you type a hostname like intranet.company.local and get a live intranet page, powered by DHCP pools and DNS zones you configured yourself.
- Network reconnaissance results from nmap revealing every active host, open port, and software version on your subnet.
- A hardened web server and an automated security audit script you run with one command to generate a timestamped report with PASS, WARN, and FAIL results.
- Secret Mission: an optional challenge to push your sysadmin skills even further.
Are there any prerequisites?
You'll need Docker Desktop installed on your Mac and basic familiarity with the command line (commands like ls, cd, and mkdir). Everything else gets set up inside Docker containers during the project.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Network Lab
Every company network starts with infrastructure. Before you can configure services or run security audits, you need the servers themselves.
In this step, you will build the lab that simulates your company's network using Docker. You will create three containers: a DNS/DHCP server, a web server, and a security audit workstation, all connected on a private network.
In this step, get ready to:
- Verify Docker Desktop is running on your Mac.
- Create the project skeleton with directories and Dockerfiles for each service.
- Write a Docker Compose configuration that wires the three services into a single company network.
Verify Docker Desktop is running
Docker Desktop needs to be actively running for any container commands to work. A quick check now saves you from confusing errors later.
- Press Cmd+Space to open your search bar.
- Type Terminal and press Enter to open it.
- Check that Docker is running by executing this command:
docker info
✔️ I see Server Version in the output
Docker Desktop is running and ready. You should see details like the server version, number of containers, and storage driver. Move on to the next substep.
ⓧ Cannot connect to the Docker daemon
You will see a message saying Docker cannot connect to the daemon. This means Docker Desktop is installed but not currently running.
- Press Cmd+Space to open your search bar.
- Type Docker Desktop and press Enter to launch it.
- Wait for the whale icon in your menu bar to stop animating. This means Docker is fully started.
- Run docker info again in your terminal. You should now see the server version output.
Create the project skeleton and Dockerfiles
Your company network needs three servers, each living in its own directory. You will also create a scripts directory for the audit tools you build later.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and all subdirectories by running this command:
mkdir -p company-network/{dns-dhcp,web,toolbox,scripts}
- Move into the project folder and confirm the subdirectories exist by running:
cd company-network
ls
You should see four directories listed: dns-dhcp, scripts, toolbox, and web.
Don't see the four directories?
Make sure you ran the mkdir -p command from the ~/Desktop directory, not from inside a subdirectory. The curly braces must not have spaces inside them.
Help me debug my project directory structure.
The dns-dhcp server needs its own Docker image built from a Dockerfile. This image runs dnsmasq, a lightweight service that handles both DNS and DHCP for small networks.
- Create the file dns-dhcp/Dockerfile inside your company-network folder with the following content:
# Use a minimal Alpine Linux base image
FROM alpine:3.20
# Install dnsmasq for DNS and DHCP services
RUN apk add --no-cache dnsmasq
# Open ports for DNS (TCP and UDP) and DHCP
EXPOSE 53/tcp 53/udp 67/udp
# Run dnsmasq in the foreground so Docker can track the process
CMD ["dnsmasq", "--no-daemon", "--log-queries", "--log-facility=-"]
What does this Dockerfile do?
- FROM alpine:3.20 starts from a tiny Linux image (about 5 MB). Alpine keeps the container lean.
- --no-daemon keeps dnsmasq running in the foreground. Docker needs the main process to stay in the foreground or it thinks the container has exited.
- Port 53 is the standard DNS port. Port 67 is the standard DHCP port.
- Save dns-dhcp/Dockerfile.
Next up is the toolbox container. This is the security audit workstation you will use to scan and probe the network, like a sysadmin sitting down at a dedicated monitoring station.
- Create the file toolbox/Dockerfile inside your company-network folder with the following content:
# Use Ubuntu for broader tool support
FROM ubuntu:24.04
# Install network scanning and diagnostic tools
RUN apt-get update && apt-get install -y --no-install-recommends \
nmap \
iproute2 \
dnsutils \
iputils-ping \
curl \
cron \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory for audit scripts
WORKDIR /scripts
What does this Dockerfile do?
- nmap is the network scanner you will use to discover hosts and open ports.
- dnsutils gives you the dig command for testing DNS lookups.
- rm -rf /var/lib/apt/lists/* cleans up the package cache so the final image stays small.
- Save toolbox/Dockerfile.
- Confirm both Dockerfiles exist by running:
ls dns-dhcp/Dockerfile toolbox/Dockerfile
You should see both file paths listed without any errors.
Seeing 'No such file or directory'?
Make sure you are inside the company-network folder when you run the command. Run pwd to check your current location. The output should end with company-network.
Also confirm the files were saved inside the correct subdirectories, not at the project root.
Help me find my missing Dockerfiles.
Write the Docker Compose configuration
Docker Compose lets you define multiple containers and their network in a single file. Your compose.yaml will wire the DNS/DHCP server, web server, and toolbox together on a private company network.
- Create a file called compose.yaml in the root of your company-network folder. Start with the DNS/DHCP and web service definitions:
# Define the three services that make up the company network
services:
# DNS and DHCP server - central name resolution and address assignment
dns-dhcp:
build: ./dns-dhcp
container_name: company-dns-dhcp
cap_add:
- NET_ADMIN
volumes:
- ./dns-dhcp/dnsmasq.conf:/etc/dnsmasq.conf:ro
networks:
company-net:
ipv4_address: 192.168.50.10
# Company intranet web server
web:
image: nginx:1.30.4
container_name: company-web
volumes:
- ./web/index.html:/usr/share/nginx/html/index.html:ro
- ./web/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
company-net:
ipv4_address: 192.168.50.20
What does this configuration do?
- build: ./dns-dhcp tells Docker to build an image from the Dockerfile you just wrote in the dns-dhcp/ directory.
- cap_add: NET_ADMIN grants the DNS/DHCP container permission to manage network settings. DHCP needs this to assign IP addresses.
- ipv4_address gives each service a fixed IP on the company network, just like a real server room where critical machines have static addresses.
- The web service uses the official nginx image (nginx:1.30.4) directly from Docker Hub instead of building from a custom Dockerfile.
- Add the toolbox service and the network definition below the web service in the same compose.yaml file:
# Security audit workstation with scanning tools
toolbox:
build: ./toolbox
container_name: company-toolbox
cap_add:
- NET_RAW
dns:
- 192.168.50.10
depends_on:
- dns-dhcp
- web
networks:
company-net:
ipv4_address: 192.168.50.30
command: sleep infinity
volumes:
- ./scripts:/scripts
# Custom bridge network simulating the company LAN
networks:
company-net:
driver: bridge
ipam:
config:
- subnet: 192.168.50.0/24
gateway: 192.168.50.1
What does this second section do?
- cap_add: NET_RAW lets the toolbox send raw network packets. nmap needs this to scan ports and discover hosts.
- dns: 192.168.50.10 tells the toolbox to use your dnsmasq server for DNS lookups instead of Docker's default. This is how the toolbox can resolve company hostnames like intranet.company.local.
- command: sleep infinity keeps the toolbox container running so you can open a shell into it whenever you need to run scans.
- The networks section creates a custom bridge network called company-net with the subnet 192.168.50.0/24. This gives you 254 usable addresses for company devices.
- Save compose.yaml.
Before you run this, what do you think will happen? Will Docker validate the configuration, or will it complain about missing files?
- Validate the full Compose configuration by running:
docker compose config
Docker Compose parses and renders your configuration into its canonical form. You should see valid YAML output showing three services (dns-dhcp, web, toolbox) and one network (company-net).
Why use docker compose config?
This command validates your YAML and expands all short-form syntax into the full canonical format. It catches typos, indentation errors, and invalid keys before you try to launch any containers. It does not check whether mounted files exist on your machine yet.
Seeing a YAML error?
YAML is strict about indentation. Every level must use exactly 2 spaces (not tabs). Check that each service is indented under services: and each network is indented under networks:.
Also make sure you are running the command from inside the company-network folder, where compose.yaml lives.
Help me fix my compose.yaml errors.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all three files: dns-dhcp/Dockerfile, toolbox/Dockerfile, and compose.yaml.
ⓧ I'd like to double check the full code
Scroll up and confirm each file matches the code blocks above. You should have these three files in your company-network folder, in this order:
- dns-dhcp/Dockerfile (Alpine-based, installs dnsmasq, exposes ports 53 and 67).
- toolbox/Dockerfile (Ubuntu-based, installs nmap, iproute2, dnsutils, iputils-ping, curl, cron).
- compose.yaml (three services: dns-dhcp at 192.168.50.10, web at 192.168.50.20, toolbox at 192.168.50.30, plus the company-net bridge network).
If anything looks different, update it to match the code blocks above, then save and re-run docker compose config to confirm.
Your lab skeleton is in place. You have a DNS/DHCP server, a web server, and an audit workstation all defined and ready to launch. Next up, you will configure the DHCP pools and DNS records, then bring the entire network online.
Configure DHCP/DNS and Deploy the Company Network
Your project skeleton is in place with Dockerfiles and a validated Compose configuration. Now it is time to give the network its brain.
Without centralized DHCP and DNS, every device on the network needs its IP address configured by hand, and nobody can reach a server by name. This is the very first thing a sysadmin sets up on day one.
In this step, get ready to:
- Configure dnsmasq with DHCP pools, gateway options, static reservations, and internal DNS records.
- Create a company intranet web page and launch the full network with Docker Compose.
- Verify that DNS resolves company hostnames and the web server serves the intranet page.
Write the DHCP configuration
The dnsmasq service handles both DHCP and DNS in a single lightweight process. You will configure it with a dynamic address pool, network options, and static reservations for devices that always need the same IP.
- Create a new file called dnsmasq.conf inside the dns-dhcp folder of your company-network project.
- Paste the following DHCP configuration into the file:
# ============================================
# Company Network - DHCP and DNS Configuration
# ============================================
# DHCP Pool Configuration
# Assigns addresses from .100 to .200 with a 12-hour lease
dhcp-range=192.168.50.100,192.168.50.200,255.255.255.0,12h
# DHCP Options
# Option 3: Default gateway
dhcp-option=3,192.168.50.1
# Option 6: DNS server
dhcp-option=6,192.168.50.10
# Static DHCP Reservations
# Printer - always gets .50 with infinite lease
dhcp-host=aa:bb:cc:dd:ee:01,company-printer,192.168.50.50,infinite
# NAS - always gets .51 with infinite lease
dhcp-host=aa:bb:cc:dd:ee:02,company-nas,192.168.50.51,infinite
What does this configuration do?
- dhcp-range defines a pool of IPs (.100 to .200) that dnsmasq hands out to devices that connect to the network. Each lease lasts 12 hours before the device needs to renew.
- dhcp-option=3 tells devices where the network gateway is. dhcp-option=6 tells them which DNS server to use for name lookups.
- dhcp-host entries are static reservations. They pin a specific MAC address to a fixed IP. The company printer always gets .50 and the NAS always gets .51, no matter when they reconnect.
- Now add the DNS and remaining configuration below the static reservations, still in the same file:
# Internal DNS Records
address=/intranet.company.local/192.168.50.20
address=/dns.company.local/192.168.50.10
address=/mail.company.local/192.168.50.40
address=/files.company.local/192.168.50.41
# Upstream DNS Servers
server=8.8.8.8
server=8.8.4.4
# Logging
log-dhcp
log-queries
What do the DNS directives do?
- Each address= line maps a company hostname to a fixed IP. When anyone on the network types intranet.company.local, dnsmasq returns 192.168.50.20 instead of asking the internet.
- The server= lines point dnsmasq to Google's public DNS (8.8.8.8 and 8.8.4.4) for any hostname it does not handle internally.
- log-dhcp and log-queries enable logging so you can see every DHCP transaction and DNS query in the container logs.
- Save dns-dhcp/dnsmasq.conf.
- Confirm the file was created by running:
ls dns-dhcp/dnsmasq.conf
You should see dns-dhcp/dnsmasq.conf printed back to you.
Don't see the file?
- Make sure your terminal is inside the company-network folder. Run pwd to check.
- Confirm the file is named exactly dnsmasq.conf (not dnsmasq.cfg or dnsmasq.txt) and sits inside the dns-dhcp subdirectory.
Still stuck? Ask for help.
✔️ Awesome, I've got everything!
Great. Make sure you have saved dns-dhcp/dnsmasq.conf before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your file contains these sections in order: the DHCP pool and options chunk (with dhcp-range, dhcp-option, and dhcp-host entries), followed by the DNS records chunk (with address= lines, server= lines, and the logging directives).
Create the intranet page and launch the network
The company's nginx web server needs two things: a server configuration file and the HTML page it will serve.
- Create a new file called default.conf inside the web folder of your project. This tells nginx how to listen for and serve requests.
- Paste the following server block into it:
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
What does this server block do?
This is a basic nginx virtual host. It listens on port 80 and serves files from /usr/share/nginx/html. Your compose.yaml mounts this file into the nginx container so it replaces the default configuration.
- Save web/default.conf.
- Now create a new file called index.html inside the web folder. This is the company intranet page that employees will see.
- Paste the page header and styles into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Company Intranet</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 { color: #333; }
.status { color: #28a745; font-weight: bold; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background-color: #333; color: white; }
tr:nth-child(even) { background-color: #f2f2f2; }
</style>
</head>
What does this set up?
This is the HTML head section with embedded CSS for a clean internal portal look. The styles format a service directory table with alternating row colours and a dark header row.
- Confirm both web files exist by running:
ls web/
You should see default.conf and index.html listed.
- Now add the page body below the closing </head> tag in web/index.html:
<body>
<h1>Company Intranet Portal</h1>
<p>Status: <span class="status">Online</span></p>
<h2>Network Services</h2>
<table>
<tr><th>Service</th><th>Address</th><th>Status</th></tr>
<tr><td>DNS/DHCP Server</td><td>192.168.50.10</td><td>Active</td></tr>
<tr><td>Web Server</td><td>192.168.50.20</td><td>Active</td></tr>
<tr><td>Mail Server</td><td>192.168.50.40</td><td>Planned</td></tr>
<tr><td>File Server</td><td>192.168.50.41</td><td>Planned</td></tr>
</table>
<h2>Quick Links</h2>
<ul>
<li>IT Support: support@company.local</li>
<li>Network Admin: admin@company.local</li>
</ul>
<p><em>For internal use only. Unauthorized access is prohibited.</em></p>
</body>
</html>
What does this page show?
This is a service directory table listing every server on the company network, its IP address, and whether it is active or planned. It is exactly what a small company would put on an internal landing page so employees know where to find things.
- Save web/index.html.
- Launch the entire company network by running:
docker compose up -d --build
What does this command do?
- up creates and starts all three containers defined in compose.yaml.
- -d runs everything in detached mode so your terminal stays free.
- --build forces Docker to build the custom images for the dns-dhcp and toolbox containers from their Dockerfiles before starting them.
You should see Docker pull the nginx:1.30.4 image, build two custom images, and start all three containers. Look for lines ending in Started for each service.
Seeing a build or start error?
- If you see a message about a missing file, double check that dns-dhcp/dnsmasq.conf, web/default.conf, and web/index.html all exist. These files are bind-mounted into the containers.
- Make sure Docker Desktop is running. Check the Docker icon in your menu bar.
- If you see a port conflict on port 53, another DNS service may be using it. Run docker compose down, check for other containers on port 53, and try again.
Need more help debugging the error?
✔️ Awesome, I've got everything!
Great. Make sure you have saved both web/default.conf and web/index.html before moving on.
ⓧ I'd like to double check the full code
For web/default.conf, scroll up and confirm it has a single server block listening on port 80 with a location / pointing to /usr/share/nginx/html.
For web/index.html, confirm it contains the head section (styles for the table) followed by the body section (the Company Intranet Portal heading, the Network Services table with four rows, and the Quick Links list).
Verify DNS resolution and the web server
The containers are running, but you need to prove the services actually work. You will check three things: the dnsmasq configuration is valid, DNS resolves company hostnames, and the web server serves the intranet page.
- Validate the dnsmasq configuration by running:
docker compose exec dns-dhcp dnsmasq --test
You should see a line ending in dnsmasq: syntax check OK. This confirms every directive in your config file is valid.
Seeing a syntax error?
The error message will name the line number and the bad directive. Open dns-dhcp/dnsmasq.conf and check that line for typos, missing commas, or extra spaces. Restart the container with docker compose restart dns-dhcp after fixing the file.
Help me fix my dnsmasq config syntax error.
- Test DNS resolution from the toolbox container by running:
docker compose exec toolbox dig intranet.company.local @192.168.50.10
What does this command do?
- docker compose exec toolbox runs a command inside the running toolbox container.
- dig is a DNS lookup tool. It queries the dnsmasq server at 192.168.50.10 and asks it to resolve intranet.company.local.
Look for an ANSWER SECTION in the output. You should see intranet.company.local resolving to 192.168.50.20. That is your web server's IP.
Before you run the next command, what do you think you will see when you curl the web server's IP from inside the network?
- Verify the web server responds with the intranet page by running:
docker compose exec toolbox curl -s http://192.168.50.20
You should see the full HTML of your Company Intranet Portal, including the Network Services table with all four service rows.
Getting an empty response or connection refused?
- Run docker compose ps to check that all three containers show a status of Up. If the web container exited, check docker compose logs web for error details.
- Make sure web/index.html and web/default.conf exist as files (not directories). If they were created as directories by accident, delete them and recreate them as files.
Help me figure out why my web server is not responding.
Your company network is live. You have a DNS/DHCP server handing out addresses and resolving internal hostnames, and a web server hosting the company intranet. Next up, you will scan this network from the outside to see what an attacker would find.
Probe the Network with nmap
In the previous step, you deployed a full company network with DHCP pools, DNS records, and a web server. Everything is running.
But you have zero visibility into what is actually exposed on that network. A sysadmin who deploys services without auditing what they expose is flying blind. In this step, you will use nmap to scan the network from inside the toolbox container, the same way an attacker or a security auditor would.
In this step, get ready to:
- Discover all active hosts on the company network with a ping scan.
- Scan open ports and fingerprint running services on each server.
- Identify a real security vulnerability in the web server's HTTP headers.
Enter the toolbox and discover network hosts
The toolbox container is your audit workstation sitting on the company network. From here, you see the network exactly the way any device on it would.
- Enter the toolbox container by running this command:
docker compose exec toolbox bash
Your terminal prompt changes to something like root@company-toolbox:/scripts#. You are now inside the toolbox container on the company network.
Seeing an error?
Make sure your containers are running. Switch to a separate terminal, navigate to the company-network folder, and run docker compose ps to check. If any container is not running, start everything with docker compose up -d.
Help me troubleshoot why docker compose exec toolbox bash is failing.
Now it is time for your first reconnaissance scan. Host discovery answers the most basic question: who is on this network?
- Run a host discovery scan across the entire company subnet:
nmap -sn 192.168.50.0/24
What does nmap -sn do?
- The -sn flag tells nmap to perform a ping scan. It discovers live hosts without scanning any ports.
- 192.168.50.0/24 covers the entire company subnet (addresses 192.168.50.0 through 192.168.50.255). nmap pings every address to find which hosts are alive.
You should see Nmap scan report lines for at least three hosts: the gateway (192.168.50.1), the DNS/DHCP server (192.168.50.10), the web server (192.168.50.20), and the toolbox itself (192.168.50.30).
Seeing fewer hosts than expected?
Docker networking sometimes returns fewer hosts on the ping scan. As long as you see the three container IPs (192.168.50.10, 192.168.50.20, 192.168.50.30), your network is healthy.
Help me understand why my nmap -sn scan is not finding all the hosts I expected.
Scan ports and fingerprint services
Knowing which hosts exist is only half the picture. Every open port on those hosts is a potential entry point for an attacker. Port scanning reveals exactly which doors are open.
- Run a TCP port scan against the web server:
nmap -sT 192.168.50.20
What does -sT do?
The -sT flag performs a full TCP connect scan. It completes the TCP three-way handshake on each port to confirm whether it is open, closed, or filtered.
You should see port 80/tcp listed as open with the service name http. That is the web server doing its job.
- Now scan the DNS server to see its open ports:
nmap -sT 192.168.50.10
You should see port 53/tcp listed as open with the service name domain. That is dnsmasq listening for DNS queries.
Knowing a port is open tells you the door exists. The next question is: what exact software is behind that door?
- Run a service version scan to fingerprint the software on the web server:
nmap -sV 192.168.50.20
What does -sV do?
The -sV flag probes open ports to determine the exact software and version number running behind each one. Attackers use this to search for known vulnerabilities that apply to a specific version.
In the output, look at the VERSION column for port 80/tcp. You should see the nginx version number listed there.
That version number nmap found is interesting. But there is another way to check what the web server willingly reveals about itself to anyone who asks.
Before you run the next command, think about this: should a production web server broadcast exactly what software and version it runs to every single request?
- Check the web server's HTTP response headers:
curl -sI http://192.168.50.20
What does curl -sI do?
- -s runs curl in silent mode, suppressing the progress bar.
- -I fetches only the HTTP response headers, not the page content.
Look at the Server: header in the output. It reads Server: nginx/1.30.4. The web server is advertising its exact software name and version to every request.
This is a real security vulnerability called version disclosure. You will fix it in the next step.
Why is version disclosure dangerous?
When a server broadcasts its exact software version, an attacker can search vulnerability databases for known CVEs that target that specific version. It is free reconnaissance.
Hiding the version does not fix vulnerabilities, but it removes an easy shortcut that attackers rely on to narrow their attack surface.
Verify DNS resolution from the network
You have confirmed the web server is reachable by IP address. Now verify that the DNS records you configured in dnsmasq actually resolve correctly from a client on the network.
- Check that intranet.company.local resolves to the correct IP by running:
dig +short intranet.company.local @192.168.50.10
You should see 192.168.50.20 in the output. That confirms dnsmasq maps the company intranet hostname to the web server.
- Check the mail hostname resolves correctly:
dig +short mail.company.local @192.168.50.10
You should see 192.168.50.40. Both DNS records are resolving to the IPs you configured in dnsmasq.conf.
DNS records resolve to the correct IPs. But does end-to-end name resolution actually work? The toolbox is configured to use 192.168.50.10 as its DNS server. What do you think will happen when you curl the intranet by hostname instead of by IP?
- Fetch the intranet page using its company hostname:
curl -s http://intranet.company.local
You should see the full HTML of your company intranet page. The toolbox resolved intranet.company.local to 192.168.50.20 through dnsmasq, then fetched the page from nginx.
Getting a connection error instead of HTML?
Make sure you are running this command inside the toolbox container (your prompt should show root@company-toolbox). The toolbox has its DNS set to 192.168.50.10 via the dns: option in compose.yaml. Running this from your host terminal will not resolve the .company.local domain.
Help me troubleshoot why curl to intranet.company.local is failing from the toolbox.
You now have a complete picture of what your network exposes: the live hosts, the open ports, the exact software versions, and one real vulnerability. The web server is leaking its nginx version to anyone who asks. In the next step, you will fix that vulnerability and build a script to automate the entire security audit.
Build the Automated Security Audit Script and Fix the Vulnerability
In Step 3, you discovered that the web server is leaking its exact nginx version number in the Server header. That gives attackers a shortcut to finding known vulnerabilities for that specific software version.
In this step, you will fix that vulnerability and then build a Bash script that automates every security check you ran manually. Manual scanning does not scale. Real sysadmins write audit scripts so checks run consistently every time, on every machine.
In this step, get ready to:
- Fix the nginx version disclosure vulnerability.
- Write an automated security audit script with PASS/WARN/FAIL reporting.
- Run the script and review the generated report.
Fix the nginx version disclosure
The Server: nginx/1.30.4 header you saw in Step 3 tells anyone probing your network exactly which software version is running. An attacker can look up known CVEs for that version and launch a targeted exploit.
The fix is a single nginx directive: server_tokens off. This tells nginx to stop including the version number in its responses.
- Create a new file called web/default.conf in your company-network project folder. Paste the following configuration into it:
server {
listen 80;
server_name localhost;
# Prevent nginx from revealing its version number
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
What does this config do?
This is a standard nginx server block that listens on port 80. The key line is server_tokens off. With this directive, the Server header in HTTP responses changes from Server: nginx/1.30.4 to just Server: nginx. The version number is stripped out.
Why is the compose.yaml already set up for this file?
Your compose.yaml already contains a volume mount for web/default.conf from when you created it in Step 1. Up until now, the file did not exist on disk, so nginx used its built-in defaults. Now that you are creating the file, nginx will load your custom configuration on restart.
- Save web/default.conf.
- Restart the web service so it picks up the new config by running this command from your company-network directory:
docker compose up -d
Docker Compose recreates only the containers whose configuration changed. You should see the web container restart while the others remain unchanged.
Before you run the next command, think about what you expect the Server header to look like now. Will it still show a version number?
- Verify the fix by checking the HTTP headers from inside the toolbox:
docker compose exec toolbox curl -sI http://192.168.50.20
The Server header now reads Server: nginx with no version number. The vulnerability is fixed.
Still seeing a version number?
- Make sure you saved web/default.conf before restarting the containers.
- Confirm the file is at web/default.conf (not web/nginx.conf or another filename). The mount in compose.yaml expects that exact path.
- Check that server_tokens off; is inside the server { } block, not outside it.
Why is the Server header still showing the nginx version?
✔️ Awesome, I've got everything!
Great. Double check you have saved web/default.conf.
ⓧ I'd like to double check the full code
server {
listen 80;
server_name localhost;
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
Write the security audit script
Now that the vulnerability is fixed, it is time to automate all the checks you have been running manually. You will build a Bash script that scans the network, verifies DNS, checks the web server, and generates a structured report with PASS, WARN, and FAIL results.
- Create a new file called scripts/audit.sh in your company-network folder. Start by pasting the script header, variables, and utility functions:
#!/bin/bash
# Company Network Security Audit Script
# Run from inside the toolbox container
DNS_SERVER="192.168.50.10"
WEB_SERVER="192.168.50.20"
NETWORK="192.168.50.0/24"
REPORT="/tmp/audit-report-$(date +%Y%m%d-%H%M%S).txt"
PASS=0
WARN=0
FAIL=0
result() {
local status=$1 check=$2 detail=$3
case $status in
PASS) PASS=$((PASS + 1)) ;;
WARN) WARN=$((WARN + 1)) ;;
FAIL) FAIL=$((FAIL + 1)) ;;
esac
printf "[%s] %s - %s\n" "$status" "$check" "$detail" | tee -a "$REPORT"
}
header() {
printf "\n--- %s ---\n" "$1" | tee -a "$REPORT"
}
What does this code do?
- DNS_SERVER, WEB_SERVER, and NETWORK store the IPs and subnet the script will scan. Changing these variables lets you reuse the script on a different network.
- REPORT creates a unique timestamped filename so each audit run produces its own report file.
- The result() function is the heart of the script. It increments the appropriate counter (PASS, WARN, or FAIL), then prints the result to both the terminal and the report file using tee.
- The header() function prints section dividers to keep the report readable.
- Below the header() function, add the report banner, host discovery, and DNS verification checks:
printf "==================================================\n" | tee "$REPORT"
printf " Company Network Security Audit\n" | tee -a "$REPORT"
printf " Date: %s\n" "$(date)" | tee -a "$REPORT"
printf " Auditor: %s\n" "$(hostname)" | tee -a "$REPORT"
printf "==================================================\n" | tee -a "$REPORT"
header "Host Discovery"
host_count=$(nmap -sn "$NETWORK" 2>/dev/null | grep -c "Nmap scan report")
if [ "$host_count" -ge 3 ]; then
result "PASS" "Network Scan" "Found $host_count active hosts"
else
result "WARN" "Network Scan" "Expected 3+ hosts, found $host_count"
fi
header "DNS Verification"
dns_intranet=$(dig +short intranet.company.local @"$DNS_SERVER" 2>/dev/null)
if [ "$dns_intranet" = "$WEB_SERVER" ]; then
result "PASS" "DNS - intranet.company.local" "Resolves to $WEB_SERVER"
else
result "FAIL" "DNS - intranet.company.local" "Expected $WEB_SERVER, got: $dns_intranet"
fi
dns_mail=$(dig +short mail.company.local @"$DNS_SERVER" 2>/dev/null)
if [ -n "$dns_mail" ]; then
result "PASS" "DNS - mail.company.local" "Resolves to $dns_mail"
else
result "WARN" "DNS - mail.company.local" "No record found"
fi
What do these checks do?
- The report banner prints a header with the date and hostname to identify each audit run.
- Host Discovery uses nmap -sn (the same ping scan you ran manually in Step 3) and counts the results. If fewer than 3 hosts are found, something is wrong.
- DNS Verification runs dig for intranet.company.local and mail.company.local, comparing the results against expected IPs. A FAIL means DNS is broken. A WARN means a record is missing.
- Save scripts/audit.sh and test the partial script to confirm it runs correctly so far:
docker compose exec toolbox bash /scripts/audit.sh
You should see the report banner, the Host Discovery section with a PASS result showing 3 or more active hosts, and the DNS Verification section with PASS results for both hostnames.
Seeing a syntax error?
- Check that every if statement has a matching fi.
- Make sure the case statement inside result() ends with esac.
- Confirm there are no extra or missing quotes around the variable references.
Why is my audit script throwing a syntax error?
- Next, add the web server audit and the web server port scan. Paste this code below the DNS verification section in scripts/audit.sh:
header "Web Server Audit"
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://$WEB_SERVER" 2>/dev/null)
if [ "$http_code" = "200" ]; then
result "PASS" "Web Server Status" "HTTP $http_code"
else
result "FAIL" "Web Server Status" "HTTP $http_code"
fi
server_header=$(curl -sI "http://$WEB_SERVER" 2>/dev/null | grep -i "^Server:" | tr -d '\r')
if echo "$server_header" | grep -q "/"; then
result "WARN" "Server Version Leak" "$server_header (version exposed)"
else
result "PASS" "Server Header" "Version info is hidden"
fi
header "Port Scan - Web Server ($WEB_SERVER)"
while IFS= read -r line; do
port=$(echo "$line" | awk '{print $1}')
service=$(echo "$line" | awk '{print $3}')
if [ "$port" = "80/tcp" ]; then
result "PASS" "Port $port" "Expected service ($service)"
else
result "WARN" "Port $port" "Unexpected open port ($service)"
fi
done < <(nmap -sT "$WEB_SERVER" 2>/dev/null | grep "/tcp.*open")
What do these checks do?
- Web Server Status checks that the web server returns HTTP 200. Anything else is a failure.
- Server Version Leak looks for a / character in the Server header. A slash means the header contains a version number like nginx/1.30.4. Without the slash, the version is hidden.
- The port scan loops through every open TCP port on the web server. Port 80/tcp is expected. Anything else gets a WARN because it might be an unintended exposure.
- Finally, add the DNS server port scan, connectivity checks, and the summary block. Paste this at the bottom of scripts/audit.sh:
header "Port Scan - DNS Server ($DNS_SERVER)"
while IFS= read -r line; do
port=$(echo "$line" | awk '{print $1}')
service=$(echo "$line" | awk '{print $3}')
if [ "$port" = "53/tcp" ]; then
result "PASS" "Port $port" "DNS service ($service)"
else
result "WARN" "Port $port" "Unexpected open port ($service)"
fi
done < <(nmap -sT "$DNS_SERVER" 2>/dev/null | grep "/tcp.*open")
header "Connectivity"
if ping -c 1 -W 2 "$WEB_SERVER" > /dev/null 2>&1; then
result "PASS" "Ping Web Server" "$WEB_SERVER reachable"
else
result "FAIL" "Ping Web Server" "$WEB_SERVER unreachable"
fi
if ping -c 1 -W 2 "$DNS_SERVER" > /dev/null 2>&1; then
result "PASS" "Ping DNS Server" "$DNS_SERVER reachable"
else
result "FAIL" "Ping DNS Server" "$DNS_SERVER unreachable"
fi
printf "\n==================================================\n" | tee -a "$REPORT"
printf " AUDIT SUMMARY\n" | tee -a "$REPORT"
printf " PASS: %d | WARN: %d | FAIL: %d\n" "$PASS" "$WARN" "$FAIL" | tee -a "$REPORT"
printf " Report saved: %s\n" "$REPORT" | tee -a "$REPORT"
printf "==================================================\n" | tee -a "$REPORT"
What do these final checks do?
- The DNS server port scan works just like the web server scan, but expects port 53/tcp (DNS) and flags anything else as unexpected.
- Connectivity sends a single ping to each server with a 2-second timeout. If a server is unreachable, that is a FAIL.
- The summary tallies all PASS, WARN, and FAIL counts and prints the path to the saved report file.
- Save scripts/audit.sh.
✔️ Awesome, I've got everything!
Great. Double check that all four sections of code appear in scripts/audit.sh in order: the variables and utility functions at the top, then the report banner with host discovery and DNS, then the web server audit with the web server port scan, then the DNS server port scan with connectivity and summary at the bottom.
ⓧ I'd like to double check the full code
The full scripts/audit.sh file is built from the four code blocks above. Scroll up and confirm each one is present in your file, in this order:
- Block 1: Shebang (#!/bin/bash), variables (DNS_SERVER, WEB_SERVER, NETWORK, REPORT), counters, result() function, header() function.
- Block 2: Report banner (printf lines), Host Discovery (nmap -sn), DNS Verification (dig for intranet.company.local and mail.company.local).
- Block 3: Web Server Audit (HTTP status and server header leak), Port Scan for the web server (nmap -sT on 192.168.50.20).
- Block 4: Port Scan for the DNS server (nmap -sT on 192.168.50.10), Connectivity (ping), and Audit Summary.
Run the full audit and review the report
The script is complete. Time to make it executable and run the full audit from inside the toolbox.
- Make the script executable by running:
chmod +x scripts/audit.sh
- Before you run the full audit, predict what you expect to see. Will the Server Header check show PASS or WARN? What about Host Discovery and DNS?
- Run the audit from inside the toolbox:
docker compose exec toolbox bash /scripts/audit.sh
You should see a structured report scroll by with section headers for Host Discovery, DNS Verification, Web Server Audit, Port Scans, and Connectivity. The Server Header check shows PASS because you fixed the version leak earlier in this step.
What should the report look like?
Every check should show [PASS] in a healthy network. You may see [WARN] on some port scans if Docker exposes additional ports beyond the expected ones. No checks should show [FAIL] unless a container has stopped or DNS is misconfigured.
The last line of the report prints the file path where the timestamped report was saved inside the toolbox container, under /tmp/.
Seeing permission denied?
- Make sure you ran chmod +x scripts/audit.sh from your host terminal (inside the company-network directory), not from inside the toolbox container.
- If the script still fails, try running it directly with bash as shown in the command above. Using bash /scripts/audit.sh bypasses the execute permission entirely.
Why is my audit script failing with a permission error?
Secret mission
Add File System Auditing and Automated Scheduling
Your audit script catches network vulnerabilities, but what about the file system itself? Extend the script to detect dangerous SUID binaries and world-writable files, then schedule it to run automatically every day with cron.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your Mac using Docker, so there are no ongoing cloud costs.
Resources you used:
- 3 Docker containers: company-dns-dhcp, company-web, company-toolbox.
- 2 custom Docker images built from your Dockerfiles (company-network-dns-dhcp and company-network-toolbox).
- 1 pulled Docker image: nginx:1.30.4.
- 1 Docker bridge network: company-net.
- The company-network project directory on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively experimenting with your network lab or want to keep tweaking your audit script.
- Your three containers will keep running and use minimal CPU and memory.
- The company-network directory stays on your Desktop with all your configuration files and the audit script.
- You can re-enter the toolbox any time by running docker compose exec toolbox bash from inside the project directory.
Pause - I'll come back to this later
Shut down the running containers to free up memory, but keep everything in place so you can resume later.
- Open your terminal and navigate to the company-network directory on your Desktop.
- Stop all containers by running:
docker compose stop
- When you are ready to resume, navigate back to the company-network directory and run:
docker compose start
- All three containers will come back up with your existing configuration intact.
Delete - I don't want to use this again
Remove all project containers, networks, images, and files from your machine.
- Open your terminal and navigate to the company-network directory on your Desktop.
- Remove all containers and the Docker network by running:
docker compose down
- Remove the custom Docker images to reclaim disk space by running:
docker rmi company-network-dns-dhcp company-network-toolbox nginx:1.30.4
- Navigate up to your Desktop and delete the project directory by running:
cd ~/Desktop
rm -rf company-network
- Confirm the directory is gone by running ls ~/Desktop and checking that company-network no longer appears.
Nice Work!
Nice Work!
You just built, secured, and audited a small business network from scratch. You walked in as the new sysadmin, stood up core infrastructure, found a real vulnerability, and automated the monitoring to catch problems before attackers do.
You've learned how to:
- Deploy a multi-container company network with Docker Compose, featuring dnsmasq DHCP pools with lease times, gateway options, static MAC-based reservations, and internal DNS zones that resolve company hostnames to the correct servers.
- Use nmap for network reconnaissance to discover active hosts, scan open ports, fingerprint running services, and identify a real nginx version disclosure vulnerability that you then fixed with server_tokens off.
- Build an automated Bash security audit script that scans the network and generates a timestamped report with structured PASS/WARN/FAIL results across host discovery, DNS, port scanning, and web server checks.
- Secret Mission: Extended the audit with file system security checks for SUID binaries and world-writable files, then scheduled automated daily audits with cron.
Ready to quiz yourself?