CI/CD Pipeline for a Security Scanner

Build a CI/CD pipeline that lints, tests, and containerizes a network security scanner.

Introduction

30 Second Summary

Every time you change code and push it live, something could break. Without automated checks, bugs slip through and reach users before anyone notices.

In this project, you will build a CI/CD pipeline that automatically validates a network security scanner every time you push code. Your pipeline will catch mistakes before they ever reach production.

What You'll Build

A complete GitHub Actions pipeline that lints, tests, and containerizes a Bash-based network security scanner called netscan.

By the end of this project, you'll have:

  • A network security scanner that performs port scanning, SSL/TLS certificate analysis, DNS lookups, and HTTP security header inspection.
  • An automated CI/CD pipeline that lints your code with ShellCheck, runs tests with bats-core, and builds a Docker image on every push.
  • A public GitHub repository with a green CI badge proving your scanner is automatically validated, demonstrating DevSecOps practices to employers.
  • Secret Mission: Split your pipeline into independent stages with dependency ordering, matching how real production pipelines work.

Are there any prerequisites?

You should be comfortable with basic Bash scripting (variables, functions, if statements). You also need a GitHub account, Docker installed, and VS Code installed on your machine.

Before We Start

Before you write any code, you need the right tools in place. This project uses three specialized tools that work together: ShellCheck catches Bash scripting mistakes before they become bugs, bats-core runs automated tests on your scripts, and nmap performs the network scanning your security tool will rely on.

In this step, you'll install all three tools, verify they work, and create a GitHub repository to house your project and its pipeline.

In this step, get ready to:
  • Install ShellCheck and nmap.
  • Install the bats-core testing framework.
  • Create a public GitHub repository and connect it locally.
Install ShellCheck and nmap

ShellCheck is a static analysis tool that finds bugs and bad patterns in Bash scripts. nmap is a network scanner that your security tool will use to probe target ports.

  • Open your terminal by pressing Super (the Windows key) and typing Terminal, then press Enter.
  • Install both tools by running this command:
sudo apt install shellcheck nmap

What does this command do?

sudo runs the command with administrator privileges. apt install is the package manager on Pop!_OS (and other Debian-based systems) that downloads and installs software from the official repositories.

