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 )