NAME

App::WebPerimeterScan::Checks - the rule set

VERSION

Version 0.001.0

SYNOPSIS

Check one page that you have already fetched:

use App::WebPerimeterScan::Checks;
use App::WebPerimeterScan::Findings;
use App::WebPerimeterScan::HTTP;

my $http     = App::WebPerimeterScan::HTTP->new;
my $findings = App::WebPerimeterScan::Findings->new;
my $url      = 'https://example.com/';

App::WebPerimeterScan::Checks::check_page($findings,
	host => 'example.com', url => $url, res => $http->fetch($url));

print "$_->{severity} $_->{title}\n" for @{ $findings->sorted };

Check the TLS service and the open ports:

App::WebPerimeterScan::Checks::check_tls($findings,
	host => 'example.com',
	tls  => App::WebPerimeterScan::TLS->inspect(host => 'example.com'));
App::WebPerimeterScan::Checks::check_ports($findings,
	host => 'example.com',
	scan => App::WebPerimeterScan::Ports->scan(host => 'example.com'));

Test a CORS policy with the probe origin:

my $origin = App::WebPerimeterScan::Checks::probe_origin();
my $res = $http->fetch($url, headers => [ "Origin: $origin" ]);
App::WebPerimeterScan::Checks::check_cors($findings,
	host => 'example.com', url => $url, res => $res);

Make the thresholds stricter, for every later check:

$App::WebPerimeterScan::Checks::config{tls_expiry_warn_days} = 60;
$App::WebPerimeterScan::Checks::config{ssh_min_version}      = '9.6';

DESCRIPTION

This module holds the rules that decide what is a problem. Each rule has a short id, such as csp-missing; "RULES" lists them all.

Each check_* function looks at data that was already collected (a response, a TLS result or a port scan) and adds a finding to the collection for each problem it sees. The only function that sends network traffic itself is check_paths, which requests a fixed list of well-known paths.

None of the checks attacks the site. They read headers, read the page, read the certificate, and ask for files that a correct site answers with "404 Not Found".

Every check_* function returns the collection it was given. The words of each finding are in App::WebPerimeterScan::Messages; this module holds only the rule ids, their severities and the logic.

The thresholds that a site owner might want to change are in %App::WebPerimeterScan::Checks::config:

hsts_min_max_age      15552000   HSTS max-age below this is short
                                 (180 days, in seconds)
tls_expiry_warn_days  30         report a certificate this close to
                                 expiry
tls_expiry_high_days  14         ... as high severity below this
ssh_min_version       9.0        older OpenSSH banners are reported
excerpt_length        200        bytes of a page kept as evidence
mixed_content_limit   5          resources named in one finding
robots_limit          5          robots.txt paths named as evidence

SUBROUTINES/METHODS

check_page

Purpose: the per-page rules: security headers, cookies, information disclosure, directory listings, error pages and mixed content.

Args: $findings, then named host, url and res (a response from "fetch" in App::WebPerimeterScan::HTTP).

Returns: $findings.

Side Effects: adds findings. A response with error set is ignored.

Usage: check_page($findings, host => $host, url => $url, res => $res);

EXAMPLE

my $findings = App::WebPerimeterScan::Findings->new;
App::WebPerimeterScan::Checks::check_page($findings,
	host => 'example.com',
	url  => 'https://example.com/',
	res  => { status => 200, headers => {}, body => '', set_cookie => [] },
);
# hsts-missing, csp-missing, clickjacking, nosniff-missing,
# referrer-policy-missing, permissions-policy-missing

API SPECIFICATION

INPUT

{
	findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
	host     => { type => 'string', optional => 1 },
	url      => { type => 'string', optional => 1 },
	res      => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }

MESSAGES

Message                                   Meaning / resolution
----------------------------------------  -----------------------------
Parameter 'findings' must be an object    The first argument must be an
... (fatal)                               App::WebPerimeterScan::Findings.

The findings it can add are listed in "RULES".

PSEUDOCODE