Your terminal will ask for your password. Type it in (you won't see characters appear) and press Enter. If prompted to confirm the install, type Y and press Enter.

  • Verify ShellCheck installed correctly by running:
shellcheck --version
  • Verify nmap installed correctly by running:
nmap --version

You should see version information printed for each tool. The exact version numbers depend on your system's package repositories.

Command not found after installing?

If shellcheck or nmap is not found, scroll up in your terminal and check the output of the apt install command for error messages. A common fix is to update your package list first with sudo apt update, then retry the install.

Why can't my terminal find shellcheck or nmap?

Install bats-core

bats-core (Bash Automated Testing System) is a testing framework purpose-built for Bash scripts. Your CI/CD pipeline will use it to automatically verify that your scanner works correctly on every push.

Unlike ShellCheck and nmap, bats-core isn't available through apt. You'll install it from source by cloning its repository and running the included install script.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the bats-core repository by running:
git clone https://github.com/bats-core/bats-core.git
  • Install bats-core system-wide by running:
cd bats-core
sudo ./install.sh /usr/local

What does this install script do?

install.sh /usr/local copies the bats executable into /usr/local/bin so you can run bats from any directory on your system.

  • Navigate back to your Desktop by running:
cd ~/Desktop
  • Verify bats-core installed correctly by running:
bats --version

You should see Bats followed by a version number printed in your terminal.

bats: command not found?

Make sure you ran the install script with sudo. Without it, the script doesn't have permission to copy files into /usr/local/bin.

Also confirm you ran the install from inside the bats-core directory. If you're unsure, run cd ~/Desktop/bats-core && sudo ./install.sh /usr/local again.

Help me troubleshoot my bats-core installation.

Create your project repository

Now you need a place to store your code and trigger your pipeline. You'll create a local project folder, initialize Git inside it, and connect it to a public repository on GitHub.

  • Create your project folder and initialize Git by running these commands:
mkdir netscan
cd netscan
git init

What do these commands do?

  • mkdir netscan creates a new folder called netscan on your Desktop.
  • cd netscan moves your terminal into that folder.
  • git init turns the folder into a Git repository so you can track changes.

Next, you'll create the matching repository on GitHub so your pipeline has somewhere to run.

  • In your browser, go to github.com.
  • Click the + icon in the top right corner.
  • Select New repository.
  • Enter netscan as the Repository name.
  • Select Public for visibility.
  • Leave all checkboxes unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why public?

GitHub Actions is free and unlimited for public repositories. A public repo also means employers can see your pipeline in action directly from your profile.

After creating the repo, GitHub shows a setup page with a URL. You'll use that URL to connect your local folder to GitHub.

  • Back in your terminal (still inside the netscan folder), connect your local repo to GitHub by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/netscan.git"]]
  • Create an initial commit and push it to GitHub by running:
git commit --allow-empty -m "Initial commit"
git branch -M main
git push -u origin main

What do these commands do?

  • git commit --allow-empty creates a commit even though there are no files yet. This gives you a starting point to push.
  • git branch -M main renames your default branch to main to match GitHub's convention.
  • git push -u origin main sends your commit to GitHub and sets origin main as the default push target.
  • Verify the connection by running:
git remote -v

You should see your GitHub URL listed twice (once for fetch, once for push). You can also refresh your repository page on GitHub to see the initial commit.

Push rejected or authentication failed?

If you see an authentication error, GitHub no longer accepts password logins from the terminal. You need either an SSH key or a personal access token configured. Check your GitHub authentication settings.

If the push is rejected because the remote already has commits (e.g. you accidentally initialized with a README), delete the repository on GitHub and recreate it empty.

Help me push my local repo to GitHub.

Your environment is ready. You have a linter, a test runner, and a scanner installed locally, plus a public GitHub repository waiting for code. Next up, you'll write the network security scanner itself.

Write the Network Security Scanner

Your local tools are installed and your repository is ready. Now it is time to write the actual security scanner that your pipeline will protect.

The scanner is a Bash script called netscan.sh that takes a domain as input and produces a security report. It uses real tools that DevSecOps engineers rely on daily: nmap for port scanning, openssl for certificate analysis, curl for header inspection, and the host command for DNS lookups.

In this step, get ready to:
  • Create the script skeleton with argument parsing and help text.
  • Implement four scan functions for ports, SSL, headers, and DNS.
  • Run ShellCheck locally, make the script executable, and push to GitHub.
Create the script skeleton

Every production Bash script starts with a clear structure: a shebang line telling the system which interpreter to use, strict mode to catch errors early, and a help message so users know what flags are available.

  • Create netscan.sh in your project directory by running:
touch netscan.sh
  • Open netscan.sh in VS Code.
  • Add the script header, help text, and version function by pasting this code:
#!/usr/bin/env bash
set -euo pipefail

VERSION="1.0.0"

# Display usage information and available options
usage() {
    cat <<EOF
netscan - Network Security Scanner

Usage: netscan.sh [OPTIONS]

Options:
    --help              Show this help message
    --version           Show version information
    --target DOMAIN     Target domain to scan (required for scans)
    --scan TYPE         Scan type: ports, ssl, headers, dns, or all (default: all)

Scan Types:
    ports       Scan top 20 common ports using nmap
    ssl         Check SSL/TLS certificate expiry
    headers     Inspect HTTP security headers
    dns         Look up DNS records (A, MX, NS)
    all         Run all scans

Examples:
    netscan.sh --target example.com --scan all
    netscan.sh --target example.com --scan ssl
EOF
}

# Print the current version
show_version() {
    echo "netscan version ${VERSION}"
}

What does this code do?

The shebang #!/usr/bin/env bash tells the OS to run this file with Bash, regardless of where Bash is installed.

The line set -euo pipefail enables strict mode. -e exits on any error, -u treats unset variables as errors, and -o pipefail catches failures inside piped commands. This prevents bugs from silently snowballing.

The usage() function uses a heredoc to print structured help text when the user passes --help.

You will not see any output yet. The next chunk adds the main() function that ties everything together.

  • Below show_version(), add the main function that handles argument parsing and dispatches scans:
# Entry point: parse arguments and dispatch to scan functions
main() {
    local target=""
    local scan_type="all"

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --help)
                usage
                exit 0
                ;;
            --version)
                show_version
                exit 0
                ;;
            --target)
                if [[ $# -lt 2 ]]; then
                    echo "Error: --target requires a domain argument" >&2
                    exit 1
                fi
                target="$2"
                shift 2
                ;;
            --scan)
                if [[ $# -lt 2 ]]; then
                    echo "Error: --scan requires a type argument" >&2
                    exit 1
                fi
                scan_type="$2"
                shift 2
                ;;
            *)
                echo "Error: Unknown option '$1'" >&2
                echo "Run 'netscan.sh --help' for usage information." >&2
                exit 1
                ;;
        esac
    done

    if [[ -z "$target" && "$scan_type" != "" ]]; then
        if [[ "${scan_type}" == "all" || -n "${scan_type}" ]]; then
            echo "Error: --target is required for scanning" >&2
            echo "Run 'netscan.sh --help' for usage information." >&2
            exit 1
        fi
    fi

    echo "========================================"
    echo " Network Security Report"
    echo " Target: ${target}"
    echo " Date: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "========================================"
    echo ""

    case "${scan_type}" in
        ports)   scan_ports "$target" ;;
        ssl)     scan_ssl "$target" ;;
        headers) scan_headers "$target" ;;
        dns)     scan_dns "$target" ;;
        all)
            scan_ports "$target"
            scan_ssl "$target"
            scan_headers "$target"
            scan_dns "$target"
            ;;
        *)
            echo "Error: Unknown scan type '${scan_type}'" >&2
            echo "Valid types: ports, ssl, headers, dns, all" >&2
            exit 1
            ;;
    esac
}

main "$@"

What does this code do?

The main() function uses a while loop with a case statement to parse command-line flags. Each flag (--help, --version, --target, --scan) has its own handler. Unknown flags trigger an error.

Before running any scan, it validates that --target was provided. Then it prints a report header and uses a second case to dispatch to the correct scan function.

The final line main "$@" calls the function with all script arguments. This pattern keeps everything inside functions, which is cleaner and easier to test.

  • Save netscan.sh.
  • Verify the argument parsing works by running:
bash netscan.sh --help

You should see the full help text printed to your terminal, starting with netscan - Network Security Scanner.

