App-WebPerimeterScan
view release on metacpan or search on metacpan
lib/App/WebPerimeterScan/Checks.pm view on Meta::CPAN
# real origin, and can never resolve to anything.
Readonly::Scalar my $PROBE_ORIGIN => 'https://ws-probe.invalid';
Readonly::Scalar my $HTTP_OK => 200;
Readonly::Scalar my $HTTP_SERVER_ERROR => 500;
# Ports whose being open is the point of a web server.
Readonly::Hash my %WEB_PORT => (80 => 1, 443 => 1);
# PHP versions at or above these are fixed for PHP2Shell
# (CVE-2026-109618 / CVE-2026-109619); branches older than the oldest key
# have no fix at all.
Readonly::Hash my %PHP2SHELL_FIXED => (
'8.5' => '8.5.12',
'8.4' => '8.4.27',
'8.3' => '8.3.36',
'8.2' => '8.2.35',
);
Readonly::Scalar my $PHP2SHELL_OLDEST_BRANCH => '8.2';
# The default severity and the reference URL of each rule. The text of a
# rule is in the message catalogue under the same id.
Readonly::Hash my %RULE => (
'hsts-missing' => { severity => 'medium', reference => 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security' },
'hsts-short-max-age' => { severity => 'low' },
'hsts-no-subdomains' => { severity => 'info' },
'csp-missing' => { severity => 'medium', reference => 'https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP' },
'csp-wildcard' => { severity => 'medium' },
'csp-unsafe' => { severity => 'low' },
'clickjacking' => { severity => 'medium', reference => 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options' },
'nosniff-missing' => { severity => 'low' },
'referrer-policy-missing' => { severity => 'low' },
'permissions-policy-missing' => { severity => 'info' },
'server-version-disclosed' => { severity => 'low' },
'x-powered-by' => { severity => 'low' },
'generator-disclosed' => { severity => 'info' },
'php2shell' => { severity => 'critical', reference => 'https://www.cve.org/CVERecord?id=CVE-2026-109618' },
'cookie-flags' => { severity => 'low', reference => 'https://owasp.org/www-project-secure-headers/' },
'directory-listing' => { severity => 'medium' },
'error-disclosure' => { severity => 'medium', reference => 'https://cwe.mitre.org/data/definitions/209.html' },
'mixed-content-active' => { severity => 'high', reference => 'https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content' },
'mixed-content-passive' => { severity => 'low' },
'cors-misconfiguration' => { severity => 'low', reference => 'https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS' },
'trace-enabled' => { severity => 'medium' },
'write-methods-enabled' => { severity => 'low' },
'tls-unavailable' => { severity => 'medium' },
'tls-invalid-certificate' => { severity => 'high', reference => 'https://www.ssllabs.com/ssltest/' },
'tls-self-signed' => { severity => 'high' },
'tls-expired' => { severity => 'high' },
'tls-expiring' => { severity => 'medium' },
'tls-legacy-protocol' => { severity => 'medium' },
'tls-weak-cipher' => { severity => 'high' },
'portscan-failed' => { severity => 'info' },
'ports-other-open' => { severity => 'info' },
'outdated-ssh-banner' => { severity => 'medium' },
'security-txt-present' => { severity => 'info' },
'robots-disclosure' => { severity => 'info' },
);
# CORS outcome => severity.
Readonly::Hash my %CORS_SEVERITY => (credentials => 'high', reflected => 'medium', wildcard => 'low');
# Script sources that allow any origin: a bare wildcard or a whole scheme.
Readonly::Hash my %ANY_SOURCE => map { $_ => 1 } ('*', 'http:', 'https:', 'data:');
# A nonce or hash makes CSP2+ browsers ignore 'unsafe-inline'.
Readonly::Scalar my $NONCE_OR_HASH => qr/\A'(?:nonce|sha256|sha384|sha512)-/i;
# Protocol versions deprecated by RFC 8996, worst first.
Readonly::Array my @LEGACY_PROTOCOLS => qw(TLSv1 TLSv1_1);
# Cipher suites that are broken or unauthenticated.
Readonly::Scalar my $WEAK_CIPHER => qr/RC4|3DES|DES-CBC3|NULL|EXPORT|MD5|anon/i;
# Error-page fingerprints, in the order they are listed in a title.
Readonly::Array my @ERROR_MARKERS => (
[ php => qr/(?:Fatal error|Parse error|Warning):\s+\S+ in \S+ on line \d+/i ],
[ perl => qr/\bat \S+\.p[lm] line \d+/i ],
[ python => qr/Traceback \(most recent call last\)/ ],
[ java => qr/\bat [\w.\$]+\([\w.]+\.java:\d+\)/ ],
[ sql => qr/(?:SQL syntax|mysql_fetch|ORA-\d{5}|unclosed quotation mark)/i ],
[ stack => qr/Stack trace:/i ],
);
Readonly::Scalar my $ERROR_HINT => qr/(?:Warning|Fatal error):\s+\S+/i;
# Resources a page loads: tag => [ URL attribute, mixed-content class ].
# A link's class depends on its rel, so it is decided separately.
Readonly::Hash my %RESOURCE => (
script => [ src => 'active' ],
iframe => [ src => 'active' ],
frame => [ src => 'active' ],
embed => [ src => 'active' ],
object => [ data => 'active' ],
form => [ action => 'active' ],
img => [ src => 'passive' ],
audio => [ src => 'passive' ],
video => [ src => 'passive' ],
source => [ src => 'passive' ],
link => [ href => undef ],
);
# Only these link relations load anything. A stylesheet is blockable
# ("active") mixed content; canonical, alternate and the like load nothing
# and are ignored.
Readonly::Hash my %LINK_REL => (stylesheet => 'active', icon => 'passive');
Readonly::Scalar my $PLAINTEXT_URL => qr{\Ahttp://}i;
# Well-known paths that should never be served. The match regexp is what
# turns "the server returned 200" into "the file is really there": a SPA that
# answers 200 to everything must not produce twenty findings. Titles and
# advice are in the message catalogue under each id.
Readonly::Array my @PROBES => (
{ id => 'git-exposed', path => '/.git/config', severity => 'critical', match => qr/\[core\]|repositoryformatversion/, reference => 'https://cwe.mitre.org/data/definitions/538.html' },
{ id => 'git-head', path => '/.git/HEAD', severity => 'high', match => qr/\Aref:\s+refs\// },
{ id => 'svn-exposed', path => '/.svn/entries', severity => 'high', match => qr/^\d+\s*$/m },
{ id => 'env-exposed', path => '/.env', severity => 'critical', match => qr/^\s*[A-Z][A-Z0-9_]{2,}\s*=\s*\S/m, reference => 'https://cwe.mitre.org/data/definitions/200.html' },
{ id => 'htpasswd-exposed', path => '/.htpasswd', severity => 'critical', match => qr/^[^:\s]+:\$?(?:apr1|2y|1|5|6|\{SHA\})/m },
{ id => 'htaccess-exposed', path => '/.htaccess', severity => 'medium', match => qr/<IfModule|RewriteEngine|Options\s+[-+]/i },
{ id => 'web-config-exposed', path => '/web.config', severity => 'high', match => qr/<configuration/i },
{ id => 'php-config-backup', path => '/wp-config.php.bak', severity => 'critical', match => qr/DB_PASSWORD|DB_NAME|AUTH_KEY/ },
lib/App/WebPerimeterScan/Checks.pm view on Meta::CPAN
The findings it can add are listed in L</RULES>.
=head3 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
=cut
sub check_page
{
my $findings = shift;
my $args = _check_args($findings, \@_, { res => { type => 'hashref', optional => 1 } });
my $res = $args->{res};
return $findings unless $res && !$res->{error};
# Everything the page rules need, gathered once and passed around, so
# no helper has a seven-argument signature.
my $url = $args->{url} // '';
my $body = $res->{body} // '';
my $page = {
findings => $findings,
host => $args->{host} // '',
url => $url,
headers => $res->{headers} || {},
status => $res->{status} // 0,
set_cookie => $res->{set_cookie} || [],
body => $body,
html => _parse_html($body),
is_https => $url =~ m{\Ahttps://}i ? 1 : 0,
};
# HSTS is only meaningful over HTTPS.
_check_hsts($page) if $page->{is_https};
_check_csp($page);
_check_simple_headers($page);
_check_disclosure($page);
_check_cookies($page);
_check_body($page);
return $findings;
}
=head2 check_cors
Purpose: report a CORS policy that lets an untrusted origin read responses.
The response must come from a request that sent C<Origin:> set to
C<probe_origin()>.
Args: C<$findings>, then named C<host>, C<url> and C<res>.
Returns: C<$findings>.
Side Effects: adds at most one C<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: C<< check_cors($findings, host => $h, url => $u, res => $res); >>
=head3 EXAMPLE
App::WebPerimeterScan::Checks::check_cors($findings,
host => 'example.com', url => 'https://example.com/',
res => { headers => { 'access-control-allow-origin' => ['*'] } },
);
=head3 API SPECIFICATION
=head4 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 },
}
=head4 OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }
=head3 MESSAGES
As C<check_page>.
=cut
sub check_cors
{
my $findings = shift;
my $args = _check_args($findings, \@_, { res => { type => 'hashref', optional => 1 } });
my $res = $args->{res};
my $headers = ($res && !$res->{error}) ? ($res->{headers} || {}) : {};
my $allowed = _last($headers, 'access-control-allow-origin');
my $credentials = lc(_last($headers, 'access-control-allow-credentials') // '') eq 'true';
# Which of the three bad outcomes this is, if any.
my $case = !defined $allowed ? undef
: $credentials && ($allowed eq '*' || $allowed eq $PROBE_ORIGIN) ? 'credentials'
: $allowed eq $PROBE_ORIGIN ? 'reflected'
: $allowed eq '*' ? 'wildcard'
: undef;
_flag($findings, 'cors-misconfiguration',
host => $args->{host},
url => $args->{url},
severity => $CORS_SEVERITY{$case},
vars => {
case => $case,
allowed => $allowed,
origin => $PROBE_ORIGIN,
credentials => $credentials ? 'yes' : 'no',
},
) if defined $case;
return $findings;
}
=head2 check_methods
Purpose: report TRACE and write methods advertised by an C<OPTIONS> response.
Args: C<$findings>, then named C<host>, C<url> and C<res> (the C<OPTIONS>
response).
Returns: C<$findings>.
Side Effects: adds C<trace-enabled> and/or C<write-methods-enabled>.
Usage: C<< check_methods($findings, host => $h, url => $u, res => $options); >>
=head3 EXAMPLE
App::WebPerimeterScan::Checks::check_methods($findings,
host => 'example.com', url => 'https://example.com/',
res => { headers => { allow => ['GET,HEAD,TRACE'] } },
);
=head3 API SPECIFICATION
=head4 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 },
}
=head4 OUTPUT
{ type => 'object', isa => 'App::WebPerimeterScan::Findings' }
=head3 MESSAGES
As C<check_page>.
=cut
sub check_methods
{
my $findings = shift;
my $args = _check_args($findings, \@_, { res => { type => 'hashref', optional => 1 } });
my $res = $args->{res};
my $allow = ($res && !$res->{error} && $res->{headers}{allow}) ? join(',', @{ $res->{headers}{allow} }) : '';
my %common = (host => $args->{host}, url => $args->{url}, vars => { allow => $allow });
_flag($findings, 'trace-enabled', %common) if $allow =~ /\bTRACE\b/i;
_flag($findings, 'write-methods-enabled', %common) if $allow =~ /\b(?:PUT|DELETE|PATCH)\b/i;
return $findings;
lib/App/WebPerimeterScan/Checks.pm view on Meta::CPAN
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:
=over 4
=item * Only C<script-src> is read, or C<default-src> when there is no
C<script-src>. C<style-src> and the other directives are not judged.
=item * C<csp-wildcard> means "any origin": C<*>, C<http:>, C<https:> or
C<data:>. A host wildcard such as C<https://*.example.com> is not any
origin and is not reported. C<'strict-dynamic'> turns the rule off,
because browsers that support it ignore host and scheme sources.
=item * C<csp-unsafe> reports C<'unsafe-inline'> only when the same
directive has no nonce or hash (C<'nonce-...'>, C<'sha256-...'> and so
on), because browsers then ignore C<'unsafe-inline'>. C<'unsafe-eval'>
is always reported.
=item * Several C<Content-Security-Policy> headers are joined and read as
one policy; see L</LIMITATIONS>.
=back
Probe rules (C<check_cors>, C<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 (C<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 (C<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 (C<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 C<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
=head1 COMMON PITFALLS
=over 4
lib/App/WebPerimeterScan/Checks.pm view on Meta::CPAN
=head1 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:
=over 4
=item * The part above the middle line declares names. A name that ends in
C<?> is an input. A name that ends in C<!> is an output.
=item * The part below the middle line lists facts that are true after the
call.
=item * A name with a dash after it, such as C<items'>, is the value after
the call. The same name without the dash is the value before the call.
=item * C<Delta X> (written with the Greek capital delta) means the call
may change the state C<X>. C<Xi X> (the Greek capital xi) means the call
does not change it.
=back
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 â 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
=head2 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
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 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 â {*, PROBE} â items' = items
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 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)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 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)
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 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')
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 fingerprint
ââ Fingerprint âââââââââââââââââââââââââââââââââââââââ
â res? : RESPONSE; result! : STRING
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â res? = ⥠⨠res?.error â ⥠â result! = ""
â otherwise result! = status(res?) â ":" â #body(res?) â ":" â md5(body(res?))
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
=head2 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
ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
( run in 1.169 second using v1.01-cache-2.11-cpan-3f962061493 )