# OiO SecOps — DNS deep scan
#
# Runs the DNS checks a browser physically cannot perform, because they need raw
# sockets or a stable baseline:
#
#   D02  zone transfer (AXFR) exposed
#   D04  open recursive resolver
#   D06  lame delegation, tested per nameserver
#   D07  nameserver set changed against a committed baseline
#   D42  server version disclosure (version.bind)
#   D43  unnecessary services listening on the DNS server
#
# Every other check in the catalog runs live in the browser over DNS-over-HTTPS.
#
# Setup:
#   1. Copy this file to .github/workflows/dns-deep-scan.yml
#   2. Set the domain, either as a workflow_dispatch input or as a repository
#      variable named DNS_DEEP_SCAN_DOMAIN
#   3. Commit the first run's nameserver list as dns-baseline.txt to enable D07

name: DNS deep scan

on:
  workflow_dispatch:
    inputs:
      domain:
        description: Registrable domain to scan
        required: true
        default: example.com
  schedule:
    # Weekly, Monday 06:17 UTC. Off the hour to avoid the scheduler's busy window.
    - cron: "17 6 * * 1"
  push:
    paths:
      - "dns-baseline.txt"

permissions:
  contents: read

env:
  DOMAIN: ${{ inputs.domain || vars.DNS_DEEP_SCAN_DOMAIN || 'example.com' }}