Not seeing the help text?

  • Check that main "$@" is the very last line in the file. Without it, nothing runs.
  • Make sure the heredoc delimiter EOF at the end of the usage function starts at column 1 (no leading spaces).
  • If you see a syntax error, verify that every ;; in the case statement is present.

Help me debug my netscan.sh argument parsing.

Implement the scan functions

Now you will add the four scan functions that do the real security work. Each function takes a domain as its argument and produces formatted output. You will add these between show_version() and main() in netscan.sh.

  • In netscan.sh, find the closing brace of show_version() and add the port scanning function below it:
# Scan the top 20 most common ports using nmap
scan_ports() {
    local target="$1"
    echo "=== Port Scan (Top 20) ==="
    echo "Target: ${target}"
    echo ""
    nmap -Pn --top-ports 20 "$target" 2>/dev/null || echo "Error: nmap scan failed"
    echo ""
}

What does this code do?

The -Pn flag tells nmap to skip host discovery (assume the host is up). This is important because some networks and CI environments block ICMP ping packets.

The --top-ports 20 flag scans the 20 most commonly open ports (like 22/SSH, 80/HTTP, 443/HTTPS). Scanning all 65,535 ports would take minutes. Twenty gives a fast, useful snapshot.

  • Save netscan.sh and verify the port scan works by running:
bash netscan.sh --target example.com --scan ports

You should see a report header followed by nmap output listing ports and their states (open, closed, or filtered).

Seeing "Error: nmap scan failed"?

  • Confirm nmap is installed by running nmap --version.
  • If you are behind a restrictive firewall, try a different target like scanme.nmap.org.

Help me debug my nmap port scan function.

  • Below scan_ports(), add the SSL certificate checking function:
# Check SSL/TLS certificate expiry using openssl
scan_ssl() {
    local target="$1"
    echo "=== SSL/TLS Certificate Check ==="
    echo "Target: ${target}:443"
    echo ""

    local expiry_str
    expiry_str=$(openssl s_client -connect "${target}:443" -servername "$target" \
        </dev/null 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null \
        | cut -d= -f2)

    if [[ -z "$expiry_str" ]]; then
        echo "Error: Could not retrieve SSL certificate"
        echo ""
        return 1
    fi

    local expiry_epoch now_epoch days_left
    expiry_epoch=$(date -d "$expiry_str" +%s)
    now_epoch=$(date +%s)
    days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

    echo "Certificate expires: ${expiry_str}"
    echo "Days remaining: ${days_left}"

    if [[ $days_left -lt 0 ]]; then
        echo "Status: EXPIRED"
    elif [[ $days_left -lt 30 ]]; then
        echo "Status: WARNING (expires within 30 days)"
    else
        echo "Status: OK"
    fi
    echo ""
}

What does this code do?

This function connects to port 443, retrieves the SSL certificate, and extracts the expiry date. The -servername flag is required for SNI (Server Name Indication) so that shared-hosting servers return the correct certificate.

It then converts the expiry date to epoch seconds and calculates how many days remain. Certificates expiring in under 30 days get a WARNING status. Expired certificates are flagged as EXPIRED.

  • Save netscan.sh and verify the SSL scan by running:
bash netscan.sh --target example.com --scan ssl

