NAME
App::WebPerimeterScan::Ports - parallel TCP connect scan
VERSION
Version 0.001.0
SYNOPSIS
Scan the default list of ports and print what answered:
use App::WebPerimeterScan::Ports;
my $scan = App::WebPerimeterScan::Ports->scan(host => 'example.com');
die "scan failed: $scan->{error}\n" if $scan->{error};
for my $open (@{ $scan->{open} }) {
print "$open->{port} $open->{banner}\n";
}
Check only a few ports, quickly, without reading greetings:
my $web = App::WebPerimeterScan::Ports->scan(
host => 'example.com',
ports => [ 80, 443, 8443 ],
timeout => 1,
banner => 0,
);
my %is_open = map { $_->{port} => 1 } @{ $web->{open} };
print "HTTPS is reachable\n" if $is_open{443};
Check the scope before you scan. This module does not do it for you:
$scope->assert_target_allowed($host);
my $result = App::WebPerimeterScan::Ports->scan(host => $host);
DESCRIPTION
This module finds out which TCP ports on a host accept a connection. It tries to connect to each port. It does not send any data except to read a greeting.
All the connections are started at the same time, and the module waits for all of them together. So a scan of the whole default list takes about one timeout, not one timeout for each port.
For each open port, the module then makes one more short connection and reads the first line the service sends, if it sends one. This line is called the banner. A banner such as SSH-2.0-OpenSSH_7.4 often tells you the software and its version, which is how an old, unpatched service is found.
The default list of ports is in @App::WebPerimeterScan::Ports::DEFAULT_PORTS. It contains the usual web, mail, file-sharing, remote-desktop and database ports. %App::WebPerimeterScan::Ports::RISKY gives the severity of each port that should not be open to the internet.
REQUIREMENTS
Socket 2.020: the core of 5.10.1 ships 2.018, and SO_ERROR is what separates a refused connection from an open one. IO::Select 1.17 and IO::Socket::INET 1.31 are the versions this is tested against.
The parallel path needs a working fcntl(F_SETFL) and O_NONBLOCK. Where they are missing, as on Windows, the scan connects one port at a time instead.
SUBROUTINES/METHODS
scan
Purpose: find which of a list of TCP ports accept a connection.
Args: named host (required), ports (array reference; default @DEFAULT_PORTS), timeout (seconds for the whole batch; default 3) and banner (boolean, read a greeting from each open port; default 1). A class method.
Returns: a hash reference: host, open (array reference of { port => N, banner => TEXT }, sorted by port) and error (undef, or why the scan could not run). Port numbers outside 1-65535 are ignored.
Side Effects: TCP connections to the host; a second short connection to each open port when banner is set.
Usage: my $scan = App::WebPerimeterScan::Ports->scan(host => $h, ports => [22, 443]);
EXAMPLE
my $scan = App::WebPerimeterScan::Ports->scan(
host => 'example.com', ports => [ 22, 80, 443 ], timeout => 2,
);
die "$scan->{error}\n" if $scan->{error};
print join(', ', map { $_->{port} } @{ $scan->{open} }), "\n";
API SPECIFICATION
INPUT
{
host => { type => 'string', min => 1 },
ports => { type => 'arrayref', element_type => 'integer', optional => 1 },
timeout => { type => 'number', min => 0, optional => 1 },
banner => { type => 'boolean', optional => 1 },
}
OUTPUT
{
type => 'hashref',
schema => {
host => { type => 'string' },
open => { type => 'arrayref' },
error => { type => 'string', optional => 1 },
},
}
MESSAGES
Returned in error, not thrown:
Message Meaning / resolution
------------------------ -------------------------------------------
cannot resolve 'H' H has no IPv4 address; check the name.
no tcp protocol The system has no "tcp" protocol entry.
PSEUDOCODE
validate the arguments; apply defaults from %config
resolve the host to an IPv4 address, or return an error
keep only port numbers in 1..65535
connect to them all in parallel where fcntl allows, else one at a time
sort the open ports
read a greeting from each open port if banners were asked for
COMMON PITFALLS
The scope is not checked here.
scanconnects to any host it is given. Call$scope->assert_target_allowed($host)first. The main scanner, App::WebPerimeterScan, always does.Not in
opendoes not mean closed. A port that refused the connection and a port behind a firewall that dropped it look the same: both are missing fromopen.Check
errorfirst. When the host name cannot be resolved,errorsays so andopenis empty. An emptyopenwith noerrormeans that the scan ran and nothing answered.Bad port numbers are dropped without a warning. Ports outside 1 to 65535 are ignored. If no valid port is left,
openis empty.timeoutis for the whole batch. It is not for each port. A long list on a slow network may need a larger value.The banner is an empty string, not
undef, when there is none. Many services (HTTP, for example) wait for the client to speak first and send no greeting. Withbanner => 0every banner is empty.A banner is a claim, not a fact. A service can send any text. Use it as a hint about the software, not as proof of the version.
It is a class method. Call it as
App::WebPerimeterScan::Ports->scan(...). There is no object to create.
ENCODING
hostmust be an ASCII host name or an IPv4 address. Write an international name in its Punycode form.Banners are cleaned so that they are always safe to print: line breaks become spaces, every byte that is not printable ASCII is removed, and the text is cut to 120 characters. So a banner never contains UTF-8, emoji or control characters, and the non-ASCII part of a greeting is lost.
erroris a message for people, from the message catalogue. Do not compare it with a fixed string.
LIMITATIONS
IPv4 only: the host is resolved with
inet_aton, so a host that is reachable only over IPv6 reports an error and a dual-stack host is only scanned on its IPv4 address.A connect scan cannot tell "filtered" from "closed": both are simply absent from
open.The banner is read on a second connection, so a service that only answers the first connection, or rate-limits connections, may show none.
The whole batch shares one timeout, so a very long port list on a slow link can time out ports that a longer wait would have found open.
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
scan
┌─ Scan ──────────────────────────────────────────────
│ host? : HOST; ports? : ℙ PORT; result! : SCANRESULT
├─────────────────────────────────────────────────────
│ resolve(host?) = ∅ ⇒ result!.error ≠ ⊥ ∧ result!.open = ⟨⟩
│ resolve(host?) ≠ ∅ ⇒
│ ran(port ∘ result!.open) =
│ { p : ports? ∩ 1 .. 65535 | handshake(host?, p) completes
│ within timeout }
│ ∧ result!.open is sorted by port
└─────────────────────────────────────────────────────
STATE DIAGRAM
The module keeps no state between calls. The diagram shows the life of one call to scan, and then the states each port can be in during it.
The call:
scan(host, ports, timeout, banner)
|
v
+-------------+
| VALIDATE |--- unknown argument, or no host
+-------------+ -> croak
|
v
+-------------+
| RESOLVE |--- the name has no IPv4 address
+-------------+ -> result { error, open => [] }
|
| ports outside 1..65535 removed
v
+-------------+
| CONNECT | all ports at once (non-blocking), or one at a
+-------------+ time where non-blocking sockets are not available
|
| each port ends as OPEN or NOT OPEN (below)
v
+-------------+
| BANNERS | only when banner = 1: one more connection to
+-------------+ each OPEN port; read for up to 0.6 seconds
|
v
result { host, open => [ { port, banner }, ... ] sorted by port }
Each port during CONNECT:
connect started
|
v
+-------------+
| PENDING |
+-------------+
| | |
handshake done, | | | timeout reached
no socket error | | | (no answer: often a firewall)
| | +--------------------+
v | v
+-----------+ | refused or +-------------+
| OPEN | | other error | NOT OPEN |
+-----------+ +----------------->+-------------+
|
| banner = 1
v
+-----------+
| BANNER | first line of the greeting, cleaned;
| READ | empty when the service says nothing
+-----------+
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.