App-WebPerimeterScan

 view release on metacpan or  search on metacpan

lib/App/WebPerimeterScan/Checks.pm  view on Meta::CPAN

	'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);

lib/App/WebPerimeterScan/Checks.pm  view on Meta::CPAN


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' => ['*'] } },
	);

lib/App/WebPerimeterScan/Checks.pm  view on Meta::CPAN


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.

lib/App/WebPerimeterScan/Checks.pm  view on Meta::CPAN

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

lib/App/WebPerimeterScan/Checks.pm  view on Meta::CPAN

	│                            ⁀ 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

lib/App/WebPerimeterScan/Messages.pm  view on Meta::CPAN

	'mixed-content-passive.detail' => 'Images, media or icons are fetched over plaintext, so they can be modified in transit and can leak the page URL through the Referer header.',
	'mixed-content-passive.fix'    => 'Reference these resources over https://.',

	# ==================================================================
	# CORS and methods
	# ==================================================================
	'cors-misconfiguration.title'    => 'CORS policy allows untrusted origins',
	'cors-misconfiguration.detail'   => {
		select => 'case',
		forms  => {
			credentials => 'Any origin may read authenticated responses, so a malicious site can act as the logged-in user.',
			reflected   => 'The server echoes back whatever Origin it is given, so any site can read these responses.',
			other       => 'Any origin may read these responses. Harmless for genuinely public data, dangerous for anything user-specific.',
		},
	},
	'cors-misconfiguration.evidence' => {
		select => 'credentials',
		forms  => {
			yes   => 'Access-Control-Allow-Origin: {allowed}; Access-Control-Allow-Credentials: true (request sent Origin: {origin})',
			other => 'Access-Control-Allow-Origin: {allowed} (request sent Origin: {origin})',
		},
	},
	'cors-misconfiguration.fix'      => 'Echo back only origins on an explicit allowlist, never a wildcard, and only with credentials when necessary.',

	'trace-enabled.title'    => 'HTTP TRACE is enabled',
	'trace-enabled.detail'   => 'TRACE echoes the request back, which can be combined with other flaws to read cookies and headers.',
	'trace-enabled.evidence' => 'Allow: {allow}',
	'trace-enabled.fix'      => 'Disable TRACE (TraceEnable off in Apache).',

	'write-methods-enabled.title'    => 'Write methods advertised on a public URL',
	'write-methods-enabled.detail'   => 'PUT, DELETE or PATCH are allowed here. That may be intended for an API, but it is worth confirming the endpoint authenticates and authorises.',
	'write-methods-enabled.evidence' => 'Allow: {allow}',
	'write-methods-enabled.fix'      => 'Restrict methods per route at the web server or application layer.',

lib/App/WebPerimeterScan/Messages.pm  view on Meta::CPAN


	'outdated-ssh-banner.title'  => 'SSH announces an old version',
	'outdated-ssh-banner.detail' => 'OpenSSH releases before {minimum} include versions with published remote flaws; the banner alone is not proof of a working exploit, but it is a strong hint.',
	'outdated-ssh-banner.fix'    => 'Upgrade OpenSSH and restart sshd.',
	'outdated-ssh-banner.item'   => 'port {port}: {banner}',

	# ==================================================================
	# Well-known paths
	# ==================================================================
	'git-exposed.title'  => 'Exposed Git repository metadata',
	'git-exposed.detail' => 'The .git directory is being served. Source code, commit history and any credentials ever committed can be reconstructed from it.',
	'git-exposed.fix'    => 'Deny requests to /.git/ at the web server, and deploy with the repository outside the document root.',

	'git-head.title'  => 'Exposed Git HEAD',
	'git-head.detail' => 'A Git ref file is readable, which confirms the repository is exposed.',
	'git-head.fix'    => 'Deny requests to /.git/ at the web server.',

	'svn-exposed.title'  => 'Exposed Subversion metadata',
	'svn-exposed.detail' => 'Subversion administrative files are readable.',
	'svn-exposed.fix'    => 'Deny requests to /.svn/ at the web server.',

