← Nmap Scripting Engine (NSE): Writing Your Own Scripts

Lesson 10 of 10

Making scripts production-ready

Harden a script for real networks: safety, performance, error handling, documentation, testing, distribution, and contributing upstream.

15 minHands-on lab

A script that works against your lab server isn’t ready to be pointed at 20,000 hosts. Production networks contain slow servers, hung connections, unexpected protocols, IPv6, huge responses, and colleagues who will run --script safe and believe you.

This lesson turns Scenario 2’s http-hygiene into something you can hand to other people, then covers the surrounding practices: testing, distribution and contributing upstream.

The production checklist

1. Honest metadata

  • Categories are a promise. Use safe only if it can’t crash or disturb a service. Use intrusive when in doubt. default is reserved for scripts that are fast, safe, reliable and broadly useful, and you’d need to convince the Nmap project of that.
  • Complete description, author, license.
  • Naming: service-purpose in lower-case with hyphens (http-hygiene, acmeq-info).

2. Documentation

Fill in @usage, @args, @output and, for scripts returning tables, @xmloutput. Verify with nmap --script-help ./your-script.nse.

3. Robust input handling

  • Validate every script argument, and give a clear error for a bad one.
  • Convert with tonumber and check for nil.
  • Never trust data from the network: a banner can be empty, huge, binary, or hostile.

4. Failure is normal

  • A refused connection, timeout or garbage response is not an error. Log with stdnse.debug1 and return nil.
  • Reserve error() for genuine bugs.
  • Always release resources. If you open a raw socket, use nmap.new_try(function() socket:close() end) so it closes on every exit path.

5. Bounded work

  • Timeouts on every network operation. Prefer stdnse.get_timeout(host, max), which respects the user’s -T setting.
  • Cap sizes: max_body_size for HTTP, receive_bytes(n) with a limit for sockets.
  • Cap loops (at most N probes, at most M redirects).
  • Never busy-loop. NSE is cooperative (lesson 2). If you must wait, use stdnse.sleep.

6. Be a polite guest

  • Send as few requests as necessary.
  • Set a recognisable, configurable User-Agent (the http library honours http.useragent).
  • Don’t do anything in a safe script you wouldn’t want done to your own servers.

7. Portability

  • Support IPv6 (use host.ip, don’t parse it), and plain and TLS ports.
  • Avoid OS-specific assumptions such as file paths.
  • Test on more than one Nmap version.

8. Structured output

Return a table. Keep field names stable. They become an API for anyone parsing -oX output.

The hardened script

Here’s http-hygiene again with the checklist applied. Compare it with Scenario 2 and spot each change.

local http      = require "http"
local shortport = require "shortport"
local stdnse    = require "stdnse"

description = [[
Checks a web server for missing security response headers and for a short list
of commonly exposed sensitive files. A file is only reported when the response
body matches a known signature and does not look like a catch-all page.

The script sends at most one request for the site root, one baseline request,
and one request per probe (a maximum of about a dozen requests per service).
]]

---
-- @usage
-- nmap -p 80,443 --script http-hygiene <target>
-- nmap -p 80 --script http-hygiene --script-args http-hygiene.basepath=/app/ <target>
--
-- @args http-hygiene.basepath  Path prefix to test under. Default: "/".
-- @args http-hygiene.timeout   Per-request timeout in seconds. Default: 5, range 1-60.
--
-- @output
-- PORT   STATE SERVICE
-- 80/tcp open  http
-- | http-hygiene:
-- |   missing_headers:
-- |     x-content-type-options
-- |   exposed_files:
-- |_    /.git/HEAD (git repository metadata)
--
-- @xmloutput
-- <table key="missing_headers">
--   <elem>x-content-type-options</elem>
-- </table>
-- <table key="exposed_files">
--   <elem>/.git/HEAD (git repository metadata)</elem>
-- </table>

author = "Your Name"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"discovery", "safe"}

portrule = shortport.http