You should see the certificate expiry date, days remaining, and a status of OK (example.com's certificate is renewed well before expiry).

Seeing "Error: Could not retrieve SSL certificate"?

  • Make sure openssl is installed (it should be on Pop!_OS by default).
  • Verify you can reach the target on port 443. Try openssl s_client -connect example.com:443 </dev/null directly.

Help me debug my SSL certificate scan function.

  • Below scan_ssl(), add the HTTP security headers function:
# Inspect HTTP security headers using curl
scan_headers() {
    local target="$1"
    echo "=== HTTP Security Headers ==="
    echo "Target: https://${target}"
    echo ""

    local headers
    headers=$(curl -sI -m 10 "https://${target}" 2>/dev/null) || {
        echo "Error: Could not connect to https://${target}"
        echo ""
        return 1
    }

    local -a check_headers=("Strict-Transport-Security" "X-Frame-Options" "X-Content-Type-Options" "Content-Security-Policy")

    for header in "${check_headers[@]}"; do
        if echo "$headers" | grep -qi "^${header}:"; then
            local value
            value=$(echo "$headers" | grep -i "^${header}:" | head -1 | cut -d: -f2- | xargs)
            echo "[PASS] ${header}: ${value}"
        else
            echo "[MISSING] ${header}"
        fi
    done
    echo ""
}

What does this code do?

The curl -sI -m 10 fetches only the HTTP response headers (-I), silently (-s), with a 10-second timeout (-m 10).

It then loops through four critical security headers. Strict-Transport-Security enforces HTTPS. X-Frame-Options prevents clickjacking. X-Content-Type-Options stops MIME sniffing. Content-Security-Policy controls which resources can load. Each header is marked PASS or MISSING.

  • Save netscan.sh and verify the headers scan by running:
bash netscan.sh --target example.com --scan headers

You should see each header marked as [PASS] or [MISSING]. Most sites will have some headers missing.

Seeing "Error: Could not connect"?

  • Confirm you have internet access by running curl -I https://example.com directly.
  • If you are on a restricted network, try a different domain that you know is reachable.

Help me debug my HTTP headers scan function.

  • Below scan_headers(), add the DNS lookup function:
# Look up DNS records using the host command
scan_dns() {
    local target="$1"
    echo "=== DNS Records ==="
    echo "Target: ${target}"
    echo ""

    echo "-- A Records --"
    host -t A "$target" 2>/dev/null || echo "No A records found"
    echo ""

    echo "-- MX Records --"
    host -t MX "$target" 2>/dev/null || echo "No MX records found"
    echo ""

    echo "-- NS Records --"
    host -t NS "$target" 2>/dev/null || echo "No NS records found"
    echo ""
}

What does this code do?

The host command performs DNS lookups. -t A finds the IP address, -t MX finds mail servers, and -t NS finds the nameservers responsible for the domain.

DNS records reveal important security information. If nameservers are misconfigured or mail records point to unexpected hosts, it could indicate a hijacking attempt or misconfiguration.

  • Save netscan.sh and verify the DNS scan by running:
bash netscan.sh --target example.com --scan dns

You should see A, MX, and NS records for example.com listed under separate headings.

Seeing "No A records found" for everything?

  • The host command comes from the bind9-dnsutils package on Ubuntu/Pop!_OS. Install it with sudo apt install bind9-dnsutils if missing.
  • Make sure you can resolve DNS generally by running host google.com.

Help me debug my DNS scan function.

Lint, make executable, and commit

Before committing, you will run ShellCheck to catch any Bash pitfalls. ShellCheck performs static analysis on your script, flagging issues like unquoted variables, incorrect redirections, and potential bugs that Bash would silently ignore.

  • Run ShellCheck against your script:
shellcheck netscan.sh

You should see no output at all. No output from ShellCheck means zero warnings.

What if ShellCheck shows warnings?

ShellCheck labels each warning with a code like SC2086 (unquoted variable) or SC2034 (unused variable). Read the message carefully. It tells you the exact line number and what to fix.

The code in this project is already written to pass ShellCheck cleanly. If you see warnings, double-check your code matches the reference below.

  • Make the script executable by running:
chmod +x netscan.sh
  • Commit and push your changes by running:
git add netscan.sh
git commit -m "Add network security scanner with four scan modules"
git push
  • Verify the push succeeded by checking your GitHub repository page. You should see netscan.sh listed in the file tree.

Push rejected or permission denied?

  • Make sure your remote URL is set correctly by running git remote -v.
  • If using HTTPS, you may need a personal access token instead of a password. If using SSH, confirm your key is added to your GitHub account.

Help me push my code to GitHub.

✔️ Awesome, I've got everything!

Great. Double check you have saved netscan.sh and pushed to GitHub.

ⓧ I'd like to double check the full code

Here is the complete netscan.sh file. Compare yours against it:

#!/usr/bin/env bash
set -euo pipefail

VERSION="1.0.0"

usage() {
    cat <<EOF
netscan - Network Security Scanner

Usage: netscan.sh [OPTIONS]

Options:
    --help              Show this help message
    --version           Show version information
    --target DOMAIN     Target domain to scan (required for scans)
    --scan TYPE         Scan type: ports, ssl, headers, dns, or all (default: all)

Scan Types:
    ports       Scan top 20 common ports using nmap
    ssl         Check SSL/TLS certificate expiry
    headers     Inspect HTTP security headers
    dns         Look up DNS records (A, MX, NS)
    all         Run all scans

Examples:
    netscan.sh --target example.com --scan all
    netscan.sh --target example.com --scan ssl
EOF
}

show_version() {
    echo "netscan version ${VERSION}"
}

scan_ports() {
    local target="$1"
    echo "=== Port Scan (Top 20) ==="
    echo "Target: ${target}"
    echo ""
    nmap -Pn --top-ports 20 "$target" 2>/dev/null || echo "Error: nmap scan failed"
    echo ""
}

scan_ssl() {
    local target="$1"
    echo "=== SSL/TLS Certificate Check ==="
    echo "Target: ${target}:443"
    echo ""

    local expiry_str
    expiry_str=$(openssl s_client -connect "${target}:443" -servername "$target" \
        </dev/null 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null \
        | cut -d= -f2)

    if [[ -z "$expiry_str" ]]; then
        echo "Error: Could not retrieve SSL certificate"
        echo ""
        return 1
    fi

    local expiry_epoch now_epoch days_left
    expiry_epoch=$(date -d "$expiry_str" +%s)
    now_epoch=$(date +%s)
    days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

    echo "Certificate expires: ${expiry_str}"
    echo "Days remaining: ${days_left}"

    if [[ $days_left -lt 0 ]]; then
        echo "Status: EXPIRED"
    elif [[ $days_left -lt 30 ]]; then
        echo "Status: WARNING (expires within 30 days)"
    else
        echo "Status: OK"
    fi
    echo ""
}

scan_headers() {
    local target="$1"
    echo "=== HTTP Security Headers ==="
    echo "Target: https://${target}"
    echo ""

    local headers
    headers=$(curl -sI -m 10 "https://${target}" 2>/dev/null) || {
        echo "Error: Could not connect to https://${target}"
        echo ""
        return 1
    }

    local -a check_headers=("Strict-Transport-Security" "X-Frame-Options" "X-Content-Type-Options" "Content-Security-Policy")

    for header in "${check_headers[@]}"; do
        if echo "$headers" | grep -qi "^${header}:"; then
            local value
            value=$(echo "$headers" | grep -i "^${header}:" | head -1 | cut -d: -f2- | xargs)
            echo "[PASS] ${header}: ${value}"
        else
            echo "[MISSING] ${header}"
        fi
    done
    echo ""
}

scan_dns() {
    local target="$1"
    echo "=== DNS Records ==="
    echo "Target: ${target}"
    echo ""

    echo "-- A Records --"
    host -t A "$target" 2>/dev/null || echo "No A records found"
    echo ""

    echo "-- MX Records --"
    host -t MX "$target" 2>/dev/null || echo "No MX records found"
    echo ""

    echo "-- NS Records --"
    host -t NS "$target" 2>/dev/null || echo "No NS records found"
    echo ""
}

main() {
    local target=""
    local scan_type="all"

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --help)
                usage
                exit 0
                ;;
            --version)
                show_version
                exit 0
                ;;
            --target)
                if [[ $# -lt 2 ]]; then
                    echo "Error: --target requires a domain argument" >&2
                    exit 1
                fi
                target="$2"
                shift 2
                ;;
            --scan)
                if [[ $# -lt 2 ]]; then
                    echo "Error: --scan requires a type argument" >&2
                    exit 1
                fi
                scan_type="$2"
                shift 2
                ;;
            *)
                echo "Error: Unknown option '$1'" >&2
                echo "Run 'netscan.sh --help' for usage information." >&2
                exit 1
                ;;
        esac
    done

    if [[ -z "$target" && "$scan_type" != "" ]]; then
        if [[ "${scan_type}" == "all" || -n "${scan_type}" ]]; then
            echo "Error: --target is required for scanning" >&2
            echo "Run 'netscan.sh --help' for usage information." >&2
            exit 1
        fi
    fi

    echo "========================================"
    echo " Network Security Report"
    echo " Target: ${target}"
    echo " Date: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "========================================"
    echo ""

    case "${scan_type}" in
        ports)   scan_ports "$target" ;;
        ssl)     scan_ssl "$target" ;;
        headers) scan_headers "$target" ;;
        dns)     scan_dns "$target" ;;
        all)
            scan_ports "$target"
            scan_ssl "$target"
            scan_headers "$target"
            scan_dns "$target"
            ;;
        *)
            echo "Error: Unknown scan type '${scan_type}'" >&2
            echo "Valid types: ports, ssl, headers, dns, all" >&2
            exit 1
            ;;
    esac
}

