local http = require "http"
local stdnse = require "stdnse"
local shortport = require "shortport"
local string = require "string"
local url = require "url"

description = [[
Tries to detect the presence of web application firewall and its type and version.

This works by sending a number of requests and looking in the responses for known behavior and fingerprints
such as Server header, cookies and headers values.
]]

---
-- @args http-waf-fingerprint.root The base path. Defaults to <code>/</code>.
--
-- @usage
-- nmap --script=http-waf-fingerprint <targets>
--
--@output
--PORT   STATE SERVICE REASON
--80/tcp open  http    syn-ack
--| http-waf-fingerprint: 
--|   Detected firewalls
--|_    BinarySec version 3.2.2

author = "Hani Benhabiles"
license = "Same as Nmap--See http://nmap.org/book/man-legal.html"
categories = {"discovery", "intrusive"}

--
-- Version 0.1:
-- - Initial version based on work done with wafw00f.
-- - Removed many false positives.
-- - Added fingeprints for WAFs such as Incapsula WAF, Cloudflare, USP-SES ,Cisco ACE XML Gateway and ModSecurity.
-- - Added fingerprints and version detection for Webknight and BinarySec, Citrix Netscaler and ModSecurity
--
--
-- TODO:    Fingerprints for other WAFs
--          Add intensive mode (WAF specific requests)
--

portrule = shortport.service("http")

-- Each WAF has a table with name, version and detected keys
-- as well as a match function.
-- HTTP Responses are passed to match function which will alter detected
-- and version values after analyzing responses if adequate fingerprints
-- are found.

local bigip
bigip = {
    name = "F5 BigIP",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            
            if response.header['x-cnection'] then
                stdnse.print_debug("%s BigIP detected through X-Cnection header.", SCRIPT_NAME)
                bigip.detected = true
                return
            end
            
            if response.header.server == 'BigIP' then -- 
                stdnse.print_debug("%s BigIP detected through Server header.", SCRIPT_NAME)
                bigip.detected = true
                return
            end
            
            for _, cookie in pairs(response.cookies) do --
                if string.find(cookie.name, "BIGipServer") then
                    stdnse.print_debug("%s BigIP detected through cookies.", SCRIPT_NAME)
                    bigip.detected = true
                    return
                end
                -- Application Security Manager module
                if string.match(cookie.name, 'TS%w+') and string.len(cookie.name) <= 8 then
                    stdnse.print_debug("%s F5 ASM detected through cookies.", SCRIPT_NAME)
                    bigip.detected = true
                    return
                end
            end
        end
    end,
}

local webknight
webknight = {
    name = "Webknight",
    detected = false,
    version = nil,

    match = function(responses)
        for name, response in pairs(responses) do
            if response.header.server and string.find(response.header.server, 'WebKnight/') then -- 
                stdnse.print_debug("%s WebKnight detected through Server Header.", SCRIPT_NAME)
                webknight.version = string.sub(response.header.server, 11)
                webknight.detected = true
                return 
            end
            if response.status == 999 then
                if not webknight.detected then stdnse.print_debug("%s WebKnight detected through 999 response status code.", SCRIPT_NAME) end
                webknight.detected = true
            end
        end
    end,
}

local isaserver
isaserver = {
    name = "ISA Server",
    detected = false,
    version = nil,
    -- TODO Check if version detection is possible
    -- based on the response reason
    reason = {"Forbidden %( The server denied the specified Uniform Resource Locator %(URL%). Contact the server administrator.  %)",
              "Forbidden %( The ISA Server denied the specified Uniform Resource Locator %(URL%)"  
             },

    match = function(responses)
        for _, response in pairs(responses) do
            for _, reason in pairs(isaserver.reason) do --
                if http.response_contains(response, reason, true) then -- TODO Replace with something more performant
                    stdnse.print_debug("%s ISA Server detected through response reason.", SCRIPT_NAME)
                    isaserver.detected = true
                    return
                end
            end
        end
    end,
}