jobs:
  deep-scan:
    name: Deep scan
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install tooling
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq dnsutils nmap jq

      - name: Discover authoritative nameservers
        run: |
          set -euo pipefail
          dig +short NS "$DOMAIN" | sed 's/\.$//' | sort > nameservers.txt
          count=$(grep -c . nameservers.txt || true)
          echo "Nameservers for $DOMAIN: $count"
          cat nameservers.txt
          if [ "$count" -lt 2 ]; then
            echo "::warning title=Nameserver redundancy::Only $count nameserver published (D28/D44)"
          fi

      - name: Parent delegation comparison (D30)
        run: |
          set -uo pipefail
          : > parent-delegation.txt
          parent="${DOMAIN#*.}"
          if [ "$parent" = "$DOMAIN" ]; then
            echo "::notice title=No parent::Nothing above $DOMAIN to compare against"
          else
            parent_ns=$(dig +short NS "$parent" | sed 's/\.$//' | head -1)
            if [ -z "$parent_ns" ]; then
              echo "::warning title=Parent unreachable::Could not resolve a nameserver for $parent"
            else
              echo "Asking $parent_ns for the delegation of $DOMAIN"
              # The parent's answer is the set that actually governs resolution.
              dig +norecurse +time=5 +tries=1 "@$parent_ns" "$DOMAIN" NS +noall +answer \
                | sed 's/\.$//' | awk '{print $5}' | grep . | sort > parent-delegation.txt
              cat parent-delegation.txt
              if diff -q parent-delegation.txt nameservers.txt >/dev/null; then
                echo "Parent delegation matches the zone NS set."
              else
                echo "::error title=NS delegation mismatch::The parent delegates a different NS set than the zone serves (D30)"
                diff -u parent-delegation.txt nameservers.txt || true
              fi
            fi
          fi

      - name: Baseline diff for the nameserver set (D03, D07)
        run: |
          set -uo pipefail
          if [ -f dns-baseline.txt ]; then
            if diff -u dns-baseline.txt nameservers.txt > baseline.diff; then
              echo "Nameserver set matches the committed baseline."
              rm -f baseline.diff
            else
              echo "::error title=Nameserver set changed::The NS set differs from dns-baseline.txt (D07)"
              cat baseline.diff
            fi
          else
            echo "::notice title=No baseline::Commit this list as dns-baseline.txt to enable change detection"
            cat nameservers.txt
          fi

      - name: Zone transfer (D02)
        run: |
          set -uo pipefail
          : > axfr.txt
          while read -r ns; do
            [ -z "$ns" ] && continue
            echo "== AXFR @ $ns"
            out=$(dig +time=5 +tries=1 +noall +answer AXFR "@$ns" "$DOMAIN" 2>&1 || true)
            echo "$out"
            # A refused transfer prints only lines beginning with ';'.
            if echo "$out" | grep -qE '^[^;].*IN'; then
              echo "::error title=Zone transfer allowed::AXFR succeeded against $ns (D02)"
              echo "$ns: EXPOSED" >> axfr.txt
            else
              echo "$ns: refused" >> axfr.txt
            fi
          done < nameservers.txt
          cat axfr.txt

      - name: Open recursion (D04)
        run: |
          set -uo pipefail
          : > recursion.txt
          while read -r ns; do
            [ -z "$ns" ] && continue
            # Ask for a name this server is not authoritative for. Answering it
            # means the server resolves for the whole Internet.
            out=$(dig +time=5 +tries=1 "@$ns" www.google.com A 2>&1 || true)
            answers=$(echo "$out" | grep -cE '^[^;].*IN[[:space:]]+A' || true)
            echo "$ns: answers=$answers"
            if [ "$answers" -gt 0 ]; then
              echo "::error title=Open resolver::$ns answered a recursive query (D04)"
              echo "$ns: OPEN" >> recursion.txt
            else
              echo "$ns: refused" >> recursion.txt
            fi
          done < nameservers.txt
          cat recursion.txt

      - name: Lame delegation (D06)
        run: |
          set -uo pipefail
          : > lame.txt
          while read -r ns; do
            [ -z "$ns" ] && continue
            flags=$(dig +time=5 +tries=1 +norecurse "@$ns" "$DOMAIN" SOA 2>/dev/null \
              | awk -F'flags: ' '/flags:/{print $2; exit}')
            echo "$ns: flags=$flags"
            if ! echo "$flags" | grep -qw aa; then
              echo "::warning title=Lame delegation::$ns did not answer authoritatively for $DOMAIN (D06)"
              echo "$ns: NOT authoritative" >> lame.txt
            fi
          done < nameservers.txt
          if [ -s lame.txt ]; then cat lame.txt; else echo "All nameservers answered authoritatively."; fi

      - name: Version disclosure (D42)
        run: |
          set -uo pipefail
          : > version.txt
          while read -r ns; do
            [ -z "$ns" ] && continue
            for name in version.bind hostname.bind id.server; do
              out=$(dig +time=5 +tries=1 CH TXT "$name" "@$ns" +short 2>/dev/null | tr -d '"' || true)
              if [ -n "$out" ]; then
                echo "::warning title=Version disclosure::$ns answered CH TXT $name = $out (D42)"
                echo "$ns $name = $out" >> version.txt
              fi
            done
          done < nameservers.txt
          if [ -s version.txt ]; then cat version.txt; else echo "No version disclosure."; fi

      - name: Service exposure (D43)
        run: |
          set -uo pipefail
          : > services.txt
          while read -r ns; do
            [ -z "$ns" ] && continue
            ip=$(dig +short A "$ns" | head -1)
            [ -z "$ip" ] && continue
            echo "== $ns ($ip)" | tee -a services.txt
            # Only 53 and 443 belong on an authoritative server.
            sudo nmap -sT -Pn -n --open \
              -p 21,22,23,25,53,80,110,143,443,3306,5432,6379,8080,8443 "$ip" \
              | tee -a services.txt
            # UDP 53 recursion, cross-checking D04.
            sudo nmap -sU -Pn -n -p 53 --script dns-recursion "$ip" | tee -a services.txt
          done < nameservers.txt

      - name: Machine-readable results
        if: always()
        run: |
          jq -n \
            --arg domain "$DOMAIN" \
            --arg scannedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            --arg nameservers "$(paste -sd', ' nameservers.txt 2>/dev/null || echo '')" \
            --arg axfr "$(paste -sd'; ' axfr.txt 2>/dev/null || echo '')" \
            --arg recursion "$(paste -sd'; ' recursion.txt 2>/dev/null || echo '')" \
            --arg lameDelegation "$(paste -sd'; ' lame.txt 2>/dev/null || echo '')" \
            --arg versionDisclosure "$(paste -sd'; ' version.txt 2>/dev/null || echo '')" \
            '{domain:$domain, scannedAt:$scannedAt, nameservers:$nameservers, axfr:$axfr,
              recursion:$recursion, lameDelegation:$lameDelegation,
              versionDisclosure:$versionDisclosure}' \
            > deep-scan-results.json
          cat deep-scan-results.json

      - name: Run summary
        if: always()
        run: |
          {
            echo "## DNS deep scan — $DOMAIN"
            echo
            echo "### Nameservers"
            sed 's/^/- /' nameservers.txt 2>/dev/null || true
            echo
            echo "### Parent delegation (D30)"
            sed 's/^/- /' parent-delegation.txt 2>/dev/null || true
            echo
            echo "### Zone transfer (D02)"
            sed 's/^/- /' axfr.txt 2>/dev/null || true
            echo
            echo "### Open recursion (D04)"
            sed 's/^/- /' recursion.txt 2>/dev/null || true
            echo
            echo "### Lame delegation (D06)"
            if [ -s lame.txt ]; then sed 's/^/- /' lame.txt; else echo "- none"; fi
            echo
            echo "### Version disclosure (D42)"
            if [ -s version.txt ]; then sed 's/^/- /' version.txt; else echo "- none"; fi
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Upload evidence
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dns-deep-scan-evidence
          path: |
            nameservers.txt
            parent-delegation.txt
            axfr.txt
            recursion.txt
            lame.txt
            version.txt
            services.txt
            baseline.diff
            deep-scan-results.json
          if-no-files-found: ignore
          retention-days: 30
