NAME
App::WebPerimeterScan::Report - text, JSON and HTML output
VERSION
Version 0.001.0
SYNOPSIS
Print a text report after a scan:
use App::WebPerimeterScan::Report;
my %meta = (
version => $App::WebPerimeterScan::VERSION,
generated => '2026-11-05T10:00:00Z',
scope_source => 'scope.txt',
requests => $scanner->http->requests,
hosts => $scanner->host_reports,
);
print App::WebPerimeterScan::Report::as_text($scanner->findings, %meta);
Write the text report to a file, without colour codes:
open my $out, '>', 'report.txt';
print $out App::WebPerimeterScan::Report::as_text($findings, %meta, color => 0);
close $out;
Save a JSON report for another program:
open my $json, '>', 'report.json';
print $json App::WebPerimeterScan::Report::as_json($findings, %meta);
close $json;
Make an HTML page to send by e-mail:
my $page = App::WebPerimeterScan::Report::as_html($findings, %meta);
The metadata is optional. The smallest call is:
print App::WebPerimeterScan::Report::as_text($findings);
DESCRIPTION
This module turns the findings of a scan into a report. There are three formats:
Text (
as_text), for a terminal or a log. It can use colour.JSON (
as_json), for other programs. The keys are always in the same order, so two reports can be compared withdiff.HTML (
as_html), a single page with its own style sheet and no JavaScript, safe to open from an e-mail.
Each function takes the findings collection first, then optional metadata, given as name => value pairs:
version the version of the scanner
generated when the scan ran, as text
scope_source the name of the scope file
requests how many HTTP requests were sent
hosts the list from App::WebPerimeterScan->host_reports
color text only: 1 for colour, 0 for none
All three formats list the same findings in the same order, because the order comes from App::WebPerimeterScan::Findings. The labels (such as "Fix:") come from the message catalogue. The text of each finding was written when the finding was recorded.
REQUIREMENTS
JSON::PP 4.00 for the JSON report and HTML::Entities (part of HTML::Parser) for the HTML report. Term::ANSIColor is loaded only when colour is used.
SUBROUTINES/METHODS
as_text
Purpose: the report for a terminal or a plain-text file.
Args: $findings, then the named metadata above. color forces colour on or off; when it is not given, colour is used only if STDOUT is a terminal.
Returns: the report as a string.
Side Effects: none (reads whether STDOUT is a terminal when color is not given).
Usage: print as_text($findings, %meta);
EXAMPLE
my $text = App::WebPerimeterScan::Report::as_text(
$findings, version => '0.001.0', generated => '2026-11-05T00:00:00Z',
scope_source => 'scope.txt', color => 0,
);
API SPECIFICATION
INPUT
{
findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
version => { type => 'string', optional => 1 },
generated => { type => 'string', optional => 1 },
scope_source => { type => 'string', optional => 1 },
requests => { type => 'integer', min => 0, optional => 1 },
hosts => { type => 'arrayref', optional => 1 },
color => { type => 'boolean', optional => 1 },
}
OUTPUT
{ type => 'string' }
MESSAGES
Message Meaning / resolution
---------------------------------------- -------------------------------
Parameter 'findings' must be an object Pass an App::WebPerimeterScan::
... (fatal) Findings as the first argument.
PSEUDOCODE
write the header: tool, version, time, scope, request count
for each host: TLS line, certificate, open ports, pages scanned
if there are no findings, say so (and that it is not proof) and stop
for each finding, worst first
start a new severity section when the severity changes
write the id (coloured), title, URL, file, source, detail,
evidence, fix and reference
write the summary counts
as_json
Purpose: the machine-readable report.
Args: $findings, then the named metadata above (color is ignored).
Returns: canonical, pretty-printed JSON, with keys tool, version, generated, scope, requests, summary (counts by severity), hosts and findings (worst first, each with every field in @App::WebPerimeterScan::Findings::FIELDS).
Side Effects: none.
Usage: print as_json($findings, %meta);
EXAMPLE
my $data = JSON::PP::decode_json(App::WebPerimeterScan::Report::as_json($findings, %meta));
print "$data->{summary}{high} high\n";
API SPECIFICATION
INPUT
{
findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
version => { type => 'string', optional => 1 },
generated => { type => 'string', optional => 1 },
scope_source => { type => 'string', optional => 1 },
requests => { type => 'integer', min => 0, optional => 1 },
hosts => { type => 'arrayref', optional => 1 },
color => { type => 'boolean', optional => 1 }, # accepted and ignored
}
OUTPUT
{ type => 'string', matches => qr/\A\{/ }
MESSAGES
As as_text.
as_html
Purpose: a self-contained HTML page for a browser or an e-mail.
Args: $findings, then the named metadata above (color is ignored).
Returns: the page as a string.
Side Effects: none. Every value interpolated into the page is escaped, including the version and timestamp, so a host name, a header value or the caller's metadata cannot inject markup.
Usage: print as_html($findings, %meta);
EXAMPLE
open my $fh, '>', 'report.html';
print $fh App::WebPerimeterScan::Report::as_html($findings, %meta);
close $fh;
API SPECIFICATION
INPUT
{
findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
version => { type => 'string', optional => 1 },
generated => { type => 'string', optional => 1 },
scope_source => { type => 'string', optional => 1 },
requests => { type => 'integer', min => 0, optional => 1 },
hosts => { type => 'arrayref', optional => 1 },
color => { type => 'boolean', optional => 1 }, # accepted and ignored
}
OUTPUT
{ type => 'string', matches => qr/\A<!DOCTYPE html>/ }
MESSAGES
As as_text.
PSEUDOCODE
escape every value before it is placed in the page
build a pill per severity that has findings
build a host row per host: TLS summary, open ports, pages scanned
build a finding row per finding, worst first; one "no findings" row if none
assemble the page from the catalogue's labels and the fixed stylesheet
COMMON PITFALLS
These are functions, not methods. Call
App::WebPerimeterScan::Report::as_text($findings, ...). WritingApp::WebPerimeterScan::Report->as_text($findings)passes the class name as the first argument, and the call dies.A misspelt metadata name is fatal.
scope => 'x'dies with "Unknown parameter"; the name isscope_source.Colour codes in a file. When
coloris not given, the text report uses colour if STDOUT is a terminal, even if you write the report to a file. Passcolor => 0when the report is not for the screen.undefmetadata is the same as leaving it out. For example,requests => undefis accepted, and no request count is shown.Without
hosts, the per-host details are missing. The TLS summary, the open ports and the list of pages appear only whenhostsis given. The findings are listed either way.The reports return a string; they do not print. Print the result yourself.
ENCODING
The output has the same kind of string as the input. The scanner gives byte strings (UTF-8 from web pages stays as UTF-8 bytes), so the reports are byte strings with correct UTF-8. Print them with no encoding layer.
If you give character strings, the result is a character string; print it through
:encoding(UTF-8). Do not mix the two kinds; see "ENCODING" in App::WebPerimeterScan::Findings.HTML: the page declares
<meta charset="utf-8">. Every value is escaped with HTML::Entities, so<,>,&and quotes cannot break the page. Other characters, including non-ASCII UTF-8 bytes, are copied unchanged. The page relies on its charset declaration to show them correctly.JSON: non-ASCII bytes are copied into the output unchanged, so UTF-8 input gives valid UTF-8 JSON, which
decode_jsonreads back.Text: printed as it is. A terminal that is not set to UTF-8 shows non-ASCII text wrongly, but the bytes in the report are correct.
LIMITATIONS
The text report wraps nothing: a long title or evidence line is one long line.
Colour is decided by whether STDOUT is a terminal, even when the caller will write the string somewhere else; pass
colorto be explicit.The HTML report embeds its stylesheet and has no script, so it is safe to open from e-mail, but it cannot sort or filter.
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
as_text
┌─ AsText ────────────────────────────────────────────
│ ΞFindings; meta? : META; result! : TEXT
├─────────────────────────────────────────────────────
│ header(meta?) prefix_of result!
│ ∀ h ∈ ran meta?.hosts • host_block(h) in result!
│ items = ⟨⟩ ⇒ clean_message suffix_of result!
│ items ≠ ⟨⟩ ⇒ ∀ f ∈ ran sorted • "[" ⁀ f.id ⁀ "]" in result!
│ ∧ summary(counts) suffix_of result!
└─────────────────────────────────────────────────────
as_json
┌─ AsJson ────────────────────────────────────────────
│ ΞFindings; meta? : META; result! : JSON
├─────────────────────────────────────────────────────
│ decode(result!).findings = ⟨ f ↾ FIELDS | f ∈ sorted ⟩
│ decode(result!).summary = counts
│ decode(result!).hosts = meta?.hosts
└─────────────────────────────────────────────────────
as_html
┌─ AsHtml ────────────────────────────────────────────
│ ΞFindings; meta? : META; result! : HTML
├─────────────────────────────────────────────────────
│ ∀ v ∈ values(meta?) ∪ values(ran items) •
│ escape(v) in result! ∧ (v ≠ escape(v) ⇒ ¬ (v in result!))
│ #rows(result!) = max(1, #items)
└─────────────────────────────────────────────────────
STATE DIAGRAM
The module keeps no state. Each function reads the findings and the metadata and returns a new string; nothing is changed. Every format contains the parts below, but each one lays them out in its own way.
findings + metadata
|
v
+----------------+
| VALIDATE |--- the first argument is not a findings
+----------------+ collection, or a metadata name is unknown
| -> croak
v
+----------------+
| HEADER | tool, version, time, scope file, request count
+----------------+
|
v
+----------------+
| HOSTS | for each entry in 'hosts': TLS summary,
+----------------+ certificate, open ports, pages scanned
|
v
+----------------+
| FINDINGS | in the order of findings->sorted; or the
+----------------+ "no findings" message
|
v
+----------------+
| SUMMARY | the count at each severity
+----------------+
|
+--------------+---------------+
| as_text | as_json | as_html
v v v
lines of text, one JSON one HTML page; every
coloured if object, keys value escaped
'color' is on sorted
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.