main "$@"

Your scanner is written, linted, and live on GitHub. Next up, you will write automated tests that prove your scanner handles arguments correctly and produces real output.

Write Automated Tests

Your scanner is written and passes ShellCheck's linting with zero warnings. But linting only catches syntax issues and bad patterns. It cannot tell you whether your scanner actually behaves correctly when given real arguments.

In this step, you'll write automated tests using bats-core (the Bash Automated Testing System). These tests validate that your scanner handles flags correctly, rejects bad input, and produces the expected output.

In this step, get ready to:
  • Write a test suite for netscan.sh using bats-core.
  • Run the tests locally and verify they all pass.
  • Commit and push your test file to GitHub.
Create the test directory and test file

Tests live in a separate test/ directory, keeping them organized and isolated from your application code.

  • Create the test directory by running this command:
mkdir test
  • Confirm the directory exists by running:
ls

You should see test listed alongside netscan.sh in the output.

  • In VS Code, create a new file called netscan.bats inside the test/ directory.
  • Paste the following code into test/netscan.bats:
#!/usr/bin/env bats

setup() {
    PROJECT_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
    SCRIPT="${PROJECT_ROOT}/netscan.sh"
}

@test "netscan --help prints usage information" {
    run bash "$SCRIPT" --help
    [ "$status" -eq 0 ]
    [[ "$output" == *"Usage:"* ]]
}

@test "netscan --version prints version" {
    run bash "$SCRIPT" --version
    [ "$status" -eq 0 ]
    [[ "$output" == *"netscan version"* ]]
}

@test "netscan fails without --target" {
    run bash "$SCRIPT" --scan ssl
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan fails on unknown option" {
    run bash "$SCRIPT" --invalid-flag
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan fails on unknown scan type" {
    run bash "$SCRIPT" --target example.com --scan bogus
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan --target without value fails" {
    run bash "$SCRIPT" --target
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan --scan without value fails" {
    run bash "$SCRIPT" --scan
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan ssl scan produces certificate output" {
    run bash "$SCRIPT" --target example.com --scan ssl
    [ "$status" -eq 0 ]
    [[ "$output" == *"SSL/TLS Certificate Check"* ]]
    [[ "$output" == *"Days remaining"* ]]
}

What does this test file do?

The setup() function runs before every test. It locates your project root and stores the path to netscan.sh in a variable so each test can reference it.

The first seven tests validate argument parsing. They check that --help prints usage, --version prints the version, missing flags produce errors, and invalid options are rejected. None of these tests touch the network.

The final test is an integration test. It connects to example.com over the internet and verifies that the SSL scan produces real certificate output.

Why example.com for the integration test?

The domain example.com is maintained by IANA specifically for use in documentation and testing. It is always available, always has a valid SSL certificate, and will never go offline. This makes it the safest target for automated tests that hit the network.

  • Save test/netscan.bats.

File not saving in the right place?

Make sure the file is at test/netscan.bats (inside the test folder), not at the project root. In VS Code, right-click the test folder in the Explorer sidebar and select New File to place it correctly.

help me fix my test file location

Run the tests locally