local airlock
airlock = {
    name = "Airlock",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            for _, cookie in pairs(response.cookies) do --
                -- TODO Check if version detection is possible
                -- based on the difference in cookies name
                if cookie.name == "AL_LB" and string.sub(cookie.value, 1, 4) == '$xc/' then
                    stdnse.print_debug("%s Airlock detected through AL_LB cookies.", SCRIPT_NAME)
                    airlock.detected = true
                    return
                end
                if cookie.name == "AL_SESS" and (string.sub(cookie.value, 1, 5) == 'AAABL' 
                      or string.sub(cookie.value, 1, 5) == 'LgEAA' )then
                    stdnse.print_debug("%s Airlock detected through AL_SESS cookies.", SCRIPT_NAME)
                    airlock.detected = true
                    return
                end
            end
        end
    end,
}

local barracuda
barracuda = {
    name = "Barracuda",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            for _, cookie in pairs(response.cookies) do
                if cookie.name == "barra_counter_session" then
                    stdnse.print_debug("%s Barracuda detected through cookies.", SCRIPT_NAME)
                    barracuda.detected = true
                    return
                end
            end
        end
    end,
}

local denyall
denyall = {
    name = "Denyall",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            for _, cookie in pairs(response.cookies) do
                -- TODO Check accuracy
                if cookie.name == "sessioncookie" then
                    stdnse.print_debug("%s Denyall detected through cookies.", SCRIPT_NAME)
                    denyall.detected = true
                    return
                end
            end
        end
    end,
}

local f5trafficshield 
f5trafficshield = {
    name = "F5 Traffic Shield",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            -- TODO Check for version detection possibility
            -- based on the cookie name / server header presence
            if response.header.server == "F5-TrafficShield" then
                stdnse.print_debug("%s F5 Traffic Shield detected through Server header.", SCRIPT_NAME)
                f5trafficshield.detected = true
                return
            end
            
            for _, cookie in pairs(response.cookies) do
                if cookie.name == "ASINFO" then
                    stdnse.print_debug("%s F5 Traffic Shield detected through cookies.", SCRIPT_NAME)
                    f5trafficshield.detected = true
                    return
                end
            end
        end
    end,
}

local teros 
teros = {
    name = "Teros / Citrix Application Firewall Enterprise", -- CAF EX, according to citrix documentation 
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            for _, cookie in pairs(response.cookies) do
                if cookie.name == "st8id" or cookie.name == "st8_wat" or cookie.name == "st8_wlf" then
                    stdnse.print_debug("%s Teros / CAF detected through cookies.", SCRIPT_NAME)
                    teros.detected = true
                    return
                end
            end
        end
    end,
}

local binarysec 
binarysec = {
    name = "BinarySec",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header.server and string.find(response.header.server, 'BinarySEC/') then -- 
                stdnse.print_debug("%s BinarySec detected through Server Header.", SCRIPT_NAME)
                binarysec.version = string.sub(response.header.server, 11)
                binarysec.detected = true
                return
            end
            if response.header['x-binarysec-via'] or response.header['x-binarysec-nocache']then
                if not binarysec.detected then stdnse.print_debug("%s BinarySec detected through header.", SCRIPT_NAME) end
                binarysec.detected = true
            end
        end
    end,
}

local profense
profense = {
    name = "Profense",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header.server ==  'Profense' then
                stdnse.print_debug("%s Profense detected through Server header.", SCRIPT_NAME)
                profense.detected = true
                return
            end
            for _, cookie in pairs(response.cookies) do
                if cookie.name == "PLBSID" then
                    stdnse.print_debug("%s Profense detected through cookies.", SCRIPT_NAME)
                    profense.detected = true
                    return
                end
            end
        end
    end,
}