validate; return unless there is a successful response
parse the HTML once (title, generator meta tag, loaded resources)
if the page is HTTPS, check HSTS
check CSP, framing, nosniff, Referrer-Policy and Permissions-Policy
check Server, X-Powered-By (and the PHP version in it), generator
check every Set-Cookie for Secure, HttpOnly and SameSite
check for a directory listing, a verbose error, and mixed content

check_cors

Purpose: report a CORS policy that lets an untrusted origin read responses. The response must come from a request that sent Origin: set to probe_origin().

Args: $findings, then named host, url and res.

Returns: $findings.

Side Effects: adds at most one cors-misconfiguration finding: high when credentials are allowed with a wildcard or reflected origin, medium when the probe origin is reflected, low for a plain wildcard.

Usage: check_cors($findings, host => $h, url => $u, res => $res);

EXAMPLE

App::WebPerimeterScan::Checks::check_cors($findings,
	host => 'example.com', url => 'https://example.com/',
	res  => { headers => { 'access-control-allow-origin' => ['*'] } },
);

API SPECIFICATION

INPUT

{
	findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
	host     => { type => 'string', optional => 1 },
	url      => { type => 'string', optional => 1 },
	res      => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }

MESSAGES

As check_page.

check_methods

Purpose: report TRACE and write methods advertised by an OPTIONS response.

Args: $findings, then named host, url and res (the OPTIONS response).

Returns: $findings.

Side Effects: adds trace-enabled and/or write-methods-enabled.

Usage: check_methods($findings, host => $h, url => $u, res => $options);

EXAMPLE

App::WebPerimeterScan::Checks::check_methods($findings,
	host => 'example.com', url => 'https://example.com/',
	res  => { headers => { allow => ['GET,HEAD,TRACE'] } },
);

API SPECIFICATION

INPUT

{
	findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
	host     => { type => 'string', optional => 1 },
	url      => { type => 'string', optional => 1 },
	res      => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }

MESSAGES

As check_page.

check_tls

Purpose: the TLS rules: handshake, chain, self-signed, expiry, protocol versions and cipher.

Args: $findings, then named host and tls (from "inspect" in App::WebPerimeterScan::TLS).

Returns: $findings.

Side Effects: adds findings. A failed default handshake adds tls-unavailable, plus tls-legacy-protocol if a pinned old-protocol handshake still succeeded.

Usage: check_tls($findings, host => $h, tls => $tls);

EXAMPLE

App::WebPerimeterScan::Checks::check_tls($findings,
	host => 'example.com',
	tls  => { ok => 1, protocols => ['TLSv1', 'TLSv1_2'], days_left => 10 },
);
# tls-legacy-protocol (high), tls-expiring (high)

API SPECIFICATION

INPUT

{
	findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
	host     => { type => 'string', optional => 1 },
	tls      => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }

MESSAGES

As check_page.

PSEUDOCODE

validate; return unless a TLS result was given
if the default handshake failed, report tls-unavailable and any
legacy protocol, and stop
report a verification error and a self-signed certificate
report an expired certificate, or one expiring within the warning window
report TLS 1.0 / 1.1 acceptance (high if 1.0)
report a weak negotiated cipher

check_ports

Purpose: the port rules: risky services, other unexpected ports and old OpenSSH banners.

Args: $findings, then named host and scan (from "scan" in App::WebPerimeterScan::Ports).

Returns: $findings.

Side Effects: adds one port-open-N finding per risky open port (severity from %App::WebPerimeterScan::Ports::RISKY), one ports-other-open for the rest except 80 and 443, and outdated-ssh-banner.

Usage: check_ports($findings, host => $h, scan => $scan);

EXAMPLE

App::WebPerimeterScan::Checks::check_ports($findings,
	host => 'example.com',
	scan => { open => [ { port => 3306, banner => '' } ] },
);
# port-open-3306 (high)

API SPECIFICATION

INPUT

{
	findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
	host     => { type => 'string', optional => 1 },
	scan     => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }

MESSAGES

As check_page.

PSEUDOCODE

validate; return unless a scan was given
if the scan failed, report portscan-failed and stop
for each open port
	report it if it is in the risky table
	otherwise remember it unless it is 80 or 443