Before relying on a CI pipeline to run your tests, you should always run them locally first. This gives you immediate feedback and lets you fix problems before pushing.

  • Run the full test suite by running this command:
bats test/

You should see output showing all 8 tests passing with green checkmarks. The last test (SSL scan) takes a few seconds because it connects to the internet.

Seeing test failures?

If the SSL test fails, check that you have an active internet connection. This test connects to example.com over port 443.

If argument-parsing tests fail, make sure netscan.sh is executable. Run chmod +x netscan.sh and try again.

help me debug my failing bats tests

Understand the test output

The output from bats follows a standard called TAP (Test Anything Protocol). Understanding this format helps you quickly diagnose which tests pass or fail in your CI pipeline later.

How does TAP output work?

TAP output starts with a plan line like 1..8 indicating how many tests will run. Each test result is then printed as ok (pass) or not ok (fail) followed by the test number and description.

In your terminal, bats renders this with colours and checkmarks for readability. In CI environments, the raw TAP text is what gets parsed by the pipeline to determine pass/fail status.

How do bats assertions work?

Each test runs with set -e enabled automatically. This means any command that returns a non-zero exit code causes the test to fail immediately.

The run keyword captures both the exit code ($status) and the printed text ($output) of a command without letting a non-zero exit kill the test early. You then use standard shell comparisons like [ "$status" -eq 0 ] to assert what you expect.

  • Commit and push your test file by running these commands:
git add test/netscan.bats
git commit -m "Add bats-core test suite"
git push

What does this do?

This stages your new test file, commits it with a descriptive message, and pushes to your remote GitHub repository. Your test suite is now part of your project history and available for any CI pipeline to run.

✔️ Awesome, I've got everything!

Great. Double check you've saved test/netscan.bats and that all 8 tests pass when you run bats test/.

ⓧ I'd like to double check the full code

#!/usr/bin/env bats

setup() {
    PROJECT_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
    SCRIPT="${PROJECT_ROOT}/netscan.sh"
}

@test "netscan --help prints usage information" {
    run bash "$SCRIPT" --help
    [ "$status" -eq 0 ]
    [[ "$output" == *"Usage:"* ]]
}

@test "netscan --version prints version" {
    run bash "$SCRIPT" --version
    [ "$status" -eq 0 ]
    [[ "$output" == *"netscan version"* ]]
}

@test "netscan fails without --target" {
    run bash "$SCRIPT" --scan ssl
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan fails on unknown option" {
    run bash "$SCRIPT" --invalid-flag
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan fails on unknown scan type" {
    run bash "$SCRIPT" --target example.com --scan bogus
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan --target without value fails" {
    run bash "$SCRIPT" --target
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan --scan without value fails" {
    run bash "$SCRIPT" --scan
    [ "$status" -eq 1 ]
    [[ "$output" == *"Error:"* ]]
}

@test "netscan ssl scan produces certificate output" {
    run bash "$SCRIPT" --target example.com --scan ssl
    [ "$status" -eq 0 ]
    [[ "$output" == *"SSL/TLS Certificate Check"* ]]
    [[ "$output" == *"Days remaining"* ]]
}

Your scanner now has a proper test suite that verifies it handles every input scenario correctly. Next up, you'll package the scanner into a Docker container so it can run on any machine without installing dependencies manually.

Containerize with Docker

Your scanner works on your machine, but what happens when you want to run it on a server, in a CI pipeline, or on a teammate's laptop?

Each of those environments has different packages installed, different versions of nmap and openssl, and different OS configurations. In this step, you will package your scanner into a Docker container so it runs identically everywhere.

In this step, get ready to:
  • Write a Dockerfile that packages your scanner with all its dependencies.
  • Build a Docker image locally and run the container.
  • Verify the containerized scanner produces a real SSL certificate report.
Create the Dockerfile

A Dockerfile is a recipe that tells Docker exactly what to put inside your container image. You start from a base OS, install the tools your script needs, copy your script in, and declare how to run it.

  • In VS Code, create a new file called Dockerfile in the root of your project folder (the same level as netscan.sh).
  • Add the following content to Dockerfile:
# Start from Alpine Linux - a minimal 5MB base image
FROM alpine:3.21

# Install all the tools netscan.sh depends on
RUN apk add --no-cache bash nmap openssl curl bind-tools

# Copy the scanner script into the container
COPY netscan.sh /usr/local/bin/netscan.sh
RUN chmod +x /usr/local/bin/netscan.sh

# Set the scanner as the default command when the container starts
ENTRYPOINT ["/usr/local/bin/netscan.sh"]

What does this Dockerfile do?

FROM alpine:3.21 picks Alpine Linux as the base. Alpine is about 5 MB, which keeps your final image small and reduces the attack surface.

RUN apk add --no-cache installs the five packages your scanner needs: bash (Alpine defaults to sh), nmap, openssl, curl, and bind-tools (which provides the host command for DNS lookups).

ENTRYPOINT means any arguments you pass to docker run get forwarded directly to netscan.sh. So docker run netscan --target example.com --scan ssl runs the scanner as if you typed netscan.sh --target example.com --scan ssl directly.

  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile in the output.

Don't see the file?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension.
  • Check that your terminal is in the project root directory (the same folder as netscan.sh).

Help me troubleshoot my Dockerfile not appearing in the project directory.

Why Alpine instead of Ubuntu?

