NAME
App::WebPerimeterScan::Findings - collect and order scan results
VERSION
Version 0.001.0
SYNOPSIS
Record a finding whose text comes from the message catalogue. This is what the scanner's own rules do:
use App::WebPerimeterScan::Findings;
my $findings = App::WebPerimeterScan::Findings->new;
$findings->report('csp-missing',
severity => 'medium',
host => 'example.com',
vars => { url => 'https://example.com/' },
);
Record a finding with your own text, for example from a site-specific check that has no catalogue entry:
$findings->add(
id => 'staging-banner',
severity => 'low',
host => 'example.com',
title => 'The staging banner is visible in production',
fix => 'Remove the banner from the production template.',
);
Print a summary, worst finding first:
printf "%d finding(s), %d medium\n",
$findings->count, $findings->counts->{medium};
for my $finding (@{ $findings->sorted }) {
print "$finding->{severity} $finding->{title}\n";
}
Use the findings as a gate in a deployment script:
my $serious = $findings->at_or_above('high');
if(@{$serious}) {
print STDERR scalar(@{$serious}), " serious finding(s); stopping\n";
exit 1;
}
DESCRIPTION
This module holds the results of a scan. Each result is called a finding: one problem, on one host, with a severity, a title and advice on how to fix it.
The collection does two jobs:
It removes duplicates. Two findings are the same when they have the same rule id, host, URL and
key. The second one is ignored.It sorts. The worst severity comes first. Findings with the same severity are sorted by host, then by title, then by URL. The order is always the same, so the reports from two runs can be compared line by line.
Each finding is a hash reference. It has every field named in @App::WebPerimeterScan::Findings::FIELDS, and every field is a string. A field that does not apply is the empty string, never undef, so code that reads a finding never has to test for undef.
The severities, from worst to least, are critical, high, medium, low and info.
SUBROUTINES/METHODS
new
Purpose: create an empty collection.
Args: none.
Returns: the new object.
Side Effects: none.
Usage: my $findings = App::WebPerimeterScan::Findings->new;
EXAMPLE
my $findings = App::WebPerimeterScan::Findings->new;
print $findings->count, "\n"; # 0
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }
MESSAGES
Message Meaning / resolution
------------------------------- -----------------------------------
Unknown parameter 'X' new() takes no arguments.
add
Purpose: record one finding with its text already rendered.
Args: named: id, severity (one of @SEVERITIES), host, url, key (an extra de-duplication component, not stored), title, detail, evidence, fix, reference, file, source. All are optional strings.
Returns: 1 if the finding was recorded, 0 if it duplicated one already held.
Side Effects: appends to the collection. An unknown severity is recorded as info with a warning, so a typo cannot silently hide a finding below the --fail-on threshold without anyone noticing.
Usage: $findings->add(id => 'x', severity => 'low', title => 'X')
EXAMPLE
my $added = $findings->add(
id => 'trace-enabled', severity => 'medium',
host => 'example.com', url => 'https://example.com/',
title => 'HTTP TRACE is enabled',
);
# A second identical call returns 0
API SPECIFICATION
INPUT
{
id => { type => 'string', optional => 1 },
severity => { type => 'string', optional => 1 },
host => { type => 'string', optional => 1 },
url => { type => 'string', optional => 1 },
key => { type => 'string', optional => 1 },
title => { type => 'string', optional => 1 },
detail => { type => 'string', optional => 1 },
evidence => { type => 'string', optional => 1 },
fix => { type => 'string', optional => 1 },
reference => { type => 'string', optional => 1 },
file => { type => 'string', optional => 1 },
source => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
MESSAGES
Message Meaning / resolution
----------------------------------------- -------------------------------
Unknown severity 'S' for finding 'I'; The severity is not one of
recorded as info (warning) critical/high/medium/low/info.
Unknown parameter 'X' (fatal) A misspelt argument name.
PSEUDOCODE
validate the arguments
default the id; warn and default an unknown severity to info
build the de-duplication key from id, host, url and key
return 0 if the key has been seen
store every field, empty string where not given
return 1
report
Purpose: record a finding whose text comes from the message catalogue, so a rule only states its id, severity and the values that vary.
Args: $id (the rule id), then named: severity, host, url, key, reference, vars (hashref of placeholder values), evidence (raw evidence; overrides the .evidence template), message (the catalogue base key, when it differs from $id - for example port-open for port-open-3306) and rule (a hash reference with default severity and reference, overridden by those arguments).
Returns: as add.
Side Effects: as add. confesses if the catalogue has no .title or .detail for the rule.
Usage: $findings->report('tls-expired', severity => 'high', host => $h, vars => { days => 3 })
EXAMPLE
$findings->report('port-open-3306',
message => 'port-open',
severity => 'high',
host => 'db.example.com',
vars => { port => 3306, has_banner => 'no' },
);
API SPECIFICATION
INPUT
{
id => { type => 'string', min => 1, position => 0 },
severity => { type => 'string', optional => 1 },
host => { type => 'string', optional => 1 },
url => { type => 'string', optional => 1 },
key => { type => 'string', optional => 1 },
reference => { type => 'string', optional => 1 },
evidence => { type => 'string', optional => 1 },
message => { type => 'string', optional => 1 },
vars => { type => 'hashref', optional => 1 },
rule => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
MESSAGES
Message Meaning / resolution
----------------------------------- -----------------------------------
No message is defined for "K" The rule has no catalogue text; add
(fatal, with stack trace) K to App::WebPerimeterScan::Messages.
annotate
Purpose: attach the file on disk or the line of source behind a finding, once the scan is complete.
Args: $finding (a hash reference from all or sorted), then named file and/or source (strings).
Returns: $self, for chaining.
Side Effects: changes the finding in place. Only file and source can be changed; the identifying fields are fixed once recorded.
Usage: $findings->annotate($finding, file => '/srv/www/.env')
EXAMPLE
for my $finding (@{ $findings->all }) {
$findings->annotate($finding, source => 'lib/App.pm:42')
if $finding->{url} =~ m{/api/};
}
API SPECIFICATION
INPUT
{
finding => { type => 'hashref', position => 0 },
file => { type => 'string', optional => 1 },
source => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }
MESSAGES
Message Meaning / resolution
---------------------------------------- -------------------------------
annotate() was given a finding that this Pass a finding obtained from this
collection did not record (fatal) object's all() or sorted().
all
Purpose: the findings in the order they were recorded.
Args: none.
Returns: an array reference (a new array; the findings inside are shared).
Side Effects: none.
Usage: for my $f (@{ $findings->all }) { ... }
EXAMPLE
my @hosts = map { $_->{host} } @{ $findings->all };
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref', element_type => 'hashref' }
MESSAGES
None.
count
Purpose: the number of findings held.
Args: none.
Returns: a non-negative integer.
Side Effects: none.
Usage: print $findings->count
EXAMPLE
printf "%d finding(s)\n", $findings->count;
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'integer', min => 0 }
MESSAGES
None.
sorted
Purpose: the findings worst first, then by host, title and URL, so the output of two runs can be diffed.
Args: none.
Returns: an array reference.
Side Effects: none.
Usage: for my $f (@{ $findings->sorted }) { ... }
EXAMPLE
my $worst = $findings->sorted->[0];
print "Worst: $worst->{title}\n" if $worst;
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref', element_type => 'hashref' }
MESSAGES
None.
counts
Purpose: how many findings there are at each severity.
Args: none.
Returns: a hash reference with every severity as a key, zero where none.
Side Effects: none.
Usage: my $high = $findings->counts->{high};
EXAMPLE
my $counts = $findings->counts;
print "$counts->{critical} critical\n";
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'hashref' } # keys: critical high medium low info
MESSAGES
None.
rank
Purpose: the numeric rank of a severity name, higher is worse.
Args: $severity (string). A function, not a method.
Returns: 5 for critical down to 1 for info; 0 for anything else.
Side Effects: none.
Usage: App::WebPerimeterScan::Findings::rank('high')
EXAMPLE
my @worst_first = sort {
App::WebPerimeterScan::Findings::rank($b) <=> App::WebPerimeterScan::Findings::rank($a)
} qw(low critical medium);
API SPECIFICATION
INPUT
{ severity => { type => 'string', optional => 1, position => 0 } }
OUTPUT
{ type => 'integer', min => 0, max => 5 }
MESSAGES
None.
at_or_above
Purpose: the findings at or above a severity, which is what decides the exit status of the command-line tool.
Args: $threshold, a severity name.
Returns: an array reference; empty for an unknown threshold.
Side Effects: none.
Usage: exit 1 if @{ $findings->at_or_above('high') };
EXAMPLE
my $failing = $findings->at_or_above('medium');
printf "%d finding(s) at medium or worse\n", scalar @{$failing};
API SPECIFICATION
INPUT
{ threshold => { type => 'string', position => 0 } }
OUTPUT
{ type => 'arrayref', element_type => 'hashref' }
MESSAGES
None.
COMMON PITFALLS
A misspelt threshold matches nothing.
at_or_above('hihg')returns an empty list, with no warning, so a gate written asexit 1 if @{ $findings->at_or_above($level) }would pass. Check the level first withrank($level), which returns 0 for an unknown severity. The webperimeter command does this for--fail-on.A misspelt severity becomes
info.addandreportrecord a finding with an unknown severity asinfoand print a warning. The finding is kept, but it is now below most--fail-onlevels.undefis stored as the empty string. Passinghost => undefis the same as not passinghost. The only field that is filled in for you istitle, which becomes the rule id when it is empty.An
undefplaceholder value is shown, not hidden. Inreport,vars => { url => undef }leaves the text{url}in the message. This is on purpose: a visible{url}shows that a value was missing.allandsortedreturn a new list, but the same findings. Adding to or removing from the returned array does not change the collection. Changing a field of a finding in that array does change the finding in the collection. Useannotateto changefileandsource, and do not change other fields.The de-duplication key is exact.
https://example.com/andhttps://example.comare different URLs, so the same problem on both is two findings. Thekeyargument is part of the key but is not stored.
ENCODING
This module does not decode or encode text. It stores exactly the strings it is given.
Any byte string is safe in any field, including UTF-8 text in any language and emoji. The scanner itself always passes byte strings: text taken from a web page is the bytes the server sent.
Perl character strings (text with characters above 255, for example after
decode('UTF-8', ...)) are also stored correctly, but the report functions in App::WebPerimeterScan::Report then return a character string. Printing it to a handle with no:encodinglayer gives a "Wide character" warning. Either encode the text to UTF-8 bytes beforeadd, or setbinmode STDOUT, ':encoding(UTF-8)'before printing.Do not mix the two kinds of string in one collection. A report that joins UTF-8 bytes with character strings encodes the bytes a second time, and
éis printed asé.Sorting compares strings with Perl's
cmp, which orders by character number, not by the rules of any language.
LIMITATIONS
Findings are plain hash references, so code holding one can change any field, not only through
annotate. Returning copies would stop that but would also breakannotate, which needs the original; a finding class with read-only accessors is the complete fix and is left for a later release.De-duplication is exact: the same problem reported under two URLs is two findings, which is deliberate for page-level rules and noisy for host-level ones. Rules that are about the host pass an empty
url.
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
┌─ InitFindings ──────────────────────────────────────
│ Findings'
├─────────────────────────────────────────────────────
│ items' = ⟨⟩ ∧ seen' = ∅
└─────────────────────────────────────────────────────
add
┌─ Add ───────────────────────────────────────────────
│ ΔFindings
│ id?, severity?, host?, url?, key? : STRING; result! : {0, 1}
├─────────────────────────────────────────────────────
│ k = (id?, host?, url?, key?)
│ sev = if severity? ∈ SEVERITY then severity? else info
│ k ∈ seen ⇒ items' = items ∧ seen' = seen ∧ result! = 0
│ k ¬in; seen ⇒ items' = items ⁀ ⟨finding(id?, sev, …)⟩
│ ∧ seen' = seen ∪ {k} ∧ result! = 1
└─────────────────────────────────────────────────────
report
┌─ Report ────────────────────────────────────────────
│ ΔFindings; ΞCatalogue
│ id?, base? : KEY; vars? : NAME ⇸ VALUE
├─────────────────────────────────────────────────────
│ b = if base? defined then base? else id?
│ b ⁀ ".title" ∈ dom catalogue ∧ b ⁀ ".detail" ∈ dom catalogue
│ Add[title? := i18n(b ⁀ ".title", vars?),
│ detail? := i18n(b ⁀ ".detail", vars?)]
└─────────────────────────────────────────────────────
annotate
┌─ Annotate ──────────────────────────────────────────
│ ΔFindings; f? : FINDING; file?, source? : STRING
├─────────────────────────────────────────────────────
│ f? ∈ ran items
│ items' = items ⊕ {f? ↦ f? ⊕ {file ↦ file?, source ↦ source?}}
└─────────────────────────────────────────────────────
all
┌─ All ───────────────────────────────────────────────
│ ΞFindings; result! : seq FINDING
├─────────────────────────────────────────────────────
│ result! = items
└─────────────────────────────────────────────────────
count
┌─ Count ─────────────────────────────────────────────
│ ΞFindings; result! : ℕ
├─────────────────────────────────────────────────────
│ result! = #items
└─────────────────────────────────────────────────────
sorted
┌─ Sorted ────────────────────────────────────────────
│ ΞFindings; result! : seq FINDING
├─────────────────────────────────────────────────────
│ items bag_of result! ∧ #result! = #items
│ ∀ i : 1 .. #result! − 1 • key(result! i) ≤ key(result! (i + 1))
│ key(f) = (−rank(f.severity), f.host, f.title, f.url)
└─────────────────────────────────────────────────────
counts
┌─ Counts ────────────────────────────────────────────
│ ΞFindings; result! : SEVERITY → ℕ
├─────────────────────────────────────────────────────
│ ∀ s : SEVERITY • result! s = #{ f : ran items | f.severity = s }
└─────────────────────────────────────────────────────
rank
┌─ Rank ──────────────────────────────────────────────
│ severity? : STRING; result! : 0 .. 5
├─────────────────────────────────────────────────────
│ severity? ∈ dom RANK ⇒ result! = RANK(severity?)
│ severity? ¬in; dom RANK ⇒ result! = 0
└─────────────────────────────────────────────────────
at_or_above
┌─ AtOrAbove ─────────────────────────────────────────
│ ΞFindings; threshold? : STRING; result! : seq FINDING
├─────────────────────────────────────────────────────
│ threshold? ¬in; dom RANK ⇒ result! = ⟨⟩
│ threshold? ∈ dom RANK ⇒
│ result! = items ↾ { f | RANK(f.severity) ≥ RANK(threshold?) }
└─────────────────────────────────────────────────────
STATE DIAGRAM
A collection has one piece of state: the list of findings it holds, and the set of de-duplication keys it has seen. It is either empty or it holds one or more findings. Nothing ever removes a finding.
new()
|
v
+-----------------+
| EMPTY | count = 0
+-----------------+
| | |
| | | all, sorted, counts, count,
| | | at_or_above, rank
| | +-----> (no change; empty results)
| |
| | annotate(any finding)
| +-----> croak: the finding is not in this
| collection (no change)
|
| add / report with a new key
| -> finding appended; key remembered; returns 1
v
+-----------------+
+---->| HOLDING | count = n, n >= 1
| +-----------------+
| | | | |
| | | | +--- all, sorted, counts, count,
| | | | at_or_above, rank
| | | | -> (no change; new list returned)
| | | |
| | | +-------- annotate(a held finding)
| | | -> file / source fields set;
| | | returns the collection
| | |
| | +------------- add / report with a key already seen
| | -> (no change); returns 0
| |
+-------+ add / report with a new key
-> finding appended (count = n + 1); returns 1
Unknown severity on add / report: a warning is printed and the
finding is recorded as 'info'. The state change is otherwise the
same as for a new key.
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.