report the remembered ports together
report any OpenSSH banner older than the configured minimum

fingerprint

Purpose: a short identity for a response, so a probe can be compared with the answer to a path that certainly does not exist. If the application answers 200 for everything, no probe result can be trusted on status alone.

Args: $res, a response hash reference.

Returns: "status:length:md5", or an empty string for a failed response.

Side Effects: none.

Usage: next if fingerprint($res) eq $baseline;

EXAMPLE

my $baseline = App::WebPerimeterScan::Checks::fingerprint(
	$http->fetch('https://example.com/does-not-exist-12345'),
);

API SPECIFICATION

INPUT

{ res => { type => 'hashref', optional => 1, position => 0 } }

OUTPUT

{ type => 'string' }

MESSAGES

None.

check_paths

Purpose: request each well-known sensitive path and report the ones that are really there, plus security.txt and robots.txt.

Args: $findings, then named http (an App::WebPerimeterScan::HTTP), host, base (the site's root URL) and baseline (the fingerprint of a response to a path that does not exist).

Returns: $findings.

Side Effects: one request per probe plus two; adds findings. A probe is only reported when the status is 200, the fingerprint differs from the baseline, and the body matches what the real file would contain.

Usage: check_paths($findings, http => $http, host => $h, base => $base, baseline => $fp);

EXAMPLE

App::WebPerimeterScan::Checks::check_paths($findings,
	http     => App::WebPerimeterScan::HTTP->new,
	host     => 'example.com',
	base     => 'https://example.com/',
	baseline => '',
);

API SPECIFICATION

INPUT

{
	findings => { type => 'object', isa => 'App::WebPerimeterScan::Findings', position => 0 },
	http     => { type => 'object', can => 'fetch' },
	host     => { type => 'string', optional => 1 },
	base     => { type => 'string', min => 1 },
	baseline => { type => 'string', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }

MESSAGES

Message                                  Meaning / resolution
---------------------------------------  ------------------------------
Parameter 'http' must be an object that  Pass an HTTP client with a
understands the fetch method (fatal)     fetch method.

sensitive_probes

Purpose: the probe table, so the source-aware pass looks on disk for exactly the paths that were requested over the wire.

Args: none.

Returns: an array reference of hash references: id, path, severity, match and optionally reference. The entries are read-only.

Side Effects: none.

Usage: for my $probe (@{ sensitive_probes() }) { ... }

EXAMPLE

print "$_->{path}\n" for @{ App::WebPerimeterScan::Checks::sensitive_probes() };

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'arrayref', element_type => 'hashref' }

MESSAGES

None.

probe_origin

Purpose: the Origin the CORS probe sends. It is in the reserved .invalid TLD, so it can never be mistaken for a real site.

Args: none.

Returns: https://ws-probe.invalid.

Side Effects: none.

Usage: $http->fetch($url, headers => [ 'Origin: ' . probe_origin() ])

EXAMPLE

my $origin = App::WebPerimeterScan::Checks::probe_origin();

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'string', matches => qr/\.invalid\z/ }

MESSAGES

None.

RULES

This is every finding this module can report, with its usual severity and what causes it. The words of each finding are in App::WebPerimeterScan::Messages, under the rule id. The runner, App::WebPerimeterScan, adds a few more rules of its own; they are listed in its documentation.

Page rules (check_page):

Rule id                      Severity  Reported when
---------------------------  --------  ------------------------------------
hsts-missing                 medium    an HTTPS page has no
                                       Strict-Transport-Security header
hsts-short-max-age           low       HSTS max-age is under 180 days
hsts-no-subdomains           info      HSTS has no includeSubDomains
csp-missing                  medium    no Content-Security-Policy header
csp-wildcard                 medium    script-src or default-src allows any
                                       origin (*, http:, https:, data:)
csp-unsafe                   low       script-src or default-src allows
                                       'unsafe-inline' or 'unsafe-eval'
clickjacking                 medium    no X-Frame-Options and no CSP
                                       frame-ancestors