Ubuntu base images are around 75 MB. Alpine is about 5 MB. For a CLI tool that only needs a handful of packages, Alpine gives you everything you need in a fraction of the size.

Smaller images mean faster pulls in CI, less storage, and fewer packages that could contain vulnerabilities.

Build the Docker image

Building an image turns your Dockerfile recipe into a runnable snapshot. The -t flag gives the image a name (tag) so you can reference it later.

  • Build your Docker image by running this command:
docker build -t netscan .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create an image.
  • -t netscan tags (names) the resulting image as netscan.
  • . tells Docker to use the current directory as the build context (where to find the Dockerfile and files to copy).

You should see Docker pulling the Alpine image, installing packages, and finishing with a line like Successfully tagged netscan:latest. The first build takes a minute or two while Docker downloads Alpine and the packages.

Permission denied or daemon not running?

  • If you see a permission error, make sure Docker is running. On Pop!_OS, try starting it with sudo systemctl start docker.
  • If your user is not in the docker group, prefix the command with sudo or add yourself with sudo usermod -aG docker $USER (then log out and back in).

Help me fix my Docker build issue.

Run the container and verify the output

The real test is running the container and confirming it produces the same SSL report as your local script. This proves the container has everything it needs to work in any environment.

  • Run the containerized scanner against example.com by running this command:
docker run --rm netscan --target example.com --scan ssl

What does this command do?

  • docker run starts a new container from the netscan image.
  • --rm automatically removes the container after it finishes (keeps your system tidy).
  • Everything after the image name (--target example.com --scan ssl) gets passed to the ENTRYPOINT, which is your netscan.sh script.

You should see a full SSL/TLS Certificate Check report including the certificate expiry date, days remaining, and a status of OK.

Seeing an error about connecting?

  • If you see a network error, check your internet connection. The container needs outbound access to reach example.com on port 443.
  • If you see a command not found error inside the container, double check that your Dockerfile has the apk add line with all five packages.

Help me fix my Docker container network or command error.

  • Commit and push the Dockerfile by running these commands:
git add Dockerfile
git commit -m "Add Dockerfile for containerized scanner"
git push

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM alpine:3.21

RUN apk add --no-cache bash nmap openssl curl bind-tools

COPY netscan.sh /usr/local/bin/netscan.sh
RUN chmod +x /usr/local/bin/netscan.sh

ENTRYPOINT ["/usr/local/bin/netscan.sh"]

Your scanner is now fully portable. Anyone with Docker installed can pull your image and run a security scan without installing nmap, openssl, or any other dependency. Next up, you will automate everything with a CI/CD pipeline that lints, tests, and builds on every push.

Build the CI/CD Pipeline

Your scanner is containerized and ready to ship. You've proven locally that it passes ShellCheck, passes all tests, and builds into a Docker image. But right now, the only person running those checks is you.

A CI/CD pipeline automates those same checks on every push so bugs never slip through because someone forgot to lint or test. In this step, you'll create a GitHub Actions workflow that lints, tests, and builds your scanner automatically whenever code reaches GitHub.

In this step, get ready to:
  • Create a GitHub Actions workflow file that automates your pipeline.
  • Push your code and verify the pipeline passes on GitHub.
  • Break something on purpose to prove the pipeline catches errors.
Create the workflow file

GitHub Actions looks for workflow files in a specific directory: .github/workflows/. Any YAML file placed there becomes a pipeline that GitHub can run.

  • Create the required directory structure by running this command:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github/workflows

The command should run without error. The directory is empty for now since you haven't added a workflow file yet.

  • Create a new file called ci.yml inside .github/workflows/ and add the following content:
name: CI Pipeline

on: [push]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Install bats-core
        run: |
          git clone https://github.com/bats-core/bats-core.git
          cd bats-core
          sudo ./install.sh /usr/local

      - name: Lint with ShellCheck
        run: shellcheck netscan.sh

      - name: Run tests
        run: bats test/

      - name: Build Docker image
        run: docker build -t netscan .

What does this workflow do?

  • on: [push] tells GitHub to run this pipeline every time you push code to any branch.
  • runs-on: ubuntu-latest spins up a fresh Ubuntu 24.04 virtual machine for each run. ShellCheck is pre-installed on this runner, so you don't need an install step for it.
  • actions/checkout@v6 copies your repository code into the runner so subsequent steps can access it.
  • The steps run in sequence: install bats-core, lint, test, then build. If any step fails, the pipeline stops and reports a red X.

Why does bats-core need installing but ShellCheck doesn't?

GitHub's ubuntu-latest runner ships with ShellCheck pre-installed. However, bats-core is not included, so the workflow clones and installs it the same way you did locally.

  • Save .github/workflows/ci.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

name: CI Pipeline

on: [push]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Install bats-core
        run: |
          git clone https://github.com/bats-core/bats-core.git
          cd bats-core
          sudo ./install.sh /usr/local

      - name: Lint with ShellCheck
        run: shellcheck netscan.sh

      - name: Run tests
        run: bats test/

      - name: Build Docker image
        run: docker build -t netscan .

YAML indentation errors?

YAML is whitespace-sensitive. Each indentation level uses exactly 2 spaces (not tabs). If your file looks right but the pipeline fails to parse, check that your editor is set to insert spaces instead of tabs.

help me fix YAML indentation in my ci.yml file

Push and watch the pipeline run

The moment you push this workflow file to GitHub, Actions will detect it and start running your pipeline automatically.

  • Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI pipeline workflow"