local WANTED_HEADERS = {
  "strict-transport-security",
  "x-content-type-options",
  "content-security-policy",
  "x-frame-options",
}

local PROBES = {
  {path = "/.git/HEAD",     sig = "^ref: refs/",          what = "git repository metadata", plain = true},
  {path = "/.env",          sig = "^[A-Z][A-Z0-9_]*=",    what = "environment file",        plain = true},
  {path = "/server-status", sig = "Apache Server Status", what = "Apache server-status page"},
  {path = "/phpinfo.php",   sig = "PHP Version",          what = "phpinfo() output"},
}

local MAX_BODY = 64 * 1024   -- never read more than this from any response

-- Validate the arguments once. Returns options table, or nil + message.
local function get_options()
  local base = stdnse.get_script_args(SCRIPT_NAME .. ".basepath") or "/"
  if base:sub(1, 1) ~= "/" then
    return nil, "basepath must start with /"
  end
  if base:sub(-1) ~= "/" then base = base .. "/" end

  local secs = tonumber(stdnse.get_script_args(SCRIPT_NAME .. ".timeout")) or 5
  if secs < 1 or secs > 60 then
    return nil, "timeout must be between 1 and 60 seconds"
  end
  return {base = base, timeout = secs * 1000}
end

-- One bounded request. Returns the response only if a real HTTP reply arrived.
local function fetch(host, port, path, opts)
  local resp = http.get(host, port, path, {
    timeout = opts.timeout,
    redirect_ok = false,
    max_body_size = MAX_BODY,
    truncated_ok = true,
  })
  if resp and resp.status then return resp end
  stdnse.debug2("no response for %s%s", host.ip, path)
  return nil
end

action = function(host, port)
  local opts, err = get_options()
  if not opts then return "ERROR: " .. err end

  local root = fetch(host, port, opts.base, opts)
  if not root then return nil end

  local missing = {}
  for _, name in ipairs(WANTED_HEADERS) do
    local applicable = (name ~= "strict-transport-security") or shortport.ssl(host, port)
    if applicable and not root.header[name] then
      missing[#missing + 1] = name
    end
  end

  -- A server that returns 200 for a nonsense path answers everything with 200,
  -- so status codes prove nothing there. Remember it and require plain-text files.
  local baseline = fetch(host, port, opts.base .. "nse-no-such-path-7f3a", opts)
  local catch_all = baseline ~= nil and baseline.status == 200

  local exposed = {}
  for _, probe in ipairs(PROBES) do
    local resp = fetch(host, port, opts.base .. probe.path:sub(2), opts)
    if resp and resp.status == 200 and resp.body then
      local body = resp.body:sub(1, 4096)
      local looks_html = body:lower():find("<html", 1, true) ~= nil
      local same_as_baseline = catch_all and baseline.body == resp.body
      if body:find(probe.sig)
         and not (probe.plain and looks_html)
         and not same_as_baseline then
        exposed[#exposed + 1] = ("%s (%s)"):format(probe.path, probe.what)
      end
    end
  end

  if #missing == 0 and #exposed == 0 then return nil end

  local out = stdnse.output_table()
  if #missing > 0 then out.missing_headers = missing end
  if #exposed > 0 then out.exposed_files = exposed end
  return out
end

What changed, mapped to the checklist:

ChangeChecklist item
get_options() validates basepath and timeout and returns a clear error3
fetch() wraps every request with a timeout, max_body_size and a status check4, 5
A MAX_BODY cap and a fixed number of requests5, 6
Baseline request detects catch-all servers and rejects identical bodiesCorrectness, which builds trust
@args, @xmloutput and a description that states how many requests are sent2, 6
Table output with stable field names8

Test matrix

Before calling a script done, run it against each of these. Most take minutes with small Python servers, and a container makes the set repeatable.

CaseWhat you’re proving
Healthy target with real findingsThe happy path.
Healthy target, nothing wrongSilent output (nil).
Closed and filtered portsThe script doesn’t run, or exits cleanly.
Non-HTTP service on a web portNo crash, no false findings.
Server that accepts then never repliesTimeout fires, scan continues.
Server that returns a huge or endless bodySize cap holds and memory stays flat.
Catch-all server (200 for every URL)No false positives.
HTTPS target and an IPv6 targetTLS and IPv6 paths work.
Bad script argumentsClear error, no stack trace.
Many hosts at once (-iL with a few hundred)It scales and other scripts aren’t starved.

For the “never replies” case:

import socket, time
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 8088)); s.listen()
while True:
    c, _ = s.accept()   # accept the connection... and say nothing
    time.sleep(3600)

