File Coverage

File:blib/lib/App/Project/Doctor/Check/Security.pm
Coverage:97.0%

linestmtbrancondsubtimecode
1package App::Project::Doctor::Check::Security;
2
3
5
5
5
1830
4
75
use strict;
4
5
5
5
9
4
113
use warnings;
5
5
5
5
9
5
15
use autodie qw(:all);
6
7
5
5
5
10643
5
15
use parent -norequire, 'App::Project::Doctor::Check::Base';
8
9
5
5
5
140
5
110
use Carp qw(croak carp);
10
5
5
5
10
3
2549
use Readonly;
11
12our $VERSION = '0.02';
13
14Readonly::Array my @SECRET_PATTERNS => (
15        qr/(?:password|passwd|secret|api_?key|token)\s*=\s*['"][^'"]{4,}['"]/i,
16        qr/-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/,
17        qr/(?:AKIA|ASIA)[A-Z0-9]{16}/,
18);
19
20
4
220
sub name        { 'Security' }
21
1
1
sub description { 'All modules declare strict/warnings; no hardcoded credentials.' }
22
2
8
sub can_fix     { 1 }
23
5
12
sub order       { 60 }
24
25sub check {
26
23
28
        my ($self, $ctx) = @_;
27
23
31
        croak 'check requires an App::Project::Doctor::Context' unless ref $ctx;
28
29
23
17
        my @findings;
30
23
37
        my $files = $ctx->perl_files('lib', 'script', 'bin');
31
32
23
23
74
27
        for my $rel (@{$files}) {
33
22
22
1
1
21
27
12
126
                my $content = eval { $ctx->slurp($rel) } // do { carp "Cannot slurp $rel"; next };
34
35                # strict / warnings -- skip .t files (they inherit from test harness).
36
21
75
                unless ($rel =~ /\.t$/) {
37
20
59
                        unless ($content =~ /^\s*use\s+strict\b/m) {
38
3
8
                                push @findings, _f(
39                                        severity => 'error',
40                                        message  => "Missing 'use strict' in $rel.",
41                                        file     => $rel,
42                                        fix      => _fix_pragma($ctx, $rel, 'strict'),
43                                );
44                        }
45
20
50
                        unless ($content =~ /^\s*use\s+warnings\b/m) {
46
2
4
                                push @findings, _f(
47                                        severity => 'error',
48                                        message  => "Missing 'use warnings' in $rel.",
49                                        file     => $rel,
50                                        fix      => _fix_pragma($ctx, $rel, 'warnings'),
51                                );
52                        }
53                }
54
55                # Credential scan.
56
21
46
                my @lines = split /\n/, $content;
57
21
32
                for my $i (0 .. $#lines) {
58
90
288
                        for my $pat (@SECRET_PATTERNS) {
59
261
1000
                                if ($lines[$i] =~ $pat) {
60
8
61
                                        push @findings, _f(
61                                                severity => 'error',
62                                                message  => "Possible hardcoded credential in $rel at line " . ($i + 1) . '.',
63                                                file     => $rel,
64                                                line     => $i + 1,
65                                                detail   => 'Move secrets to environment variables or a config file.',
66                                        );
67
8
12
                                        last;
68                                }
69                        }
70                }
71        }
72
73
23
98
        unless (@findings) {
74
11
15
                push @findings, _f(
75                        severity => 'pass',
76                        message  => 'All checked files use strict/warnings; no credential patterns found.',
77                );
78        }
79
80
23
49
        return @findings;
81}
82
83# ---------------------------------------------------------------------------
84# Private helpers
85# ---------------------------------------------------------------------------
86
87sub _f {
88
24
54
        require App::Project::Doctor::Finding;
89
24
61
        return App::Project::Doctor::Finding->new(check_name => 'Security', @_);
90}
91
92sub _fix_pragma {
93
8
17
        my ($ctx, $rel, $pragma) = @_;
94        return sub {
95
4
8
                my $abs = $ctx->abs_path($rel);
96
4
10
                open my $fh, '<', $abs;
97
4
1669
                my @lines = <$fh>;
98
4
9
                close $fh;
99
100                # A shebang must remain the very first line of a script so the OS
101                # can recognise it; preserve it by starting the search one line in.
102
4
788
                my $insert_at = (@lines && $lines[0] =~ /^#!/) ? 1 : 0;
103
4
8
                for my $i ($insert_at .. $#lines) {
104
5
10
                        if ($lines[$i] =~ /^\s*package\s+\S+/) {
105
1
1
                                $insert_at = $i + 1;
106
1
1
                                last;
107                        }
108                }
109
4
8
                splice @lines, $insert_at, 0, "use $pragma;\n";
110
111
4
7
                open my $out, '>', $abs;
112
4
4
276
8
                print {$out} @lines;
113
4
6
                close $out;
114
8
25
        };
115}
116
1171;
118