courses

Lab — Week 6: Policy, Access Control, and Host Firewall
Due: 14 August 2026
Submission: GitLab repo secdevops-s26-<CECS>

Overview

Add the final layer of host-side controls: policy-as-code, least-privilege access, and a host firewall with automated response.

Prerequisites


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.

  1. 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.json
    

    As 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 in lab11/lab11.md — that is the interesting result.

    ℹ️ gitleaks git scans commit history; gitleaks dir scans only the working tree. Older handouts and blog posts use gitleaks 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

  1. Enable and start auditd: sudo systemctl enable --now auditd

  2. Write /etc/audit/rules.d/dvwa.rules with the following rules. Each rule must include a -k key tag:
    • Watch /etc/shadow for read access
    • Watch /etc/sudoers and /etc/sudoers.d/ for any write
    • Audit all executions by UID 0 (syscall execve)
    • Watch /var/ossec/etc/ossec.conf for any modification
    • Watch the Docker socket /var/run/docker.sock for access

    The execve rule needs one rule per architecture. Syscall numbers differ between the 32- and 64-bit ABIs, so a rule without an arch filter 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_exec
    

    Note the syntax: always,exit (not always,user), capital -S for a syscall, and a single = in the field comparison. auditctl rejects the alternatives with unhelpful messages.

  3. Load the rules: sudo augenrules --load

  4. Verify the rules are active: sudo auditctl -l

    Expect more lines than you wrote — the sudoers requirement takes two watches and execve takes one per architecture, so five logical rules load as seven entries. If auditctl -l shows fewer than you expect, a rule failed to parse and was skipped; check sudo augenrules --load output rather than assuming.

  5. 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
    
  6. Search for triggered events:
    sudo ausearch -k shadow_access --start today
    sudo ausearch -k root_exec --start today
    
  7. Confirm the same events appear in the Wazuh dashboard under Security Events, filtered by rule.groups: audit. Take a screenshot.

  8. 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.

  1. Write a profile at /etc/apparmor.d/containers/docker-nginx. Start from docker-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
    }
    
  2. 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
    
  3. Apply it to the nginx service in docker-compose.yml, replacing docker-default:
    nginx:
      security_opt:
        - apparmor:docker-nginx
        - no-new-privileges:true
    

    Recreate the container so the change takes effect: docker compose up -d --force-recreate nginx

  4. 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"
    
  5. Verify nginx still serves DVWA correctly: curl -s -o /dev/null -w "%{http_code}" http://localhost:8080

    Expect 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 recent
    

    A 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.

  6. Save the profile to your repo as lab11/nginx-apparmor-profile.

Threat Model Update

  1. 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:


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

  1. 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
    
  2. Create /etc/sudoers.d/dvwa that grants dvwa-svc the single ability to restart its own container and nothing else:
    dvwa-svc ALL=(root) NOPASSWD: /usr/bin/systemctl restart dvwa
    

    Edit it with sudo visudo -f /etc/sudoers.d/dvwa — visudo syntax-checks before saving, and a malformed file under sudoers.d breaks sudo for 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 run docker as 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 granting systemctl restart on that one unit is what keeps the grant as narrow as it reads. If you have no dvwa.service yet, write a small unit that runs docker compose up -d/down in your project directory.

  3. Verify the restriction: sudo -l -U dvwa-svc The output must show exactly that one command and no others.

  4. Attempt a restricted command and confirm it fails:
    sudo -u dvwa-svc sudo bash   # must be denied
    

nftables Host Firewall

  1. Write /etc/nftables.d/lab.nft implementing 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, not 80; a rule for port 80 will not let anyone reach your stack.
    • Allow MariaDB (3306) from 127.0.0.1 only
    • Drop all other inbound traffic
    • Allow all outbound traffic

    Two things worth reasoning about rather than transcribing:

    • An early iif lo accept already 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 filter covers IPv4 only. With no table ip6 and no table inet, IPv6 input is completely unfiltered. Use table inet filter to cover both families in one ruleset, or explain in your writeup why leaving IPv6 open is acceptable here.
  2. 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’s nftables.service loads /etc/nftables.conf and nothing else. Add include "/etc/nftables.d/*.nft" to the end of /etc/nftables.conf and sudo systemctl enable --now nftables, then confirm with sudo nft list ruleset after a reboot. A firewall that disappears on restart is a firewall you do not have.

  3. 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)
    
  4. Verify MariaDB is unreachable from Kali:
    nc -zv -w 5 10.10.10.20 3306        # must time out
    

    A timeout is the result you want: it means the packet was silently dropped by policy drop. Connection refused would 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

  1. Configure /etc/fail2ban/jail.local:
    • SSH jail: maxretry=3, findtime=600, bantime=3600, using nftables-multiport banaction
    • 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.php with 302, 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 real 401 responses 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 "[^"]*" 401 against the access log, or point the jail at fail2ban’s packaged nginx-http-auth filter, which reads nginx’s error log instead. Say which you used and why in lab12/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.

  2. 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
    
  3. 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
    done
    

    BatchMode=yes stops ssh waiting for a password prompt, so the loop runs to completion instead of hanging on the first attempt.

  4. 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:

  1. 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.10
    

    If /var/log/fail2ban.log does not exist, check logtarget in /etc/fail2ban/fail2ban.conf — on some installs it is set to SYSLOG or SYSTEMD, in which case the ban lands in the journal instead and you should point the agent at /var/log/syslog.

  2. Add the fail2ban log to the Wazuh agent’s monitored files. Edit /var/ossec/etc/ossec.conf on 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

  3. 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.10
    

    Then repeat the failed-login loop from Kali (step 11), and re-check fail2ban-client status sshd.

  4. In the Wazuh dashboard (https://10.10.10.30), go to Modules → Security Events, select agent ubuntu-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_failures The brute-force detection from auth.log — typically rule 5712, “sshd: brute force trying to get access to the system”
    rule.groups: fail2ban The ban action itself — a “Fail2ban: host banned” style alert sourced from /var/log/fail2ban.log
    data.srcip: 10.10.10.10 Everything the SIEM recorded about Kali during the attack, both of the above together

    If the fail2ban group filter returns nothing, drop back to a free-text search for 10.10.10.10 over the same window and look for the alert whose full_log field contains the Ban 10.10.10.10 line — that confirms the log is being ingested even if it matched a different rule group.

  5. 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.

  6. Unban Kali before finishing: sudo fail2ban-client set sshd unbanip 10.10.10.10

Threat Model — Final Update for Preventive Controls

  1. 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:


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.