lib/App/WebPerimeterScan/Messages.pm  view on Meta::CPAN


	'htaccess-exposed.title'  => 'Exposed .htaccess',
	'htaccess-exposed.detail' => 'Server configuration is readable and reveals internal rewrite targets and module versions.',
	'htaccess-exposed.fix'    => 'Deny access to dotfiles at the web server.',

	'web-config-exposed.title'  => 'Exposed web.config',
	'web-config-exposed.detail' => 'An IIS configuration file is readable and may contain connection strings.',
	'web-config-exposed.fix'    => 'Deny access to web.config.',

	'php-config-backup.title'  => 'Exposed WordPress configuration backup',
	'php-config-backup.detail' => 'A backup copy of wp-config.php is downloadable, exposing database credentials and salts.',
	'php-config-backup.fix'    => 'Remove backup copies from the document root and deny requests for .bak/.old/.orig files.',

	'sql-dump-exposed.title'  => 'Exposed database dump',
	'sql-dump-exposed.detail' => 'A database dump is downloadable, which is usually a complete copy of the application data.',
	'sql-dump-exposed.fix'    => 'Remove dumps from the web root and store backups off the web server.',

	'ds-store-exposed.title'  => 'Exposed .DS_Store',
	'ds-store-exposed.detail' => 'A macOS folder index is readable, which leaks the names of files (sometimes files that are otherwise unreachable) in that directory.',
	'ds-store-exposed.fix'    => 'Deny dotfiles at the web server and stop deploying macOS metadata.',

	'server-status-exposed.title'  => 'Apache server-status is public',
	'server-status-exposed.detail' => 'mod_status is reachable, exposing internal URLs, client addresses and server load.',
	'server-status-exposed.fix'    => 'Restrict /server-status with Require ip on localhost, or disable mod_status.',

	'server-info-exposed.title'  => 'Apache server-info is public',
	'server-info-exposed.detail' => 'mod_info is reachable and publishes the full module configuration.',
	'server-info-exposed.fix'    => 'Restrict /server-info with Require ip on localhost, or disable mod_info.',

	'phpinfo-exposed.title'  => 'phpinfo() is public',
	'phpinfo-exposed.detail' => 'A phpinfo page leaks absolute paths, loaded extensions, environment variables and sometimes credentials.',
	'phpinfo-exposed.fix'    => 'Delete phpinfo scripts from production.',

	'spring-actuator-exposed.title'  => 'Spring Boot actuator environment is public',
	'spring-actuator-exposed.detail' => 'The actuator env endpoint is reachable and commonly exposes datasource credentials and API keys.',
	'spring-actuator-exposed.fix'    => 'Require authentication on /actuator/**, and only expose health and info.',

	'spring-health-exposed.title'  => 'Spring Boot actuator health is public',
	'spring-health-exposed.detail' => 'Health detail is public. This is minor on its own but confirms a Spring Boot management surface.',
	'spring-health-exposed.fix'    => 'Expose only the health endpoint, and set management.endpoint.health.show-details=never.',

	'composer-json-exposed.title'  => 'composer.json is public',
	'composer-json-exposed.detail' => 'Dependency pins are readable, which tells an attacker exactly which versions to try.',
	'composer-json-exposed.fix'    => 'Deny access to composer.json and composer.lock.',

t/checks.t  view on Meta::CPAN

}

{
	my $findings = App::WebPerimeterScan::Findings->new;
	App::WebPerimeterScan::Checks::check_cors($findings,
		host => 'example.com',
		url  => 'https://example.com/',
		res  => {
			headers => {
				'access-control-allow-origin'      => ['*'],
				'access-control-allow-credentials' => ['true'],
			},
		},
	);
	my ($cors) = grep { $_->{id} eq 'cors-misconfiguration' } @{ $findings->all };
	ok($cors, 'permissive CORS reported');
	is($cors->{severity}, 'high', 'wildcard with credentials is high');
}

{
	my $findings = App::WebPerimeterScan::Findings->new;
	App::WebPerimeterScan::Checks::check_cors($findings,
		host => 'example.com',
		url  => 'https://example.com/',
		res  => { headers => { 'access-control-allow-origin' => ['https://other.example'] } },
	);
	is($findings->count, 0, 'a fixed allowlist origin is not reported');

t/checks.t  view on Meta::CPAN


	$findings = App::WebPerimeterScan::Findings->new;
	App::WebPerimeterScan::Checks::check_tls($findings, host => 'h', tls => { ok => 1, days_left => -1, not_after => 'X' });
	my ($expired) = @{ $findings->all };
	is($expired->{title}, 'TLS certificate expired 1 day ago', 'singular day');

	$findings = App::WebPerimeterScan::Findings->new;
	App::WebPerimeterScan::Checks::check_cors($findings, host => 'h', url => 'u', res => { headers => {
		'access-control-allow-origin' => [ App::WebPerimeterScan::Checks::probe_origin() ],
	} });
	is($findings->all->[0]{severity}, 'medium', 'a reflected origin without credentials is medium');
	like($findings->all->[0]{detail}, qr/echoes back/, 'the detail matches the case');
};

subtest 'check_* return the collector and validate it' => sub {
	my $findings = App::WebPerimeterScan::Findings->new;
	is(App::WebPerimeterScan::Checks::check_methods($findings, res => undef), $findings,
		'returns the collector for chaining');
	ok(!eval { App::WebPerimeterScan::Checks::check_page({}, res => response()); 1 },
		'a non-collector is refused');
	like($@, qr/findings/, 'the error names the parameter');



( run in 1.489 second using v1.01-cache-2.11-cpan-3f962061493 )