Lab — Week 6: Policy, Access Control, and Host Firewall
Due: 14 August 2026
Submission: GitLab repo secdevops-s26-<CECS>
- Overview
- Prerequisites
- Part 1: Secrets Detection — Your Course Repo
- Part 2: Policy-as-Code with auditd and AppArmor
- Part 3: IAM, Host Firewall, and Automated Response
- Submission
Overview
Add the final layer of host-side controls: policy-as-code, least-privilege access, and a host firewall with automated response.
- Part 1 — Policy-as-Code with auditd and AppArmor implements auditd rules that log privileged operations, connects those events to Wazuh, and confines nginx with an AppArmor profile.
- Part 2 — IAM, Host Firewall, and Automated Response applies least privilege at three levels: service-account restrictions, an nftables host firewall, and fail2ban automated response.
Prerequisites
- Week 4 Part 2 complete — Wazuh agent active on ubuntu-server
- auditd, apparmor-utils, nftables, and fail2ban installed (by Ansible)
Part 1: Secrets Detection — Your Course Repo
This repeats the gitleaks scan from Week 5 Part 1, deliberately. Four weeks of labs have been committed since then — Trivy reports, Compose files, Wazuh configuration — and the point of a secrets scan is that it runs again every time the history grows. Report into this week’s directory so the two runs can be compared.
- Run gitleaks across the full history of your course repo:
cd ~/path/to/secdevops-s26-<CECS> gitleaks git . \ --report-format json \ --report-path lab11/gitleaks-report.jsonAs in Week 5, the report must be clean, or every suppressed fingerprint must be justified in a
.gitleaksignore. If this run finds something the Week 5 run did not, say which commit introduced it inlab11/lab11.md— that is the interesting result.ℹ️
gitleaks gitscans commit history;gitleaks dirscans only the working tree. Older handouts and blog posts usegitleaks detect --source ., which was renamed in gitleaks 8.19 — the old form still runs but is hidden from--help.
Part 2: Policy-as-Code with auditd and AppArmor
Security controls written as code are enforced continuously, are version-controlled, and are testable. This part implements auditd rules that log privileged operations on ubuntu-server, connects those events to the Wazuh dashboard, and confines nginx with an AppArmor profile.
auditd Rules
-
Enable and start auditd:
sudo systemctl enable --now auditd - Write
/etc/audit/rules.d/dvwa.ruleswith the following rules. Each rule must include a-kkey tag:- Watch
/etc/shadowfor read access - Watch
/etc/sudoersand/etc/sudoers.d/for any write - Audit all executions by UID 0 (syscall
execve) - Watch
/var/ossec/etc/ossec.conffor any modification - Watch the Docker socket
/var/run/docker.sockfor access
The
execverule needs one rule per architecture. Syscall numbers differ between the 32- and 64-bit ABIs, so a rule without anarchfilter covers only one of them and a process invoking the other slips past unlogged:-a always,exit -F arch=b32 -S execve -F euid=0 -k root_exec -a always,exit -F arch=b64 -S execve -F euid=0 -k root_execNote the syntax:
always,exit(notalways,user), capital-Sfor a syscall, and a single=in the field comparison.auditctlrejects the alternatives with unhelpful messages. - Watch
-
Load the rules:
sudo augenrules --load -
Verify the rules are active:
sudo auditctl -lExpect more lines than you wrote — the sudoers requirement takes two watches and
execvetakes one per architecture, so five logical rules load as seven entries. Ifauditctl -lshows fewer than you expect, a rule failed to parse and was skipped; checksudo augenrules --loadoutput rather than assuming. - Trigger each rule deliberately and confirm events appear:
sudo cat /etc/shadow # triggers shadow_access key sudo touch /etc/sudoers.d/test && sudo rm /etc/sudoers.d/test # triggers sudoers key sudo ls / # triggers root_exec key - Search for triggered events:
sudo ausearch -k shadow_access --start today sudo ausearch -k root_exec --start today -
Confirm the same events appear in the Wazuh dashboard under Security Events, filtered by
rule.groups: audit. Take a screenshot. - Commit
etc/audit/rules.d/dvwa.rules(the file itself, not the system path) to your repo.
AppArmor Profile for nginx
Your nginx runs in a container, which changes how this works. AppArmor profiles attach to a binary path on the host, so a profile at /usr/sbin/nginx confines a host-installed nginx — and yours is inside a container image, invisible to that path. Docker also applies its own docker-default profile to every container unless you override it, so a container is already confined by something; the job here is to replace that with a profile you wrote.
This is why aa-genprof is not used below. It learns a profile by watching a process run on the host, and there is no host nginx process to watch.
- Write a profile at
/etc/apparmor.d/containers/docker-nginx. Start fromdocker-default’s shape — deny writes to the container’s own binaries and configuration, deny mount, deny raw network — and add what nginx needs to serve traffic. Name the profile on its first line; the name, not the filename, is what you reference later:#include <tunables/global> profile docker-nginx flags=(attach_disconnected,mediate_deleted) { #include <abstractions/base> network inet tcp, deny @{PROC}/* w, deny /sys/[^f]*/** wklx, # ... the rest of what your nginx actually needs } - Load the profile into the kernel on ubuntu-server:
sudo apparmor_parser -r -W /etc/apparmor.d/containers/docker-nginx sudo aa-status | grep docker-nginx # must appear in the loaded list - Apply it to the nginx service in
docker-compose.yml, replacingdocker-default:nginx: security_opt: - apparmor:docker-nginx - no-new-privileges:trueRecreate the container so the change takes effect:
docker compose up -d --force-recreate nginx - Confirm the running container is confined by your profile, not the default:
docker inspect $(docker compose ps -q nginx) | jq '.[0].AppArmorProfile' # Must print "docker-nginx", not "docker-default" -
Verify nginx still serves DVWA correctly:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080Expect to iterate. A profile that is too tight breaks nginx immediately; a profile that is too loose confines nothing. Watch what the kernel is refusing while you tune it:
sudo dmesg -w | grep -i apparmor sudo ausearch -m AVC --start recentA profile under which nginx works and which logs no denials is the suspicious case — check step 12 again, because it usually means the profile is not attached to the process you think it is.
- Save the profile to your repo as
lab11/nginx-apparmor-profile.
Threat Model Update
- In
lab02/threat-model.md, mark threats mitigated by auditd and AppArmor. Add a note for each: what STRIDE category it addresses and the specific rule or profile that enforces it.
Deliverables
Create lab11/ in your repo containing:
dvwa.rules— your auditd rules fileausearchoutput — showing each rule triggering at least once- Wazuh screenshot — auditd events visible in Security Events dashboard
nginx-apparmor-profile— the AppArmor profile for nginx- nginx health check —
curloutput showing HTTP 200 with the profile applied aa-statusoutput — showing your profile loaded in enforce modedocker inspectoutput — showingAppArmorProfileis your profile and notdocker-default- Updated
lab02/threat-model.md lab11/lab11.md— for each auditd rule, state which threat model entry it addresses and which STRIDE category it covers
Part 3: IAM, Host Firewall, and Automated Response
Least privilege applied at three levels in one part: service account restrictions limit what the DVWA process can do if compromised, nftables enforces which network paths are permitted, and fail2ban automatically blocks brute-force sources — all before a human can react.
Part 2 must be complete — auditd running on ubuntu-server.
Service Account Hardening
- Create a dedicated service account for DVWA with no login shell and no home directory:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin dvwa-svc - Create
/etc/sudoers.d/dvwathat grantsdvwa-svcthe single ability to restart its own container and nothing else:dvwa-svc ALL=(root) NOPASSWD: /usr/bin/systemctl restart dvwaEdit it with
sudo visudo -f /etc/sudoers.d/dvwa—visudosyntax-checks before saving, and a malformed file undersudoers.dbreakssudofor everyone on the host.Note what this deliberately does not grant. The obvious alternative,
NOPASSWD: /usr/bin/docker compose restart dvwa, would be a full privilege escalation: anyone who can rundockeras root can start a container that bind-mounts/and read or modify anything on the host, so “restart one container” and “become root” are the same permission. Wrapping the stack in a systemd unit and grantingsystemctl restarton that one unit is what keeps the grant as narrow as it reads. If you have nodvwa.serviceyet, write a small unit that runsdocker compose up -d/downin your project directory. -
Verify the restriction:
sudo -l -U dvwa-svcThe output must show exactly that one command and no others. - Attempt a restricted command and confirm it fails:
sudo -u dvwa-svc sudo bash # must be denied
nftables Host Firewall
- Write
/etc/nftables.d/lab.nftimplementing the following policy (commit it to your repo):- Allow established and related connections (return traffic)
- Allow SSH only from
10.10.10.10(Kali) - Allow HTTP and HTTPS from any source — on whichever host port your nginx container publishes. If you followed Week 3 that is
8080, not80; a rule for port 80 will not let anyone reach your stack. - Allow MariaDB (3306) from
127.0.0.1only - Drop all other inbound traffic
- Allow all outbound traffic
Two things worth reasoning about rather than transcribing:
- An early
iif lo acceptalready permits everything arriving on loopback, which makes a separate 3306-from-127.0.0.1 rule redundant. Writing it anyway documents the intent; leaving it out is also defensible. Either way, say which you chose and why — and note that in this stack MariaDB has no published port at all, so nothing is listening on the host’s 3306 in the first place. table ip filtercovers IPv4 only. With notable ip6and notable inet, IPv6 input is completely unfiltered. Usetable inet filterto cover both families in one ruleset, or explain in your writeup why leaving IPv6 open is acceptable here.
- Apply the ruleset, then make it survive a reboot:
sudo mkdir -p /etc/nftables.d sudo nft -f /etc/nftables.d/lab.nft sudo nft list ruleset # verify/etc/nftables.d/is not read automatically — Ubuntu’snftables.serviceloads/etc/nftables.confand nothing else. Addinclude "/etc/nftables.d/*.nft"to the end of/etc/nftables.confandsudo systemctl enable --now nftables, then confirm withsudo nft list rulesetafter a reboot. A firewall that disappears on restart is a firewall you do not have. - Test from Kali (10.10.10.10):
ssh dmcgrath@10.10.10.20 # must succeed curl http://10.10.10.20:8080/ # must succeed (use your published port) - Verify MariaDB is unreachable from Kali:
nc -zv -w 5 10.10.10.20 3306 # must time outA timeout is the result you want: it means the packet was silently dropped by
policy drop.Connection refusedwould mean the packet reached the host and got an RST — the firewall let it through and nothing was listening. The two look similar and mean different things.
fail2ban Configuration
- Configure
/etc/fail2ban/jail.local:- SSH jail:
maxretry=3,findtime=600,bantime=3600, usingnftables-multiportbanaction - nginx jail: pattern-match repeated HTTP 401 responses on the DVWA login endpoint (
/dvwa/login.php);maxretry=5,findtime=60,bantime=3600
The nginx jail needs one setup step first. DVWA’s own login does not produce 401s — a failed login redirects back to
login.phpwith302, and so does a successful one, so nginx’s access log cannot tell the two apart. Put HTTP Basic authentication on that location in your nginx config so authentication failures become real401responses that a log-based filter can match:location /dvwa/login.php { auth_basic "DVWA"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://dvwa; }Then either write a filter matching
"[^"]*" 401against the access log, or point the jail at fail2ban’s packagednginx-http-authfilter, which reads nginx’s error log instead. Say which you used and why inlab12/lab12.md.Remember from Week 2 that every setting must sit under a
[DEFAULT]or[jailname]header — a key before the first section header makes fail2ban discard the entire file. - SSH jail:
- Enable and start fail2ban, then confirm both jails actually loaded:
sudo systemctl enable --now fail2ban sudo fail2ban-client status # must list sshd and your nginx jail - From Kali, trigger the SSH ban:
for i in {1..5}; do ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no \ wronguser@10.10.10.20 doneBatchMode=yesstops ssh waiting for a password prompt, so the loop runs to completion instead of hanging on the first attempt. - On ubuntu-server, confirm the ban:
sudo fail2ban-client status sshd # Shows: Banned IP list: 10.10.10.10
Confirming the Ban in Wazuh
Two separate things reach the SIEM when you trigger a ban, and you need both:
- The failed logins themselves, which sshd writes to
/var/log/auth.log. The Wazuh agent already monitors that file (default configuration from Week 4), so these alerts appear with no extra work. - The ban action, which fail2ban writes to its own log,
/var/log/fail2ban.log. Nothing monitors that file by default — the agent has no idea it exists. You must tell it to read the file, or the ban will never appear in the dashboard no matter how many times you trigger it.
- On ubuntu-server, confirm fail2ban actually logged the ban locally before looking at the dashboard:
sudo grep -E 'Ban|Found' /var/log/fail2ban.log | tail -10 # Expect a line like: fail2ban.actions [...] NOTICE [sshd] Ban 10.10.10.10If
/var/log/fail2ban.logdoes not exist, checklogtargetin/etc/fail2ban/fail2ban.conf— on some installs it is set toSYSLOGorSYSTEMD, in which case the ban lands in the journal instead and you should point the agent at/var/log/syslog. - Add the fail2ban log to the Wazuh agent’s monitored files. Edit
/var/ossec/etc/ossec.confon ubuntu-server and add the following inside<ossec_config>:<localfile> <log_format>syslog</log_format> <location>/var/log/fail2ban.log</location> </localfile>Restart the agent:
sudo systemctl restart wazuh-agent - Re-trigger the ban so events are generated after the agent started reading the file (unban first, since Kali is still blocked from step 12):
sudo fail2ban-client set sshd unbanip 10.10.10.10Then repeat the failed-login loop from Kali (step 11), and re-check
fail2ban-client status sshd. -
In the Wazuh dashboard (
https://10.10.10.30), go to Modules → Security Events, select agentubuntu-server, and set the time range to Last 15 minutes. In the search bar, apply each of these filters in turn:Filter What you should see rule.groups: authentication_failuresThe brute-force detection from auth.log— typically rule 5712, “sshd: brute force trying to get access to the system”rule.groups: fail2banThe ban action itself — a “Fail2ban: host banned” style alert sourced from /var/log/fail2ban.logdata.srcip: 10.10.10.10Everything the SIEM recorded about Kali during the attack, both of the above together If the
fail2bangroup filter returns nothing, drop back to a free-text search for10.10.10.10over the same window and look for the alert whosefull_logfield contains theBan 10.10.10.10line — that confirms the log is being ingested even if it matched a different rule group. -
Expand one ban alert in the dashboard and record its rule ID, rule level, and description; you will cite these in
lab12.md. Take a screenshot showing the expanded alert with the source IP and rule description visible. - Unban Kali before finishing:
sudo fail2ban-client set sshd unbanip 10.10.10.10
Threat Model — Final Update for Preventive Controls
- In
lab02/threat-model.md, mark all threats now addressed by service account restrictions, nftables rules, or fail2ban. This should bring nearly all of your top 5 threats to MITIGATED status.
Deliverables
Create lab12/ in your repo containing:
/etc/sudoers.d/dvwa— the sudoers filesudo -l -U dvwa-svcoutput — showing only the permitted commandlab.nft— the nftables rulesetnft list rulesetoutput — confirming rules are loaded- Network test results — SSH and HTTP success from Kali; MariaDB failure from Kali
/etc/fail2ban/jail.local— fail2ban configuration- fail2ban ban confirmation —
fail2ban-client status sshdshowing Kali IP banned - fail2ban log excerpt — the
Ban 10.10.10.10line from/var/log/fail2ban.log localfilestanza — the block you added to/var/ossec/etc/ossec.conffor/var/log/fail2ban.log- Wazuh alert screenshot — expanded fail2ban ban alert in Modules → Security Events, source IP and rule description visible
- Updated
lab02/threat-model.md— near-complete mitigation of top 5 threats lab12/lab12.md— brief explanation of how each control maps to your threat model; the rule ID, level, and description of the fail2ban ban alert you observed in Wazuh; and one threat that remains open with a reason why it cannot be fully mitigated by these controls
Submission
Commit all files for both parts — lab11/ and lab12/ — and push to your GitLab repo. The lab is considered submitted when the commit appears in GitLab before the due date.