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 as exit 1 if @{ $findings->at_or_above($level) } would pass. Check the level first with rank($level), which returns 0 for an unknown severity. The webperimeter command does this for --fail-on.

  • A misspelt severity becomes info. add and report record a finding with an unknown severity as info and print a warning. The finding is kept, but it is now below most --fail-on levels.

  • undef is stored as the empty string. Passing host => undef is the same as not passing host. The only field that is filled in for you is title, which becomes the rule id when it is empty.

  • An undef placeholder value is shown, not hidden. In report, vars => { url => undef } leaves the text {url} in the message. This is on purpose: a visible {url} shows that a value was missing.

  • all and sorted return 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. Use annotate to change file and source, and do not change other fields.

  • The de-duplication key is exact. https://example.com/ and https://example.com are different URLs, so the same problem on both is two findings. The key argument 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 :encoding layer gives a "Wide character" warning. Either encode the text to UTF-8 bytes before add, or set binmode 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 break annotate, 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 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

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.