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:

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:

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

Encoding

The scanner works with bytes from start to end, and never decodes or encodes text itself. In practice:

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.

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 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                 ∉   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>

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms. If you use it, please let me know.