NAME
App::WebPerimeterScan - check your own web perimeter the way an outsider sees it
VERSION
Version 0.001.0
SYNOPSIS
From the command line, scan the hosts listed in scope.txt:
webperimeter --scope scope.txt --authorized
From Perl, scan and print the findings, worst first:
use App::WebPerimeterScan;
use App::WebPerimeterScan::Scope;
my $scope = App::WebPerimeterScan::Scope->new->load_file('scope.txt');
my $scanner = App::WebPerimeterScan->new(scope => $scope);
my $findings = $scanner->run;
print "$_->{severity} $_->{title}\n" for @{ $findings->sorted };
Stop a deployment when the scan finds anything high or critical:
my $serious = App::WebPerimeterScan->new(scope => $scope)->run->at_or_above('high');
die scalar(@{$serious}) . " serious finding(s)\n" if @{$serious};
A fast scan of only the web ports, without the sensitive-path probes:
my $quick = App::WebPerimeterScan->new(
scope => $scope,
ports => [ 80, 443 ],
max_urls => 1,
path_probes => 0,
);
Scan your own site and point each finding at the file and the code behind it. Run this on the machine that serves the site:
my $full = App::WebPerimeterScan->new(
scope => $scope,
docroots => [ '/srv/www/example' ],
source_dirs => [ '/home/me/src/example/lib' ],
);
for my $finding (@{ $full->run->sorted }) {
print "$finding->{title}\n";
print " file: $finding->{file}\n" if length $finding->{file};
print " source: $finding->{source}\n" if length $finding->{source};
}
Write an HTML report:
use App::WebPerimeterScan::Report;
open my $out, '>', 'report.html';
print $out App::WebPerimeterScan::Report::as_html($scanner->findings,
version => $App::WebPerimeterScan::VERSION,
hosts => $scanner->host_reports,
);
close $out;
DESCRIPTION
This tool looks at your own web servers from the outside, the way a stranger on the internet sees them. It does not log in. It answers these questions for each host:
Which TCP ports can be reached?
Is the TLS certificate valid, and are old protocol versions still accepted?
Which security headers are missing, and are the session cookies protected?
Has a sensitive file, such as .git/config or .env, been left where the web server will send it?
The tool does not attack the site. It does not inject code, does not guess passwords, and does not change any data: it sends only GET requests, plus one OPTIONS request for each host. So it is safe to run against a production site. (--reflection-probe adds one more GET with a harmless marker in the query string.)
README.md is generated from this POD by pod2github: change the POD and regenerate, rather than editing the README by hand.
The scope file is the authorisation boundary: a host that does not appear in it is never contacted, including hosts discovered by following links, and a host that resolves into private address space is refused unless --allow-private is given.
Every message the tool prints comes from a catalogue (App::WebPerimeterScan::Messages), so the text can be translated without touching the code; see App::WebPerimeterScan::I18N.
REQUIREMENTS
Perl 5.26.0 or later, with the modules listed in Makefile.PL. The floor is set by Params::Validate::Strict, which needs 5.26; the code in this distribution would run on 5.14. cpan installs anything that is missing, so these floors matter mainly on a machine that already has older copies of them:
Socket2.020The port scan reads
SO_ERRORfrom it to tell a refused connection from an open one, and the private-address check uses itsgetaddrinfo.Net::SSLeay1.92The first release with OpenSSL 3 support. Earlier releases are not supported: 1.46 does not compile against OpenSSL 3 at all.
IO::Socket::SSL2.000IO::Select1.17 andIO::Socket::INET1.31They are stated because the port scan relies on how they behave, not only on what they provide.
Sub::Private0.09 andSub::Protected0.03Every internal routine is wrapped so that a call from outside its package croaks. Tests run under
proveare exempt (HARNESS_ACTIVE).Params::Get0.17 andParams::Validate::Strict0.41Every public routine validates its arguments against the schema in its documentation.
Data::Validate::IP0.31Classifies resolved addresses against the IANA special-purpose registries.
Both SSL modules need the OpenSSL headers at install time, which the installer cannot supply for you; install your distribution's openssl development package first if the build fails.
The remaining prerequisites are autodie with IPC::System::Simple, Getopt::Long 2.50, HTML::Parser 3.71, HTML::SimpleLinkExtor 1.20, JSON::PP 4.00, Readonly 2.05, Time::HiRes 1.97 and URI 1.70, plus Test::More 1.302 for the test suite.
USAGE
bin/webperimeter --scope scope.txt --authorized
The scope file lists the hosts you are allowed to test, one per line. Without --authorized the tool refuses to send any traffic. Add --docroot DIR and --source DIR to resolve findings back to the file on disk and the code that serves them.
QUICK TEST
To check the tool works where you installed it, scan example.com. RFC 2606 reserves the name for documentation and it is always available, so it makes a stable target. It is not a licence to scan other people's hosts: the scope file is still the boundary.
$ printf 'example.com\n' > scope.txt
$ bin/webperimeter --scope scope.txt --authorized --ports 80,443 \
--max-urls 1 --no-path-probes --no-color
webperimeter 0.001.0 2026-11-06T01:05:02Z
Scope: scope.txt
Requests: 4
============================================================
example.com
------------------------------------------------------------
TLS: TLSv1_3, TLS_AES_256_GCM_SHA384, expires Dec 25 22:56:35 2026 GMT (49 days)
Certificate: /CN=example.com issued by /C=US/O=SSL Corporation/CN=Cloudflare TLS Issuing ECC CA 3
Open ports: 80, 443
URLs scanned: 1
MEDIUM (4)
----------
[csp-missing] Content-Security-Policy is missing
Evidence: No Content-Security-Policy header on https://example.com/
[...]
Summary: 0 critical, 0 high, 4 medium, 2 low, 1 info
--max-urls 1 holds the crawl to the front page and --no-path-probes skips the sensitive path checks, so the run costs four requests. Leave the probes on when the target is a deployment of your own.
The findings are the point of the exercise. example.com is a good smoke test because it is not hardened: a run reporting missing CSP, HSTS and nosniff shows the checks are wired up, and a run that reports nothing - or a critical finding - means the scan is broken, not the site.
EXAMPLE
A run against a local application, with its source tree supplied so the report can name the endpoints it never reached. The output is trimmed in the middle for length.
$ echo localhost:3000 > scope.txt
$ bin/webperimeter --scope scope.txt --authorized --allow-private --ports 3000 \
--source ~/src/njh/Database-BI --no-path-probes --max-urls 6 --delay 0.2 --no-color
webperimeter 0.001.0 2026-11-06T01:05:47Z
Scope: scope.txt
Requests: 8
============================================================
localhost
------------------------------------------------------------
Open ports: 3000
URLs scanned: 6
LOW (1)
-------
[port-open-3000] Port 3000 accepts connections from outside the host
Evidence: TCP connect succeeded, no banner
INFO (1)
--------
[source-routes-unvisited] 71 endpoints defined in source were not scanned
Evidence: ~/src/njh/Database-BI/lib/Database/BI.pm:2713 GET /login; ~/src/njh/Database-BI/lib/Database/BI.pm:2714 POST /login; ~/src/njh/Database-BI/lib/Database/BI.pm:2715 POST /logout; [...]
Fix: Name them explicitly, raise --max-urls, or review them by hand.
Summary: 0 critical, 0 high, 0 medium, 1 low, 1 info
That application sends style-src 'self' 'unsafe-inline' with a nonce-based script-src. Only the script sources are judged, so the inline-style allowance is not reported as a cross-site scripting gap.
--allow-private is required for a loopback or private target; without it the host is refused before any traffic is sent, and the refusal is reported as a high-severity scan-error so the exit status shows that nothing was scanned. The exit status is 0 here because --fail-on defaults to high and nothing reached it.
--no-path-probes skips the well-known sensitive path checks, which ask whether a deployed document root exposes files such as /.git/config. The target here is local, so those paths can be inspected directly on disk - with --docroot, or by reading the tree - and the probes would only add requests. Against a real deployment, leave them on.
source-routes-unvisited is the reason to pass --source: 71 routes exist in the code that no crawled page linked to, so none of the checks above saw how they behave. That is exactly the surface a link-following scan cannot reach. Raise --max-urls, or review them by hand.
Findings also carry File: and In source: lines when --docroot or --source lets the scan resolve them.
REPORT FORMAT
--format json records the response header block behind each header finding, so a report can be audited later without repeating the requests. Abridged to one header:
"response": {
"headers" : {
"content-security-policy" : [ "default-src 'self'; script-src 'self' 'nonce-...' https://cdnjs.cloudflare.com https://unpkg.com; ..." ]
},
"http_version" : "HTTP/1.1",
"method" : "GET",
"note" : "main page",
"raw" : "HTTP/1.1 200 OK\r\nServer: ...",
"reason" : "OK",
"status" : 200,
"url" : "http://localhost:3000/"
},
"responses" : [ ... ]
A header appears once per value, so a second Content-Security-Policy or Set-Cookie is not lost. raw is the block exactly as it arrived. responses holds the same structure for the CORS and OPTIONS probes, which is where the CORS and method findings come from.
SUBROUTINES/METHODS
new
Purpose: configure a scan.
Args: named, all optional except that run needs scope:
scope an App::WebPerimeterScan::Scope (or anything with
hosts, covers, allows and assert_target_allowed)
timeout seconds per HTTP request (10)
connect_timeout seconds for port and TLS handshakes (3)
delay seconds between requests to one host (0.5)
depth link-following depth from each root URL (1)
max_urls pages crawled per host (40)
max_size bytes of each response body kept (2000000)
path_probes request the well-known sensitive paths (1)
reflection send the reflected-input marker (0)
verbose progress on STDERR (0)
ports array reference of TCP ports (the Ports default list)
docroots array reference of document roots on this machine
source_dirs array reference of source trees on this machine
Defaults come from %App::WebPerimeterScan::config.
Returns: the new object.
Side Effects: none; no traffic is sent until run.
Usage: my $scanner = App::WebPerimeterScan->new(scope => $scope);
EXAMPLE
my $scanner = App::WebPerimeterScan->new(
scope => $scope,
ports => [ 80, 443 ],
max_urls => 1,
path_probes => 0,
source_dirs => [ "$ENV{HOME}/src/my-app" ],
);
API SPECIFICATION
INPUT
{
scope => { type => 'object', can => [qw(hosts covers allows assert_target_allowed)], optional => 1 },
timeout => { type => 'number', min => 0, optional => 1 },
connect_timeout => { type => 'number', min => 0, optional => 1 },
delay => { type => 'number', min => 0, optional => 1 },
depth => { type => 'integer', min => 0, optional => 1 },
max_urls => { type => 'integer', min => 1, optional => 1 },
max_size => { type => 'integer', min => 1, optional => 1 },
path_probes => { type => 'boolean', optional => 1 },
reflection => { type => 'boolean', optional => 1 },
verbose => { type => 'boolean', optional => 1 },
ports => { type => 'arrayref', element_type => 'integer', optional => 1 },
docroots => { type => 'arrayref', optional => 1 },
source_dirs => { type => 'arrayref', optional => 1 },
}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan' }
MESSAGES
Message Meaning / resolution
---------------------------------------- ------------------------------
Unknown parameter 'X' (fatal) A misspelt argument name.
Parameter 'X' ... must be ... (fatal) A value of the wrong type or
out of range, e.g. depth => -1.
Parameter 'scope' must be an object that Pass an App::WebPerimeterScan::
understands the ... method (fatal) Scope.
PSEUDOCODE
validate the arguments
overlay them on %config
create the findings collector and the HTTP client
create the source index if any docroot or source tree was given
findings
Purpose: the findings collector the scan writes to.
Args: none.
Returns: an App::WebPerimeterScan::Findings.
Side Effects: none.
Usage: my $count = $scanner->findings->count;
EXAMPLE
$scanner->run;
printf "%d finding(s)\n", $scanner->findings->count;
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }
MESSAGES
None.
http
Purpose: the HTTP client, for its request count or to make further requests under the same throttle.
Args: none.
Returns: an App::WebPerimeterScan::HTTP.
Side Effects: none.
Usage: printf "Requests: %d\n", $scanner->http->requests;
EXAMPLE
$scanner->run;
print $scanner->http->requests, " requests sent\n";
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::HTTP' }
MESSAGES
None.
host_reports
Purpose: what the scan learned about each host, for the report formats.
Args: none.
Returns: an array reference, sorted by host, of hash references with host, entry (the scope entry), urls (pages crawled), open_ports, base (the root URL that answered), tls (the "inspect" in App::WebPerimeterScan::TLS result, when TLS was inspected), response (the main page's header block) and responses (the probes' header blocks). See "REPORT FORMAT".
Side Effects: none.
Usage: my $hosts = $scanner->host_reports;
EXAMPLE
for my $host (@{ $scanner->host_reports }) {
printf "%s: %d page(s)\n", $host->{host}, scalar @{ $host->{urls} };
}
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref', element_type => 'hashref' }
MESSAGES
None.
run
Purpose: scan every concrete host in the scope.
Args: none.
Returns: the App::WebPerimeterScan::Findings collector.
Side Effects: network traffic to the hosts in scope, and only to them. A host that cannot be scanned (refused by the scope, or an unexpected error) becomes a high-severity scan-error finding rather than stopping the run, so the other hosts are still scanned and the exit status shows the gap.
Usage: my $findings = $scanner->run;
EXAMPLE
my $findings = App::WebPerimeterScan->new(scope => $scope)->run;
exit(@{ $findings->at_or_above('high') } ? 1 : 0);
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }
MESSAGES
Message Meaning / resolution
------------------------------------------ ------------------------------
No scope was given; a scan needs one Pass scope => ... to new().
before it can send any traffic (fatal)
No concrete hosts in scope (only The scope file lists only
wildcards?) (fatal) *.domain lines; add a host.
PSEUDOCODE
croak without a scope or without concrete hosts
for each host in scope
refuse it if it is out of scope or private (and not allowed)
port scan; fetch the root over HTTPS then HTTP
inspect TLS where it is served
send the CORS, OPTIONS and plaintext-redirect probes
crawl same-scope links breadth-first within depth and budget
probe the well-known sensitive paths against a not-found baseline
optionally send the reflection marker
list source routes the crawl never reached
on any error, record a scan-error finding and go on
map findings to files and source lines; report sensitive files on disk
RULES
These rules come from the scan itself. The page, TLS, port and path rules are listed in "RULES" in App::WebPerimeterScan::Checks.
Rule id Severity Reported when
------------------------ -------- ---------------------------------------
scan-error high a host was refused (not listed, or
private without allow_private), or its
scan failed part way
host-unreachable high neither https:// nor http:// answered
at the root of the host
no-https-redirect medium the site is served over HTTPS, but
http:// does not answer with a 301,
302, 303, 307 or 308 to https://
(checked only when the scope line names
no port; port 80 closed is fine)
catch-all-200 info with path probes on: a random path
that cannot exist
answers 200, so the server answers
every path alike
reflected-input medium with reflection on: the marker sent
in the query came back unescaped
source-file-in-docroot medium with docroots: a file that a critical
path probe looks for is on disk
low ... a file that a high path probe
looks for is on disk
(files for medium and low probes are
not looked for on disk)
source-routes-unvisited info with source_dirs: routes in the code
that the crawl never reached
COMMON PITFALLS
Use a new object for each scan.
runadds to the same findings collection every time it is called, andhost_reportskeeps the last details for each host. A secondrunon the same object gives the findings of both runs together.A scope with only wildcard lines has nothing to scan.
*.example.comlets the crawler follow links to those hosts, but is not a starting point.rundies with "No concrete hosts in scope" when the scope lists no concrete host.rundies only for a missing scope or an empty one. A host that is refused (not listed, or private), cannot be reached or fails during the scan does not stop the scan. It is recorded as ascan-errororhost-unreachablefinding, at high severity, and the next host is scanned. Look for those findings before you trust a clean result.undefmeans "use the default".depth => undefis the same as leavingdepthout. To turn something off, pass 0, for examplepath_probes => 0.delayprotects the site. The default waits half a second between two requests to the same host. Do not set it to 0 for a real site.docrootsandsource_dirsare paths on this machine. They help only when this machine holds the files of the site being scanned. A path that does not exist is ignored without a warning.Private addresses are refused by default. To scan a test server on
127.0.0.1or on your local network, create the scope withApp::WebPerimeterScan::Scope->new(allow_private => 1), or pass--allow-privateto webperimeter.No findings is not proof of safety. The tool checks a fixed list of things. See "LIMITATIONS".
Progress messages go to STDERR. With
verbose => 1, the progress lines are printed to STDERR, so they never mix with a report on STDOUT.
ENCODING
The scanner works with bytes from start to end, and never decodes or encodes text itself. In practice:
Web pages in any language are fine. Page text, titles and header values are kept as the bytes the server sent. A UTF-8 page, including one with emoji, appears correctly in all three report formats.
Links with non-ASCII characters are followed correctly. A link written as raw UTF-8 and the same link written with
%escapes are the same URL to the crawler.International host names work in the scope file. Save the file as UTF-8 and write the names as they are; they are scanned under their ASCII (Punycode) form, which is also the form shown in the reports. See "ENCODING" in App::WebPerimeterScan::Scope.
File and directory names are bytes.
docroots,source_dirsand the paths in the findings are used exactly as the operating system gives them.Messages come from the catalogue. They are English and ASCII today. Error messages may contain the operating system's own text, in the language of your locale.
If you add your own findings or metadata, add bytes. Mixing Perl character strings with the scanner's byte strings encodes the bytes a second time. See "ENCODING" in App::WebPerimeterScan::Findings.
LIMITATIONS
These are the design weaknesses a reviewer should know about before relying on a clean report. Each is a reason the tool can miss something, or report something that is not there.
A clean report is not a clean site. The checks are passive and unauthenticated: no form is submitted, no login is attempted and no parameter other than the opt-in reflection marker is altered. Anything behind a login, or reachable only by a POST, is outside what this tool can see.
DNS can change between the check and the request. The scope is checked against the name, and each connection resolves the name again, so a name that resolves to a public address for the check and a private one for the request (DNS rebinding) defeats
--allow-private. The complete fix is to resolve once and connect to that address, carrying the name in SNI and theHostheader; the HTTP, TLS and port layers would all need to take an address.IPv4 only for the port scan. The private-address check considers IPv6 addresses, but the port scan connects over IPv4 only.
The crawl follows links, not the application. Script-built navigation, forms and API calls are not followed.
--sourcemitigates this by listing the routes the crawl never reached, but only for Mojolicious-style route declarations.Header rules are host-wide. A missing header is reported once per host, with the first page that lacked it as evidence. A site whose pages differ in policy shows only the first gap.
Multiple CSP headers are read as one policy. Browsers apply each policy separately, so a strict second policy can make a lax first one safe; the lax one is still reported.
Version rules trust banners. PHP2Shell and the OpenSSH check use the version the server announces. Back-ported fixes (as distributions ship) are reported as vulnerable; a hidden or faked banner is not reported at all.
Path probes are a fixed list. Eighteen well-known files are requested; a secret under any other name is only found by
--docroot.The docroot check is not tied to a host. A document root is a directory on this machine, and nothing says which host in the scope serves it. So a sensitive file that any host already served over the network is not reported again from the disk, even when the scope holds several sites and the docroot belongs to a different one. Scan one site at a time when you give
--docroot.Only English is catalogued. The message layer is ready for other languages (see App::WebPerimeterScan::I18N), but none ships.
Perl 5.26 is required because Params::Validate::Strict needs it. The code in this distribution would run on 5.14.
FORMAL SPECIFICATION
This section states exactly what each method does, in the Z notation. You do not need it to use the module: the method descriptions above say the same thing in words. It is here for reviewers and for anyone who writes tests.
How to read a schema box:
The part above the middle line declares names. A name that ends in
?is an input. A name that ends in!is an output.The part below the middle line lists facts that are true after the call.
A name with a dash after it, such as
items', is the value after the call. The same name without the dash is the value before the call.Delta X(written with the Greek capital delta) means the call may change the stateX.Xi X(the Greek capital xi) means the call does not change it.
The symbols used are:
Δ may change Ξ does not change • such that
∀ for all ∃ there is ¬ not
∧ and ∨ or ⇒ implies
⇔ if and only if ≠ not equal ≤ ≥ at most, at least
∈ is in ¬in; is not in ⊆ is a subset of
∪ union ∩ intersection ∅ the empty set
ℙ X sets of X ⊥ undefined (undef) … and so on
⟨⟩ the empty sequence ⁀ join sequences ⇸ partial mapping
↦ maps to ⊕ override a mapping ↾ keep only these keys
∘ compose → function × ÷ − times, divide, minus
ℕ 0, 1, 2, ... ℕ₁ 1, 2, 3, ... ℝ real numbers
𝔹 true or false ⌊x⌋ x rounded down
dom M the keys of M ran M the values of M #S the size of S
new
┌─ InitScanner ───────────────────────────────────────
│ Scanner'; args? : NAME ⇸ VALUE
├─────────────────────────────────────────────────────
│ dom args? ⊆ dom config
│ ∀ k ∈ dom config • setting'(k) = (config ⊕ args?)(k)
│ findings' = ∅ ∧ hosts' = ∅
│ source' ≠ ⊥ ⇔ docroots? ≠ ⟨⟩ ∨ source_dirs? ≠ ⟨⟩
└─────────────────────────────────────────────────────
findings
┌─ FindingsOf ────────────────────────────────────────
│ ΞScanner; result! : FINDINGS
├─────────────────────────────────────────────────────
│ result! = findings
└─────────────────────────────────────────────────────
http
┌─ HttpOf ────────────────────────────────────────────
│ ΞScanner; result! : HTTP
├─────────────────────────────────────────────────────
│ result! = http
└─────────────────────────────────────────────────────
host_reports
┌─ HostReports ───────────────────────────────────────
│ ΞScanner; result! : seq HOSTREPORT
├─────────────────────────────────────────────────────
│ ran result! = ran hosts
│ ∀ i : 1 .. #result! − 1 • result!(i).host < result!(i + 1).host
└─────────────────────────────────────────────────────
run
┌─ Run ───────────────────────────────────────────────
│ ΔScanner; result! : FINDINGS
├─────────────────────────────────────────────────────
│ scope ≠ ⊥ ∧ hosts(scope) ≠ ⟨⟩
│ contacted' ⊆ { h | allows(scope, h) }
│ ∀ e ∈ ran hosts(scope) •
│ scanned(e) ∨ (scan-error, e.host) ∈ keys(findings')
│ result! = findings'
└─────────────────────────────────────────────────────
STATE DIAGRAM
A scanner object is created READY. run takes it through the steps below for each host, then to DONE. findings, http and host_reports can be called in any state and change nothing.
The object:
new(scope, options)
| unknown option or bad value -> croak (no object)
v
+-------------+
| READY | findings empty; no traffic sent
+-------------+
|
| run()
| no scope -> croak "No scope was given"
| no concrete host -> croak "No concrete hosts in scope"
v
+-------------+
| SCANNING | each host in scope->hosts, in turn (below)
+-------------+
|
| every host done
v
+-------------+
| SOURCE PASS | only with docroots or source_dirs: add file and
+-------------+ source to findings; report sensitive files on disk
|
v
+-------------+
| DONE | run() returns the findings collection
+-------------+
|
| run() again: SCANNING again; new findings are added to
| the same collection (see COMMON PITFALLS)
v
(SCANNING)
One host, inside SCANNING. Any croak inside these steps is caught, and becomes a scan-error finding for that host:
+-------------------+
| AUTHORISE |--- not listed, or private and not allowed
+-------------------+ -> scan-error (high); next host
|
v
+-------------------+
| PORT SCAN | open ports and banners -> port findings
+-------------------+
|
v
+-------------------+
| FETCH ROOT |--- neither https:// nor http:// answered
+-------------------+ -> host-unreachable (high); next host
|
| the first that answered becomes the base URL
v
+-------------------+
| INSPECT TLS | on the HTTPS port, if one is open
+-------------------+
|
v
+-------------------+
| SAME-ORIGIN | CORS probe and OPTIONS probe
| PROBES |
+-------------------+
|
v
+-------------------+
| PLAIN HTTP HOP | HTTPS base and no port in scope only: does
+-------------------+ http:// redirect to https://?
|
v
+-------------------+
| CRAWL | breadth first, up to 'depth' links deep and
+-------------------+ 'max_urls' pages; only links that
| scope->allows; page rules on each page
v
+-------------------+
| PATH PROBES | if path_probes: the sensitive paths,
+-------------------+ compared with a not-found baseline
|
v
+-------------------+
| REFLECTION PROBE | if reflection: one GET with a marker
+-------------------+
|
v
+-------------------+
| UNVISITED ROUTES | if source_dirs: routes the crawl never
+-------------------+ reached
|
v
next host
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENCE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.