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-srcis read, ordefault-srcwhen there is noscript-src.style-srcand the other directives are not judged.csp-wildcardmeans "any origin":*,http:,https:ordata:. A host wildcard such ashttps://*.example.comis not any origin and is not reported.'strict-dynamic'turns the rule off, because browsers that support it ignore host and scheme sources.csp-unsafereports'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-Policyheaders 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_pathssends requests; the scope is not checked here. It requests about twenty paths onbase. Check that the host is in scope before you call it.A failed response is not a finding. When
reshas anerror, 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_pagerecords header findings such ascsp-missingwith an emptyurl. 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_corsjudges the response to a request that sentOrigin: https://ws-probe.invalid(fromprobe_origin). A response to a normal request proves nothing.%configis shared. A change to%App::WebPerimeterScan::Checks::configaffects every later check in the program. Uselocalto change it for one block only.Leave
tlsorscanout when there is nothing to judge. When the argument is missing,check_tlsandcheck_portsadd nothing. An empty hash is different forcheck_tls: it has nook, so it is read as a failed handshake andtls-unavailableis reported.portscan-failedis reported only when the scan result has anerror.
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_lengthbytes 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-Policyheaders 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/entriesprobe 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 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
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.