nosniff-missing              low       X-Content-Type-Options is not nosniff
referrer-policy-missing      low       no Referrer-Policy header
permissions-policy-missing   info      no Permissions-Policy header
server-version-disclosed     low       the Server header has a version
x-powered-by                 low       an X-Powered-By header is sent
generator-disclosed          info      a generator meta tag names software
php2shell                    critical  X-Powered-By shows a PHP version
                                       without the PHP2Shell fix
cookie-flags                 medium    a cookie lacks Secure
                             low       a cookie lacks HttpOnly or SameSite
directory-listing            medium    the page is a directory index
error-disclosure             medium    the page shows a stack trace or
                                       a database error
mixed-content-active         high      an HTTPS page loads, over http://,
                                       a script, style sheet, frame,
                                       iframe, embed or object, or has a
                                       form that sends to http://
mixed-content-passive        low       an HTTPS page loads an image, icon,
                                       audio, video or media source over
                                       http://

How the CSP rules judge a policy:

  • Only script-src is read, or default-src when there is no script-src. style-src and the other directives are not judged.

  • csp-wildcard means "any origin": *, http:, https: or data:. A host wildcard such as https://*.example.com is not any origin and is not reported. 'strict-dynamic' turns the rule off, because browsers that support it ignore host and scheme sources.

  • csp-unsafe reports 'unsafe-inline' only when the same directive has no nonce or hash ('nonce-...', 'sha256-...' and so on), because browsers then ignore 'unsafe-inline'. 'unsafe-eval' is always reported.

  • Several Content-Security-Policy headers are joined and read as one policy; see "LIMITATIONS".

Probe rules (check_cors, check_methods):

cors-misconfiguration        high      the probe origin may read responses
                                       with credentials
                             medium    the probe origin is reflected
                             low       Access-Control-Allow-Origin is *
trace-enabled                medium    OPTIONS lists TRACE
write-methods-enabled        low       OPTIONS lists PUT, DELETE or PATCH

TLS rules (check_tls):

tls-unavailable              medium    no TLS handshake was possible
tls-invalid-certificate      high      the chain or the name does not verify
tls-self-signed              high      the certificate is self-signed
tls-expired                  high      the certificate has expired
tls-expiring                 high      it expires within 14 days
                             medium    it expires within 30 days
tls-legacy-protocol          high      TLS 1.0 is accepted
                             medium    TLS 1.1 is accepted, and not 1.0
tls-weak-cipher              high      the default cipher is weak (RC4,
                                       3DES, NULL, EXPORT, MD5 or an
                                       anonymous suite)

Port rules (check_ports):

port-open-N                  varies    port N is one of the risky ports in
                                       %App::WebPerimeterScan::Ports::RISKY;
                                       critical for 21 (FTP) and 23 (Telnet),
                                       medium for 5900 (VNC), high for the
                                       others
ports-other-open             info      other ports than 80 and 443 are open
outdated-ssh-banner          medium    an OpenSSH banner is older than 9.0
portscan-failed              info      the port scan could not run

Path rules (check_paths). Each probe is reported only when the path answers 200, the body looks like the real file (each probe has its own pattern), and, when a baseline is given, the response differs from the server's answer for a path that does not exist:

git-exposed                  critical  /.git/config
git-head                     high      /.git/HEAD
svn-exposed                  high      /.svn/entries
env-exposed                  critical  /.env
htpasswd-exposed             critical  /.htpasswd
htaccess-exposed             medium    /.htaccess
web-config-exposed           high      /web.config
php-config-backup            critical  /wp-config.php.bak
sql-dump-exposed             critical  /backup.sql
ds-store-exposed             low       /.DS_Store
server-status-exposed        high      /server-status
server-info-exposed          medium    /server-info
phpinfo-exposed              high      /phpinfo.php
spring-actuator-exposed      critical  /actuator/env
spring-health-exposed        low       /actuator/health
composer-json-exposed        low       /composer.json
package-json-exposed         low       /package.json
crossdomain-xml              medium    /crossdomain.xml
security-txt-present         info      /.well-known/security.txt exists
                                       and has a Contact: line
robots-disclosure            info      robots.txt lists Disallow paths

