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.',
}
{
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');
$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 0.668 second using v1.01-cache-2.11-cpan-3f962061493 )