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 with diff.

  • 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, ...). Writing App::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 is scope_source.

  • Colour codes in a file. When color is not given, the text report uses colour if STDOUT is a terminal, even if you write the report to a file. Pass color => 0 when the report is not for the screen.

  • undef metadata is the same as leaving it out. For example, requests => undef is 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 when hosts is 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_json reads 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 color to 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 state X. 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.