COMMON PITFALLS

  • These are functions, not methods. Call App::WebPerimeterScan::Checks::check_page($findings, ...). The findings collection is always the first argument.

  • check_paths sends requests; the scope is not checked here. It requests about twenty paths on base. Check that the host is in scope before you call it.

  • A failed response is not a finding. When res has an error, the page, CORS and OPTIONS checks add nothing. A host that could not be reached is reported by the runner, not here.

  • A header rule is reported once for each host. check_page records header findings such as csp-missing with an empty url. So calling it for ten pages that all lack the header gives one finding, not ten. It also means that a page that has the header does not cancel the finding from a page that lacks it.

  • Pass a CORS response from the probe origin. check_cors judges the response to a request that sent Origin: https://ws-probe.invalid (from probe_origin). A response to a normal request proves nothing.

  • %config is shared. A change to %App::WebPerimeterScan::Checks::config affects every later check in the program. Use local to change it for one block only.

  • Leave tls or scan out when there is nothing to judge. When the argument is missing, check_tls and check_ports add nothing. An empty hash is different for check_tls: it has no ok, so it is read as a failed handshake and tls-unavailable is reported. portscan-failed is reported only when the scan result has an error.

ENCODING

  • Page bodies are bytes. The checks search the raw bytes, and the markers they look for (headers, tag names, error messages) are ASCII. A page in any character set, including UTF-8 with emoji, is checked correctly.

  • Evidence is cut by bytes. An excerpt of a page keeps at most excerpt_length bytes and adds .... The cut can fall inside a UTF-8 character, so the last character before ... may be shown as a replacement character.

  • Header values are copied as they arrived. A header that contains non-ASCII bytes appears in the evidence unchanged.

  • Rule ids, severities and the probe paths are ASCII.

LIMITATIONS

  • Several Content-Security-Policy headers are joined and read as one policy. Browsers enforce each separately, so a strict second policy can make a lax first one safe; this module would still report the lax one.

  • The PHP2Shell rule trusts X-Powered-By. A server that hides or fakes the header is judged on what it claims.

  • Mixed content is found in the markup only; resources requested by script or CSS at run time are not seen.

  • The .svn/entries probe matches the pre-1.7 format only.

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

check_page

┌─ CheckPage ─────────────────────────────────────────
│ ΔFindings; url? : URL; res? : RESPONSE
├─────────────────────────────────────────────────────
│ res?.error ≠ ⊥ ⇒ items' = items
│ res?.error = ⊥ ⇒ items' = items ⁀ headers(res?) ⁀ cookies(res?)
│                            ⁀ body(res?)
│ scheme(url?) = https ⇔ HSTS rules ⊆ rules applied
└─────────────────────────────────────────────────────

check_cors

┌─ CheckCors ─────────────────────────────────────────
│ ΔFindings; res? : RESPONSE
├─────────────────────────────────────────────────────
│ a = last(res?.headers(access-control-allow-origin))
│ c ⇔ last(res?.headers(access-control-allow-credentials)) = "true"
│ c ∧ a ∈ {*, PROBE} ⇒ severity = high
│ ¬c ∧ a = PROBE ⇒ severity = medium
│ ¬c ∧ a = * ⇒ severity = low
│ a ¬in; {*, PROBE} ⇒ items' = items
└─────────────────────────────────────────────────────

check_methods