local netscaler
netscaler = {
    name = "Citrix Netscaler",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do

            -- TODO Check for other version detection possibilities
            -- based on fingerprint difference
            if response.header.via and string.find(response.header.via, 'NS-CACHE') then -- 
                stdnse.print_debug("%s Citrix Netscaler detected through Via Header.", SCRIPT_NAME)
                netscaler.version = string.sub(response.header.via, 10, 12)
                netscaler.detected = true
                return 
            end

            if response.header.cneonction == "close" or response.header.nncoection == "close" then
                if not netscaler.detected then stdnse.print_debug("%s Netscaler detected through Cneoction/nnCoection header.", SCRIPT_NAME) end
                netscaler.detected = true
            end
            
            -- TODO Does X-CLIENT-IP apply to Citrix Application Firewall too ?
            if response.header['x-client-ip'] then
                if not netscaler.detected then stdnse.print_debug("%s Netscaler detected through X-CLIENT-IP header.", SCRIPT_NAME) end
                netscaler.detected = true
            end

            for _, cookie in pairs(response.cookies) do
                if cookie.name == "ns_af" or cookie.name == "citrix_ns_id" or 
                                    string.find(cookie.name, "NSC_") then
                    if not netscaler.detected then stdnse.print_debug("%s Netscaler detected through cookies.", SCRIPT_NAME) end
                    netscaler.detected = true
                end
            end
        end
    end,
}

local dotdefender
dotdefender = {
    name = "dotDefender",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header['X-dotdefender-denied'] == "1" then
                stdnse.print_debug("%s dotDefender detected through X-dotDefender-denied header.", SCRIPT_NAME)
                dotdefender.detected = true
                return
            end
        end
    end,
}

local ibmdatapower
ibmdatapower = {
    name = "IBM DataPower",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header['x-backside-transport'] then
                stdnse.print_debug("%s IBM DataPower detected through X-Backside-Transport header.", SCRIPT_NAME)
                ibmdatapower.detected = true
                return
            end
        end
    end,
}

local cloudflare 
cloudflare = {
    name = "Cloudflare",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header.server ==  'cloudflare-nginx' then
                stdnse.print_debug("%s Cloudflare detected through Server header.", SCRIPT_NAME)
                cloudflare.detected = true
                return
            end
            for _, cookie in pairs(response.cookies) do
                if cookie.name == "__cfduid" then
                    stdnse.print_debug("%s Cloudflare detected through cookies.", SCRIPT_NAME)
                    cloudflare.detected = true
                    return
                end
            end
        end
    end,
}

local incapsula
incapsula = {
    name = "Incapsula WAF",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            for _, cookie in pairs(response.cookies) do
                if string.find(cookie.name, 'incap_ses') or string.find(cookie.name, 'visid_incap') then
                    stdnse.print_debug("%s Incapsula WAF detected through cookies.", SCRIPT_NAME)
                    incapsula.detected = true
                    return
                end
            end
        end
    end,
}

local uspses 
uspses = {
    name = "USP Secure Entry Server",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header.server ==  'Secure Entry Server' then
                stdnse.print_debug("%s USP-SES detected through Server header.", SCRIPT_NAME)
                uspses.detected = true
                return
            end
        end
    end,
}

local ciscoacexml
ciscoacexml = {
    name = "Cisco ACE XML Gateway",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header.server ==  'ACE XML Gateway' then
                stdnse.print_debug("%s Cisco ACE XML Gateway detected through Server header.", SCRIPT_NAME)
                ciscoacexml.detected = true
                return
            end
        end
    end,
}


local modsecurity
modsecurity = { 
    -- Credit to Brendan Coles
    name = "ModSecurity",
    detected = false,
    version = nil,

    match = function(responses)
        for _, response in pairs(responses) do
            if response.header.server and string.find(response.header.server, 'mod_security/') then
                stdnse.print_debug("%s Modsecurity detected through Server Header.", SCRIPT_NAME)
                local pos = string.find(response.header.server, 'mod_security/')
                modsecurity.version = string.sub(response.header.server, pos + 13, pos + 18)
                modsecurity.detected = true
                return
            end 

            if response.header.server and string.find(response.header.server, 'Mod_Security') then
                stdnse.print_debug("%s Modsecurity detected through Server Header.", SCRIPT_NAME)
                modsecurity.version = string.sub(response.header.server, 13, -9) 
                modsecurity.detected = true
                return
            end 

            -- The default SecServerSignature value is "NOYB" <= TODO For older versions, so we could
            -- probably do some version detection out of it.
            if response.header.server ==  'NOYB' then 
                stdnse.print_debug("%s modsecurity detected through Server header.", SCRIPT_NAME)
                modsecurity.detected = true
            end 
        end 
    end,
}