Port 8088 is one of the web ports shortport.http recognises, so the script runs against it. Run it with a short timeout:

time nmap -p 8088 --script ./http-hygiene.nse --script-args http-hygiene.timeout=2 127.0.0.1

It should finish within a few seconds and print no findings, rather than hanging on the silent server. Try it against the Scenario 2 version of the script too, and compare.

Automate the checks

A small CI pipeline catches most regressions:

# 1. Lint. luacheck understands Lua; tell it about NSE's globals.
luacheck http-hygiene.nse --std max --globals description author license categories \
    portrule hostrule prerule postrule action dependencies SCRIPT_NAME

# 2. The script loads and documents itself
nmap --script-help ./http-hygiene.nse

# 3. Run against your fixtures (containers or local servers) and compare with golden output
nmap -p 8000 --script ./http-hygiene.nse -oX out.xml 127.0.0.1
diff <(grep -A20 http-hygiene out.xml) expected.xml

Keep the fixtures in the repository beside the script, so anyone can reproduce your test results.

Distribution

  • Keep scripts in a Git repository with the script, fixtures, a README, and a changelog.
  • Version deliberately. State the minimum Nmap version you’ve tested (nmap --version).
  • Install with a documented location (a scripts/ directory Nmap searches, or --datadir) and run nmap --script-updatedb afterwards.
  • Pin what you rely on. If a script depends on a newer library function, say so.

Contributing upstream

If your script is broadly useful (a public product, a general check), consider contributing it to Nmap itself:

  1. Study the bundled scripts in the same area and match their style, structure and documentation.
  2. Make sure the script is tested against several versions of the target software and behaves on failure.
  3. Open a pull request on Nmap’s GitHub repository (or send it to the nmap-dev mailing list) with a clear explanation, sample output, and test notes.
  4. Expect review feedback. It’s how the scripts everyone relies on stay high quality.

Internal-only checks like ACMEQ stay in your own repository.

Final project

Choose one:

  • A. Harden acmeq-info.nse from Scenario 1: validate arguments, add timeouts via stdnse.get_timeout, use comm.exchange for a VERSION command, handle a server that never answers, and add @xmloutput.
  • B. Write a new script for a service you actually run, using the process from this course: mock the service, write the rule and action, debug with -d, then apply the checklist.
  • C. Extend http-hygiene with a vulns-format report for .git exposure and a http-hygiene.probes argument for custom paths.

Whichever you pick, deliver: the script, a short README with usage and sample output, your fixture servers, and the test-matrix results.

Where to go next

  • Read the bundled scripts in scripts/ for services you know well. They’re a masterclass.
  • Explore nselib for protocols you’d like to script (smb, ldap, mysql, dns, tls and many more).
  • Learn the brute library and the creds library if you plan credential-auditing scripts.
  • Follow Nmap’s release notes for new libraries and changes to the Lua version.

Checkpoint

Why does a safe category label carry so much responsibility?

Operators use –script safe precisely because they trust it won’t disturb production systems. A mislabeled script breaks that trust and can cause real outages.

Your script works on your lab server but stalls on a real network. What are the first two things you check?

That every network call has a timeout, and that nothing in the script busy-loops. Then use –script-trace to see which host and request it’s waiting on.