Contributing
August 20, 2026 ยท View on GitHub
Running the checks
Everything runs offline. No API key, no network access, no scanning of anyone.
# 1. unit tests - executed inside nmap, so the scripts see the real NSE libraries
nmap -sn -Pn --script ./tests/run.nse --script-args testdir=tests,root=. 127.0.0.1
# 2. end-to-end tests - real nmap against a local web server and a stand-in API
python3 tests/e2e/run_e2e.py
# 3. repository hygiene - secrets, AI artifacts, clutter, line endings, and a
# global read that NSE would turn into a lost result
python3 tools/check.py
# 4. the catalogue is shaped the way the script reads it
python3 tools/catalog.py --check
# 5. the XML contract every importer reads still catches a broken report
python3 tools/xml_contract.py --selftest
# 6. the PCRE-to-Lua translator still makes the decisions it is supposed to
python3 tools/fingerprints/selftest.py
# 7. the publish gate still refuses a rebuild that loses ground
python3 tools/catalog_diff.py --selftest
# 8. the tree still meets Nmap's own Code Standards
python3 tools/nmap_style.py
Gates four to seven are the data and the tools that publish it. They matter as much as the first three, because the catalogue rebuilds itself on a schedule and publishes without a human: a translator that starts writing patterns nothing can execute, or a gate that stops noticing loss, reaches every installed script within a day and there is no release to hold it back.
The unit suite takes under a second and the end-to-end suite about seven, so
there is no reason to run them one at a time or only at the end. The ten unit
suites are test_harness, test_config, test_catalog, test_fingerprints,
test_channels, test_sweep, test_lookup, test_keyed, test_render and
test_notice. Working on one, load just that file:
nmap -sn -Pn --script ./tests/run.nse \
--script-args testdir=tests,root=.,only=test_sweep.lua 127.0.0.1
The end-to-end checks run in parallel, each against its own pair of servers.
--jobs 1 serialises them, which is worth doing when a check fails in a way that
looks like interference.
The end-to-end suite is offline by default. To additionally check that the real API still answers in the shape the doubles imitate:
VULNERS_API_KEY=<token> python3 tests/e2e/run_e2e.py --live
A passing test is not proof. Six assertions in this repository were once green while measuring nothing. Before trusting a new test, break the behaviour it covers on a scratch copy of the script and confirm the test fails:
cp vulners.nse /tmp/intact && <edit vulners.nse> && <run the gate> && cp /tmp/intact vulners.nse
Give the mutation run a timeout. One mutation did not fail the suite, it
hung it, and a harness with no per-run bound turns that finding into an
afternoon. The same lesson holds outside the tests: every CI job now carries
timeout-minutes, because three of them once sat on apt-get install for
twenty-five minutes against a six-hour default.
Run a script by its absolute path when testing by hand. nmap resolves a
relative --script ./vulners.nse against its own script.db first, so it
silently runs the copy installed with nmap instead of the one you are editing:
nmap -sV --script "$PWD/vulners.nse" <target> # your file
nmap -sV --script ./vulners.nse <target> # possibly nmap's own copy
The script carries no fingerprint data. It downloads three dictionaries at scan time, from a GitHub branch, so the corpus can grow without a release:
catalog/index.json the manifest: schema and serial
catalog/fingerprints.json 721 product and version rules
catalog/paths.json 939 paths the sweep requests
catalog/probes.json 6 targeted version probes
Those four files are the only copy - there is no second "editable source". Edit them directly for a one-off correction, then:
python3 tools/catalog.py --index # bump the serial, or nobody downloads it
python3 tools/catalog.py --check # the checks the script itself applies
catalog/fingerprints.json, catalog/paths.json and catalog/probes.json are
normally generated by tools/fingerprints/build.py from checkouts of Recog,
Wappalyzer, WhatWeb, FingerprintHub and nuclei-templates, and rebuilt weekly by
.github/workflows/catalog-refresh.yml.
A hand edit survives the next rebuild only if the entry carries no source
field. You never need to rebuild them to work on the script.
While working, point the script at your own catalogue rather than the published one, or you will be testing against whatever is live:
python3 -m http.server 8000 --directory catalog &
nmap -sV --script "$PWD/vulners.nse" \
--script-args vulners.catalog_url=http://127.0.0.1:8000/ <target>
vulners.catalog=none turns the download off entirely; the script then looks up
only what nmap itself identified, which is also what happens on a machine with
no route to GitHub.
Gate eight is what nmap asks of a script that lives in its tree. HACKING
points at Nmap's Code Standards,
and that page links a check script; tools/nmap_style.py is that page as a
test - the whitespace rules, 80 columns, no semicolons, no bin/bit,
explicit endianness, structured output, the four script fields, --; for
private NSEdoc, and PEP 8 through pycodestyle with the gist's ignore list.
The globals half of nmap's script is already tools/check.py, which runs it
with luac.
Three things are exempt, because rewrapping them would make them a sample of
something that never happened: a line inside a Lua [[ long bracket ]], a
line inside an @output or @xmloutput block, and LICENSE.
Without pycodestyle installed the PEP 8 half skips, loudly - the same
bargain tools/check.py makes with luac. CI installs it.
Run all eight from the repository root. Each exits non-zero on failure, and CI runs exactly the same commands.
Useful while working on one thing:
# only the cases whose name matches a pattern
nmap -sn -Pn --script ./tests/run.nse \
--script-args testdir=tests,root=.,filter=mincvss 127.0.0.1
# list every passing case as well
nmap -sn -Pn --script ./tests/run.nse \
--script-args testdir=tests,root=.,verbose=1 127.0.0.1
How the tests are built
tests/run.nse is a prerule script, so the suite executes inside nmap itself.
The scripts under test are loaded into an isolated environment with json,
stdnse, url and shortport provided by the nmap installation that runs the
tests. Only the network is faked: tests/lib/harness.lua supplies a
programmable http double, plus stand-ins for nmap and stdnse when a test
needs to inspect what was logged.
Adding a test file means adding it to TEST_FILES in tests/run.nse. A test
file returns a list of {name = ..., fn = ...} entries.
tests/e2e/run_e2e.py runs the real nmap binary against local servers: a web
server with recognisable version banners, a stand-in Vulners API, and one serving
catalog/ out of the working tree so the checks exercise the dictionaries about
to be committed rather than whatever is published. That is what catches problems
the unit suite cannot see, such as an argument arriving as a string where the
http library expects a number.
Each check gets its own servers and its own counters, which is what lets them run
at the same time. A check that needs to count requests must therefore read them
off its own world, never off a handler class - the counters used to live there,
and that is precisely why the checks could not overlap.
Cutting a release
Tag it and push the tag; .github/workflows/release.yml does the rest:
git tag -a v2.0 -m "nmap-vulners 2.0"
git push origin v2.0
The workflow runs the gates first, then builds .tar.gz and .zip archives
with git archive - so they hold exactly what a user downloads: vulners.nse,
catalog/*.json, both installers, the README and the LICENSE, and nothing else:
.gitattributes keeps the tests, the tools, the workflows and the README's
695 KB demonstration out of it - writes
SHA256SUMS, and publishes a release with generated notes.
The catalogue ships in the archive even though nothing installs it and the
script never reads it from disk. It is a snapshot, useful for serving locally
with vulners.catalog_url on a network that cannot reach GitHub; the live
copies are on the catalog branch and are what an ordinary scan downloads.
Nothing needs to be built by hand, and a release whose gates fail is never
published. workflow_dispatch re-runs it for an existing tag.
The script's api_version is a different number: it identifies the request
generation to vulners.com and travels in the User-Agent, so it moves when the
request shape changes, not when a release is cut. The free endpoint sits behind
a rule that answers 403 to a keyless request whose User-Agent does not contain
Vulners NMAP Plugin, so that substring is a wire contract, not a label. Check any bump against the
live service before shipping it - the header is what the service uses to tell
plugin generations apart.
The installers
install.sh (POSIX) and install.ps1 (Windows) both:
- ask nmap where its data directory is, by reading which
nse_main.luait opens under-d2- that reflects the build, the package manager andNMAPDIR - replace an existing
vulners.nse; nmap ships one of its own, and leaving it in place means nmap keeps running that copy - remove the 1.x files -
vulners_enterprise.nse,http-vulners-regex.nseand their two data files. A leftover fingerprint script still carries thedefaultcategory and keeps sweeping targets under a plain-sC - offer to store an API key, validating it against the API first and writing
it mode 600. Under
sudothey resolve the home directory from$SUDO_USER, because nmap resolves~/.nmapthroughgetpwuid(getuid())and not$HOME.--no-key/-NoKeyskips the prompt, and so does having no terminal to ask on - CI, a Dockerfile, cron. The question is asked on/dev/tty, not on stdin, and it has to be: installed the documented way,curl ... | sh, stdin is the pipe carrying the script, so testing it found no terminal and skipped the offer on exactly the path most people take - and reading it would have eaten the part of the scriptshhad not parsed yet - refuse a ref that does not carry this release. In download mode they fetch
vulners.nsefrom--ref(defaultmaster) and check it for the line naming where the catalogue lives. A ref still holding 1.x would otherwise be installed while the two data files 1.x needs were being deleted, which is a downgrade to something that cannot run, arriving silently - verify by resolution, not by copying: they check that
--script vulnersresolves to the file they installed, and warn when it does not
CI runs both on Linux, macOS and Windows, installs, verifies and uninstalls again, so a change to either is exercised on the platforms it claims.
The workflows
Four of them: ci.yml on every push and pull request, release.yml on a tag,
catalog.yml publishing catalog/ to the catalog branch, and
catalog-refresh.yml rebuilding the dictionaries weekly from the upstream
corpora.
Two things they learned by being run rather than read. Every job carries
timeout-minutes, because the default is six hours and a hung job is a publish
that silently never happens. And every apt call goes through
.github/actions/apt, which bounds each one at seven minutes and retries once:
a healthy apt-get install on these runners takes 12 to 49 seconds and a bad
one has never finished, so a hang is not a slow success and should not be
waited out. A hang is also not a failure - it never returns, so nothing retries
it by itself.
House rules
- Comments and documentation in the project files are English only.
- Never commit an API key. The script reads a token from
--script-args vulners.api_key=, fromvulners.api_key_file=, fromVULNERS_API_KEY, or from~/.nmap/vulners.key; keep any such file outside the repository.tools/check.pylooks for a bare 64-character alphanumeric line, which is the shape of a key file committed by accident. - No AI assistant leftovers (
CLAUDE.md,.claude/,.cursorrules, and the like), no OS or editor clutter, no scan output..gitignorecovers the usual names andtools/check.pyis the second barrier. catalog/fingerprints.jsonis data with rules: each entry needs analiasof the formcpe:/<part>:<vendor>:<product>and aregexthat is a valid Lua pattern with exactly one capture - the version. Patterns without a capture can never produce a CPE, so the test suite rejects them.- Changes to a script want a test that fails without them.