local wafs = {
    -- WAFs that are commented out don't have reliable fingerprints
    --  with no false positives yet.
          
    bigip = bigip,
    webknight = webknight,
    isaserver = isaserver,
    airlock = airlock,
    barracuda = barracuda,
    denyall = denyall,
    f5trafficshield = f5trafficshield,
    teros = teros,
    binarysec = binarysec,
    profense = profense,
    netscaler = netscaler,
    dotdefender = dotdefender,
    ibmdatapower = ibmdatapower,
    cloudflare = cloudflare,
    incapsula = incapsula,
    uspses = uspses,
    ciscoacexml = ciscoacexml,
    modsecurity = modsecurity,
--  netcontinuum = netcontinuum,
--  secureiis = secureiis,
--  urlscan = urlscan,
--  beeware = beeware,
--  hyperguard = hyperguard,
--  websecurity = websecurity,
--  imperva = imperva,
--  ibmwas = ibmwas,
--  naxsi = naxsi,
--  nevisProxy = nevisProxy,
--  genericwaf = genericwaf,
}


local send_requests = function(host, port, root)
    local requests, all, responses = {}, {}, {}
    
    local dirtraversal = "../../../etc/passwd"
    local cleanhtml = "<hellot>hello"
    local xssstring = "<script>alert(1)</script>"
    local cmdexe = "cmd.exe"

    -- Normal index
    all = http.pipeline_add(root, nil, all, "GET")
    table.insert(requests,"normal")

    -- Normal inexisting
    all = http.pipeline_add(root .. "asofKlj", nil, all, "GET")
    table.insert(requests,"inexisting")
    
    -- Invalid Method
    all = http.pipeline_add(root, nil, all, "ASDE")
    table.insert(requests,"invalidmethod")
    
    -- Directory traversal
    all = http.pipeline_add(root .. "?parameter=" .. dirtraversal, nil, all, "GET")
    table.insert(requests,"invalidmethod")
    
    -- Invalid Host
    all = http.pipeline_add(root , {header= {Host = "somerandomsite.com"}}, all, "GET")
    table.insert(requests,"invalidhost")
    
    --Clean HTML encoded
    all = http.pipeline_add(root .. "?parameter=" .. cleanhtml , nil, all, "GET")
    table.insert(requests,"cleanhtml")
    
    --Clean HTML
    all = http.pipeline_add(root .. "?parameter=" .. url.escape(cleanhtml), nil, all, "GET")
    table.insert(requests,"cleanhtmlencoded")

    -- XSS
    all = http.pipeline_add(root .. "?parameter=" .. xssstring, nil, all, "GET")
    table.insert(requests,"xss")
    
    -- XSS encoded
    all = http.pipeline_add(root .. "?parameter=" ..  url.escape(xssstring), nil, all, "GET")
    table.insert(requests,"xssencoded")
    
    -- cmdexe
    all = http.pipeline_add(root .. "?parameter=" .. cmdexe, nil, all, "GET")
    table.insert(requests,"cmdexe")

    
    -- send all requests
    local pipeline_responses = http.pipeline_go(host, port, all)
    if not pipeline_responses then
        stdnse.print_debug("%s No response from pipelined requests", SCRIPT_NAME)
        return nil
    end
    
    -- Associate responses with requests names
    for i, response in pairs(pipeline_responses) do
        responses[requests[i]] = response 
    end 
    
    return responses
end

action = function(host, port)
    local root = stdnse.get_script_args(SCRIPT_NAME .. '.root') or "/"
    local result = {"Detected firewalls", {}}

    -- We send requests
    local responses = send_requests(host, port, root)
    if not responses then
        return nil
    end

    -- We iterate over wafs table passing the responses list to each function to analyze
    -- the presence of any fingerprints.
    for _, waf in pairs(wafs) do
        waf.match(responses)
        if waf.detected then
            if waf.version then
                table.insert(result[2], waf.name .. " version " .. waf.version)
            else
                table.insert(result[2], waf.name)
            end
        end
    end
    if #result[2] > 0 then
        return stdnse.format_output(true, result)
    end
end