┌─ CheckMethods ──────────────────────────────────────
│ ΔFindings; res? : RESPONSE
├─────────────────────────────────────────────────────
│ M = tokens(res?.headers(allow))
│ TRACE ∈ M ⇔ trace-enabled ∈ ids(items' \ items)
│ M ∩ {PUT, DELETE, PATCH} ≠ ∅ ⇔ write-methods-enabled ∈ ids(items' \ items)
└─────────────────────────────────────────────────────

check_tls

┌─ CheckTls ──────────────────────────────────────────
│ ΔFindings; tls? : TLSRESULT
├─────────────────────────────────────────────────────
│ ¬ tls?.ok ⇒ ids(items' \ items) ⊆ {tls-unavailable, tls-legacy-protocol}
│ tls?.ok ⇒
│   (tls?.verify_error ≠ ⊥ ⇔ tls-invalid-certificate ∈ new)
│ ∧ (tls?.days_left < 0 ⇔ tls-expired ∈ new)
│ ∧ (0 ≤ tls?.days_left < warn ⇔ tls-expiring ∈ new)
│ ∧ (ran tls?.protocols ∩ LEGACY ≠ ∅ ⇔ tls-legacy-protocol ∈ new)
│ where new = ids(items' \ items)
└─────────────────────────────────────────────────────

check_ports

┌─ CheckPorts ────────────────────────────────────────
│ ΔFindings; scan? : SCANRESULT
├─────────────────────────────────────────────────────
│ scan?.error ≠ ⊥ ⇒ ids(items' \ items) = {portscan-failed}
│ ∀ p ∈ open(scan?) ∩ dom RISKY • port-open-p ∈ ids(items')
│ (open(scan?) \ dom RISKY \ {80, 443}) ≠ ∅ ⇔ ports-other-open ∈ ids(items')
└─────────────────────────────────────────────────────

fingerprint

┌─ Fingerprint ───────────────────────────────────────
│ res? : RESPONSE; result! : STRING
├─────────────────────────────────────────────────────
│ res? = ⊥ ∨ res?.error ≠ ⊥ ⇒ result! = ""
│ otherwise result! = status(res?) ⁀ ":" ⁀ #body(res?) ⁀ ":" ⁀ md5(body(res?))
└─────────────────────────────────────────────────────

check_paths

┌─ CheckPaths ────────────────────────────────────────
│ ΔFindings; base? : URL; baseline? : STRING
├─────────────────────────────────────────────────────
│ ∀ p : PROBES • p.id ∈ ids(items' \ items) ⇔
│     let r = fetch(base? ⁀ p.path) •
│         r.status = 200 ∧ fingerprint(r) ≠ baseline?
│       ∧ r.body matches p.match
└─────────────────────────────────────────────────────

sensitive_probes

┌─ SensitiveProbes ───────────────────────────────────
│ result! : seq PROBE
├─────────────────────────────────────────────────────
│ result! = PROBES
└─────────────────────────────────────────────────────

probe_origin

┌─ ProbeOrigin ───────────────────────────────────────
│ result! : URL
├─────────────────────────────────────────────────────
│ result! = PROBE ∧ tld(PROBE) = invalid
└─────────────────────────────────────────────────────

STATE DIAGRAM

This module keeps no state of its own, except %config. The only state that changes is the findings collection passed in: each check can only add to it.

Every check_* function:

check_xxx(findings, named arguments)
      |
      v
+----------------+
|   VALIDATE     |--- findings is not a collection, or a named
+----------------+    argument is unknown or has the wrong type
      |                  -> croak; nothing added
      |
      |  the data to judge is missing, or is a failed response
      |-------------------> return findings; nothing added
      v
+----------------+
|  APPLY RULES   |  each rule that matches adds one finding
+----------------+  (a duplicate of one already held is ignored)
      |
      v
return findings (now holding n + k findings, k >= 0)

check_paths, the one check that sends requests:

check_paths(findings, http, base, baseline)
      |
      v
+----------------+
|  FOR EACH      |<---------------------------------------+
|  PROBE PATH    |                                        |
+----------------+                                        |
      |                                                   |
      |  http->fetch(base . path)                         |
      v                                                   |
+----------------+   failed, not 200, same as the         |
|   COMPARE      |   baseline, or body does not match     |
+----------------+---------> no finding ------------------+
      |                                                   |
      |  200, differs from the baseline, body matches     |
      +-------------> add the probe's finding ------------+
      |
      |  after the last probe
      v
+----------------+
| SECURITY.TXT   |  fetch /.well-known/security.txt;
+----------------+  200 with a Contact: line ->
      |             security-txt-present
      |
      v
+----------------+
| ROBOTS.TXT     |  fetch /robots.txt; Disallow lines ->
+----------------+  robots-disclosure
      |
      v
return findings

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.