git push
  • Open your repository on GitHub in your browser.
  • Click Actions in the tab bar under your repository name.
  • Click CI Pipeline in the left sidebar.
  • Click the run named Add CI pipeline workflow (your commit message) to open the run summary.
  • Under Jobs, click the ci job to watch each step execute in sequence.

Wait for all steps to complete. You should see a green checkmark next to each step and the overall workflow run.

What should I see?

Each step in the job (Checkout code, Install bats-core, Lint with ShellCheck, Run tests, Build Docker image) shows a green checkmark icon when it passes. The overall run status at the top also displays a green checkmark.

If you expand the Run tests step, you'll see bats-core's TAP output with your 8 passing tests.

Pipeline stuck or showing a yellow dot?

A yellow dot means the run is still in progress. GitHub Actions runners can take 10-30 seconds to start. Wait a minute and refresh the page.

If the run fails on the Install bats-core step, make sure the run: | line uses a pipe character so the commands run as a multi-line script.

help me debug my GitHub Actions pipeline failure

Break the pipeline on purpose

A pipeline that only ever passes doesn't prove much. The real value shows up when it catches mistakes before they reach production.

  • Open netscan.sh in VS Code.
  • Find this line near the top of the file:
VERSION="1.0.0"
  • Add this line directly below it (notice the missing quotes around $VERSION):
echo $VERSION

Why will this break ShellCheck?

ShellCheck flags unquoted variable expansions because they can cause unexpected behaviour with filenames containing spaces or special characters. Writing echo $VERSION instead of echo "$VERSION" triggers a warning, and any ShellCheck finding makes the lint step exit with a non-zero code.

  • Save netscan.sh, then commit and push by running:
git add netscan.sh
git commit -m "Introduce ShellCheck warning on purpose"
git push
  • Return to the Actions tab on GitHub.
  • Click into the latest run. You should see a red X on the Lint with ShellCheck step.
  • Expand the failed step to see ShellCheck's error message pointing at your unquoted variable.

The pipeline caught the problem before anyone could use the broken code. Now fix it.

  • In netscan.sh, delete the echo $VERSION line you added.
  • Save netscan.sh, then commit and push by running:
git add netscan.sh
git commit -m "Fix ShellCheck warning"
git push
  • Return to the Actions tab. Click the latest run and confirm the green checkmark is back on all steps.

What just happened?

You just experienced the full CI/CD feedback loop: push broken code, get immediate feedback (red X), fix the issue, push again, and confirm the pipeline is green. This is exactly how professional teams use pipelines to prevent bugs from reaching production.

Still seeing a red X after fixing?

Make sure you actually deleted the echo $VERSION line and saved the file before committing. Run shellcheck netscan.sh locally first to confirm no warnings remain.

help me figure out why my pipeline is still failing after I removed the bad line

Your pipeline is live and proven. Every future push to this repository will be automatically linted, tested, and built without any manual effort. In the Secret Mission, you'll take this further by splitting the pipeline into independent stages with dependency ordering.

Secret mission

Add a Multi-Stage Pipeline with Job Dependencies

Your pipeline works, but it runs everything in a single job. Real production pipelines split responsibilities into independent stages that only proceed when their dependencies pass. Can you refactor your workflow into three separate jobs with explicit dependency ordering?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses GitHub Actions on a public repository, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project directory with netscan.sh, tests, Dockerfile, and CI workflow.
  • Public GitHub repository with GitHub Actions pipeline.
  • Local Docker image (netscan).
  • ShellCheck (installed via apt).
  • nmap (installed via apt).
  • bats-core (installed from source to /usr/local).

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend the scanner with new scan types.

  • Your project directory, Docker image, and GitHub repository stay as they are.
  • ShellCheck, nmap, and bats-core remain installed on your system. These are lightweight tools useful for other Bash projects.

Pause - I'll come back to this later

Free up disk space from the Docker image while keeping your code intact.

  • Remove the local Docker image by running:
docker rmi netscan
  • Your project directory, GitHub repository, and installed tools remain unchanged.
  • You can rebuild the Docker image any time with docker build -t netscan . from your project folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the local Docker image by running:
docker rmi netscan
  • Delete your project directory by running:
cd ~/Desktop
rm -rf netscan
  • Uninstall bats-core using its official uninstall script. Navigate to your cloned bats-core directory and run:
cd ~/Desktop/bats-core
sudo ./uninstall.sh /usr/local
  • Remove the cloned bats-core repository by running:
cd ~/Desktop
rm -rf bats-core
  • Remove ShellCheck and nmap by running:
sudo apt remove shellcheck nmap
  • Delete the GitHub repository. Open your repository on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Follow the confirmation prompts to complete the deletion.

Nice Work!

Nice Work!

Nice work! You have built a complete CI/CD pipeline around a network security scanner, proving that you can automate code quality and delivery from commit to container.

You've learned how to:

  • Build a Bash network security scanner that performs port scanning, SSL/TLS certificate analysis, DNS record lookups, and HTTP security header inspection.
  • Write automated tests with bats-core and package the scanner into a portable Docker container.
  • Create a GitHub Actions CI/CD pipeline that automatically lints, tests, and builds your scanner on every push.
  • Secret Mission: Structured the pipeline into independent stages with dependency ordering using the needs keyword, matching real-world DevOps practices.

Ready to quiz yourself?