File Coverage

blib/lib/WWW/Wappalyzer.pm
Criterion Covered Total %
statement 214 214 100.0
branch 87 100 87.0
condition 41 46 89.1
subroutine 21 21 100.0
pod 6 6 100.0
total 369 387 95.3


line stmt bran cond sub pod time code
1             package WWW::Wappalyzer;
2              
3 2     2   134504 use 5.006;
  2         15  
4 2     2   8 use strict;
  2         4  
  2         34  
5 2     2   30 use warnings;
  2         5  
  2         105  
6              
7 2     2   1462 use lib::abs;
  2         5346  
  2         9  
8 2     2   1167 use JSON qw();
  2         22576  
  2         51  
9 2     2   913 use Regexp::Parser;
  2         104388  
  2         841  
10              
11             # List of multi per-page application categories names
12             my %MULTIPLE_APP_CATS = map { $_ => 1 } (
13             'Widgets',
14             'Analytics',
15             'JavaScript frameworks',
16             'JavaScript libraries',
17             'UI frameworks',
18             'Video players',
19             'Font scripts',
20             'Miscellaneous',
21             'Advertising',
22             'Payment processors',
23             'JavaScript graphics',
24             'Marketing automation',
25             'Web server extensions',
26             'WordPress plugins',
27             );
28              
29             =head1 NAME
30              
31             WWW::Wappalyzer - Perl port of Wappalyzer (https://wappalyzer.com)
32              
33             =head1 DESCRIPTION
34              
35             Uncovers the technologies used on websites: detects content management systems, web shops,
36             web servers, JavaScript frameworks, analytics tools and many more.
37              
38             Supports only `scriptSrc`, `scripts`, `html`, `meta`, `headers`, 'cookies' and `url` patterns of
39             Wappalyzer specification. Lacks 'version', 'implies', 'excludes' support in favour of speed.
40              
41             Categories: L
42             Technologies: L
43             More info on Wappalyzer: L
44              
45             =cut
46              
47             our $VERSION = '2.00';
48              
49             =head1 SYNOPSIS
50              
51             use WWW::Wappalyzer;
52             use LWP::UserAgent;
53             use List::Util 'pairmap';
54              
55             my $response = LWP::UserAgent->new->get( 'http://www.drupal.org' );
56             my %detected = WWW::Wappalyzer->new->detect(
57             html => $response->decoded_content,
58             headers => { pairmap { $a => [ $response->headers->header($a) ] } $response->headers->flatten },
59             );
60              
61             # %detected = (
62             # 'Font scripts' => [ 'Google Font API' ],
63             # 'Caching' => [ 'Varnish' ],
64             # 'CDN' => [ 'Fastly' ],
65             # 'CMS' => [ 'Drupal' ],
66             # 'Video players' => [ 'YouTube' ],
67             # 'Tag managers' => [ 'Google Tag Manager' ],
68             # 'Reverse proxies' => [ 'Nginx' ],
69             # 'Web servers' => [ 'Nginx' ],
70             # );
71              
72             =head1 EXPORT
73              
74             None by default.
75              
76             =head1 SUBROUTINES/METHODS
77              
78             =head2 new
79              
80             my $wappalyzer = WWW::Wappalyzer->new( %params )
81              
82             Constructor.
83              
84             Available parameters:
85              
86             categories - optional additional categories array ref to files list (refer 'add_categories_files' below)
87             technologies - optional additional technologies array ref to files list (refer 'add_technologies_files' below)
88              
89             Returns the instance of WWW::Wappalyzer class.
90              
91             =cut
92              
93             sub new {
94 2     2 1 1093 my ( $class, %params ) = @_;
95              
96 2         13 my $self = bless {
97             _categories => {},
98             _cats_file_list => [],
99             _techs_file_list => [],
100             }, $class;
101              
102 2         12 $self->add_categories_files( lib::abs::path( './wappalyzer_src/categories.json' ) );
103 2         6 $self->add_technologies_files( glob lib::abs::path( './wappalyzer_src/technologies' ) . '/*.json' );
104              
105 2 100       13 if ( ref $params{categories} eq 'ARRAY' ) {
106 1         3 $self->add_categories_files( @{ $params{categories} } );
  1         4  
107             }
108              
109 2 100       10 if ( ref $params{technologies} eq 'ARRAY' ) {
110 1         2 $self->add_technologies_files( @{ $params{technologies} } );
  1         2  
111             }
112              
113 2         19831 return $self;
114             }
115              
116             =head2 detect
117              
118             my %detected = $wappalyzer->detect( %params )
119              
120             Tries to detect CMS, framework, etc for given html code, http headers, URL.
121              
122             Available parameters:
123              
124             html - HTML code of web page.
125              
126             headers - Hash ref to http headers list. The value may be a plain string or an array ref
127             of strings for a multi-valued field.
128             Cookies should be passed in 'Set-Cookie' header.
129              
130             url - URL of web page.
131              
132             cats - Array ref to a list of trying categories names, defaults to all.
133             Less categories - less CPU usage.
134              
135             Returns the hash of detected applications by category:
136              
137             (
138             CMS => [ 'Joomla' ],
139             'Javascript frameworks' => [ 'jQuery', 'jQuery UI' ],
140             )
141              
142             =cut
143              
144             sub detect {
145 18     18 1 11169 my ( $self, %params ) = @_;
146              
147 18 50 100     107 return () unless $params{html} || $params{headers} || $params{url};
      66        
148            
149             # search will be case insensitive
150 18 100       63 if ( $params{html} ) {
151 11         278 $params{html} = lc $params{html};
152             }
153              
154 18 100       58 if ( $params{url} ) {
155 1         3 $params{url} = lc $params{url};
156             }
157              
158 18         29 my $headers_ref;
159 18 100       45 if ( $params{headers} ) {
160 14 100       64 die 'Bad headers param' unless ref $params{headers} eq 'HASH';
161              
162             # Make all headers lowercase and array ref valued
163 13         25 $headers_ref = {};
164 13         38 while ( my ( $header, $header_vals_ref ) = each %{ $params{headers} } ) {
  27         109  
165 14 100 66     46 unless ( ref $header_vals_ref ) {
166 9         25 $header_vals_ref = [ $header_vals_ref ];
167             }
168             elsif ( ref $header_vals_ref ne 'ARRAY' ) {
169             next;
170             }
171              
172 13         56 $headers_ref->{ lc $header } = [ map { lc } @$header_vals_ref ];
  17         75  
173             }
174             }
175              
176             # Lazy load and process techs from JSON file
177 17 100       29 $self->_load_categories_and_techs unless scalar keys %{ $self->{_categories} };
  17         76  
178              
179             my @cats = $params{cats} && ( ref( $params{cats} ) || '' ) eq 'ARRAY'
180 17 100 66     119 ? @{ $params{cats} } : $self->get_categories_names;
  2         6  
181              
182              
183 17         32 my %cookies;
184 17 100       54 if ( my $cookies_header = $headers_ref->{'set-cookie'} ) {
185 3         9 for my $cookie_str ( @$cookies_header ) {
186 5 50       31 next unless $cookie_str =~ /^(?.+?)=(?.*?);\s/;
187 2     2   906 $cookies{ lc $+{name} } = $+{value};
  2         834  
  2         2692  
  5         61  
188             }
189             }
190              
191 17         63 my %detected;
192             my %tried_multi_cat_apps;
193 17         40 for my $cat ( @cats ) {
194 1469 50       4053 my $apps_ref = $self->{_categories}{ $cat } or die "Unknown category name $cat";
195              
196             APP:
197 1469         2282 for my $app_ref ( @$apps_ref ) {
198              
199 52613         44951 my $detected;
200              
201             # Some speed optimizations
202 52613 100 100     167615 if ( @cats > 1 && $app_ref->{multi_cat}
      100        
203             && exists $tried_multi_cat_apps{ $app_ref->{name} }
204             ) {
205 8038         9716 $detected = $tried_multi_cat_apps{ $app_ref->{name} };
206             }
207             else {
208             # Try regexes...
209 44575         43230 my $confidence = 0;
210              
211 44575 100 66     92023 if ( defined $headers_ref && exists $app_ref->{headers_rules} ) {
212 5986         5358 my %headers_rules = %{ $app_ref->{headers_rules} };
  5986         18484  
213              
214             HEADER_RULE:
215 5986         11130 while ( my ( $header, $rule ) = each %headers_rules ) {
216 8094 100       17747 my $header_vals_ref = $headers_ref->{ $header } or next;
217              
218 909         1036 for my $header_val ( @$header_vals_ref ) {
219 913 100       4622 if ( $header_val =~ m/$rule->{re}/ ) {
220 10         30 $confidence += $rule->{confidence};
221 10 50       30 if ( $confidence >= 100 ) {
222 10         15 $detected = 1;
223 10         30 last HEADER_RULE;
224             }
225             }
226             }
227             }
228             }
229              
230 44575 100 100     88167 if ( !$detected && exists $app_ref->{cookies_rules} && scalar keys %cookies ) {
      100        
231 576         611 my %cookies_rules = %{ $app_ref->{cookies_rules} };
  576         2168  
232              
233             COOKIE_RULE:
234 576         1267 while ( my ( $cookie, $rule ) = each %cookies_rules ) {
235 843 100       2008 my $cookie_val = $cookies{ $cookie } or next;
236              
237 3 50       40 if ( $cookie_val =~ /$rule->{re}/ ) {
238 3         7 $confidence += $rule->{confidence};
239 3 50       17 if ( $confidence >= 100 ) {
240 3         6 $detected = 1;
241 3         11 last COOKIE_RULE;
242             }
243             }
244             }
245             }
246              
247 44575 100       52401 unless ( $detected ) {
248             # try from most to least relevant method
249             RULES:
250 44562         47295 for my $rule_type ( qw( html url ) ) {
251 89116         96382 my $rule_name = $rule_type . '_rules';
252 89116 100 100     144686 if ( defined $params{ $rule_type } && exists $app_ref->{ $rule_name } ) {
253 18316         16534 for my $rule ( @{ $app_ref->{ $rule_name } } ) {
  18316         33783  
254 18872 100       126920 if ( $params{ $rule_type } =~ m/$rule->{re}/ ) {
255 11         20 $confidence += $rule->{confidence};
256 11 100       28 if ( $confidence >= 100 ) {
257 9         16 $detected = 1;
258 9         26 last RULES;
259             }
260             }
261             }
262             }
263             }
264             }
265              
266             # Some speed optimizations
267 44575 100 100     90537 if ( @cats > 1 && $app_ref->{multi_cat} ) {
268 7560         11375 $tried_multi_cat_apps{ $app_ref->{name} } = $detected;
269             }
270             }
271              
272 52613 100       84742 next unless $detected;
273              
274             # Detected!
275 28         43 push @{ $detected{ $cat } }, $app_ref->{name};
  28         108  
276              
277 28 100       120 last APP unless $MULTIPLE_APP_CATS{ $cat };
278             }
279             }
280              
281 17         2717 return %detected;
282             }
283              
284             =head2 get_categories_names
285              
286             my @cats = $wappalyzer->get_categories_names()
287              
288             Returns the array of all application categories names.
289              
290             =cut
291              
292             sub get_categories_names {
293 16     16 1 36 my ( $self ) = @_;
294              
295             # Lazy load and process categories from JSON files
296 16 100       29 $self->_load_categories_and_techs() unless scalar keys %{ $self->{_categories} };
  16         52  
297              
298 16         29 return keys %{ $self->{_categories} };
  16         637  
299             }
300              
301             # Loads and processes categories and techs from JSON files
302             sub _load_categories_and_techs {
303 4     4   13 my ( $self ) = @_;
304              
305 4         10 my $cats_ref = {};
306              
307 4         8 for my $cats_file ( @{ $self->{_cats_file_list} } ) {
  4         18  
308 7         250 $cats_ref = { %$cats_ref, %{ _load_json( $cats_file ) } };
  7         24  
309             }
310              
311 4         13 for my $techs_file ( @{ $self->{_techs_file_list} } ) {
  4         15  
312 111         534 my $apps_ref = _load_json( $techs_file );
313              
314             # Process apps
315 111         660 while ( my ( $app, $app_ref ) = each %$apps_ref ) {
316              
317 11941 50       23519 my $new_app_ref = _process_app_techs( $app, $app_ref ) or next;
318              
319 11941 50       17021 my @cats = @{ $app_ref->{cats} } or next;
  11941         40904  
320              
321 11941 100       25667 $new_app_ref->{multi_cat} = 1 if @cats > 1;
322              
323 11941         19027 for my $cat_id ( @cats ) {
324 14101 50       51277 my $cat = $cats_ref->{ $cat_id } or die "Bad categorie id $cat_id in app $app";
325              
326 14101         17659 push @{ $self->{_categories}{ $cat->{name} } }, $new_app_ref;
  14101         176418  
327             }
328             }
329             }
330             }
331              
332             # Loads JSON file
333             sub _load_json {
334 118     118   355 my ( $file ) = @_;
335              
336 118 50       7927 open my $fh, '<', $file or die "Can not read file $file.";
337              
338 118         927 local $/ = undef;
339 118         34814 my $json = <$fh>;
340 118         1509 close $fh;
341              
342             # Replace html entities with oridinary symbols
343 118         10225 $json =~ s{>}{>}xig;
344 118         10377 $json =~ s{<}{<}xig;
345              
346 118         251 my $res = eval { JSON::decode_json( $json ) };
  118         62729  
347              
348 118 50       494 die "Can't parse JSON file $file: $@" if $@;
349              
350 118 50       396 die "$file has invalid format" unless ref $res eq 'HASH';
351              
352 118         1977 return $res;
353             }
354              
355             # Process techs of given app
356             sub _process_app_techs {
357 11941     11941   19020 my ( $app, $app_ref ) = @_;
358              
359 11941         28022 my $new_app_ref = { name => $app };
360              
361 11941         20918 my @fields = grep { exists $app_ref->{ $_ } }
  83587         134241  
362             qw( scriptSrc scripts html meta headers cookies url );
363              
364 11941         14846 my @html_rules;
365              
366             # Precompile regexps
367 11941         15131 for my $field ( @fields ) {
368 11895         26101 my $rule_ref = $app_ref->{ $field };
369             my @rules_list = !ref $rule_ref ? _parse_rule( $rule_ref )
370 11895 100       35064 : ref $rule_ref eq 'ARRAY' ? ( map { _parse_rule( $_ ) } @$rule_ref )
  2432 100       5253  
371             : () ;
372              
373 11895 100 100     67570 if ( $field eq 'html' || $field eq 'scripts' ) {
    100 66        
    100          
    100          
    50          
374 1449         4092 push @html_rules, map { $_->{re} = qr/(?-x:$_->{re})/; $_ } @rules_list;
  1857         57949  
  1857         8480  
375             }
376             elsif ( $field eq 'scriptSrc' ) {
377             push @html_rules,
378             map {
379 6371         11839 $_->{re} = qr/
  7163         264424  
380             < \s* script [^>]+ src \s* = \s* ["'] [^"']* (?-x:$_->{re}) [^"']* ["']
381             /x;
382 7163         34685 $_
383             } @rules_list;
384             }
385             elsif ( $field eq 'url' ) {
386 272         766 my @url_rules = map { $_->{re} = qr/(?-x:$_->{re})/; $_ } @rules_list;
  276         6852  
  276         1247  
387 272         976 $new_app_ref->{url_rules} = _optimize_rules( \@url_rules );
388             }
389             elsif ( $field eq 'meta' ) {
390 1403         6103 for my $key ( keys %$rule_ref ) {
391 1583         4106 my $lc_key = lc $key;
392 1583         19666 my $name_re = qr/ name \s* = \s* ["']? $lc_key ["']? /x;
393 1583         4518 my $rule = _parse_rule( $rule_ref->{ $key } );
394 1583         33520 $rule->{re} = qr/$rule->{re}/;
395 1583         44430 my $content_re = qr/ content \s* = \s* ["'] [^"']* (?-x:$rule->{re}) [^"']* ["'] /x;
396              
397 1583         53667 $rule->{re} = qr/
398             < \s* meta \s+
399             (?:
400             (?: $name_re \s+ $content_re )
401             # | (?: $content_re \s+ $name_re ) # hangs sometimes
402             )
403             /x;
404            
405 1583         9923 push @html_rules, $rule;
406             }
407             }
408             elsif ( $field eq 'headers' || $field eq 'cookies' ) {
409 2400         9024 for my $key ( keys %$rule_ref ) {
410 3328         7072 my $rule = _parse_rule( $rule_ref->{ $key } );
411 3328         47076 $rule->{re} = qr/$rule->{re}/;
412 3328         18526 $new_app_ref->{ $field . '_rules' }{ lc $key } = $rule;
413             }
414             }
415             }
416              
417 11941 100       29369 if ( @html_rules ) {
418 8149         23018 $new_app_ref->{html_rules} = _optimize_rules( \@html_rules );
419             }
420              
421 11941         41636 return $new_app_ref;
422             }
423              
424             # separate regexp and other optional parameters from the rule
425             sub _parse_rule {
426 14207     14207   24968 my ( $rule ) = @_;
427            
428 14207         46186 my ( $re, @tags ) = split /\\;/, $rule;
429            
430 14207         16556 my $confidence;
431 14207         22060 for my $tag ( @tags ) {
432 2658 100       10562 if ( ( $confidence ) = $tag =~ /^\s*confidence\s*:\s*(\d+)\s*$/ ) {
433             # supports only confidence for now
434 266         659 last;
435             }
436             }
437            
438             return {
439 14207 100 100     30601 re => _escape_re( defined( $re ) ? $re : '' ),
440             confidence => $confidence || 100,
441             };
442             }
443              
444             # Escape special symbols in regexp string of config file
445             sub _escape_re {
446 14207     14207   22667 my ( $re ) = @_;
447            
448             # Escape { } braces
449             #$re =~ s/ ([{}]) /[$1]/xig;
450              
451             # Escape [^]
452 14207         28583 $re =~ s{\Q[^]\E}{[\\^]}ig;
453              
454             # Escape \\1
455 14207         20463 $re =~ s{\Q\1\E}{\\\\1}ig;
456              
457             # Escape (?!
458 14207         20843 $re =~ s{[(][?][!]}{([?]!}ig;
459            
460             # turn literals in regexp to lowercase to make case insensitive search
461             # i flag will be slower because we makes many searches in one text
462 2     2   17 no warnings 'redefine';
  2         5  
  2         793  
463 14207     100   60021 local *Regexp::Parser::warn = sub {}; # it may be too noisy
464            
465 14207         50828 my $parser = Regexp::Parser->new();
466 14207 100       10527754 if ( $parser->regex($re) ) {
467 12316         9301308 $re = '';
468            
469 12316         24404 while ( my $node = $parser->next ) {
470 310651         25864836 my $ref = ref $node;
471 310651 100 100     546025 if ( $ref eq 'Regexp::Parser::exact' || $ref eq 'Regexp::Parser::anyof_char' ) {
472 270121         393594 $re .= lc $node->raw;
473             }
474             else {
475 40530         78766 $re .= $node->raw;
476             }
477             }
478             }
479            
480 14207         1115866 return $re;
481             }
482              
483             # If possible combine all rules in one regexp
484             sub _optimize_rules {
485 8421     8421   15438 my ( $rules ) = @_;
486            
487 8421 100 100     27328 if ( @$rules > 1 && @$rules == grep { $_->{confidence} == 100 } @$rules ) {
  4148         12884  
488             # can combine only if confidence for each is 100
489 1575         3904 my $re = join '|', map { $_->{re} } @$rules;
  3782         9574  
490             return [{
491 1575         107258 re => qr/$re/,
492             confidence => 100,
493             }];
494             }
495            
496 6846         18427 return $rules;
497             }
498              
499             =head2 add_categories_files
500              
501             $wappalyzer->add_categories_files( @filepaths )
502              
503             Puts additional categories files to a list of processed categories files.
504             See lib/WWW/wappalyzer_src/categories.json as format sample.
505              
506             =cut
507              
508             sub add_categories_files {
509 4     4 1 953 my ( $self, @filepaths ) = @_;
510              
511 4         6 push @{ $self->{_cats_file_list} }, @filepaths;
  4         16  
512              
513             # just clear out categories to lazy load later
514 4         18277 $self->{_categories} = {};
515             }
516              
517             =head2 add_technologies_files
518              
519             $wappalyzer->add_technologies_files( @filepaths )
520              
521             Puts additional techs files to a list of processed techs files.
522             See lib/WWW/wappalyzer_src/technologies/a.json as format sample.
523              
524             =cut
525              
526             sub add_technologies_files {
527 4     4 1 855 my ( $self, @filepaths ) = @_;
528              
529 4         12 push @{ $self->{_techs_file_list} }, @filepaths;
  4         29  
530              
531             # just clear out categories to lazy load later
532 4         15 $self->{_categories} = {};
533             }
534              
535             =head2 reload_files
536              
537             $wappalyzer->reload_files()
538              
539             Ask to reload data from additional categories and technologies files
540             those may be changed in runtime.
541              
542             =cut
543              
544             sub reload_files {
545 1     1 1 1001 my ( $self ) = @_;
546              
547             # just clear out categories to lazy load later
548 1         17690 $self->{_categories} = {};
549             }
550              
551              
552             =head1 AUTHOR
553              
554             Alexander Nalobin, C<< >>
555              
556             =head1 BUGS
557              
558             Please report any bugs or feature requests to C, or through
559             the web interface at L. I will be notified, and then you'll
560             automatically be notified of progress on your bug as I make changes.
561              
562              
563             =head1 SUPPORT
564              
565             You can find documentation for this module with the perldoc command.
566              
567             perldoc WWW::Wappalyzer
568              
569              
570             You can also look for information at:
571              
572             =over 4
573              
574             =item * GitHub
575              
576             L
577              
578             =item * RT: CPAN's request tracker (report bugs here)
579              
580             L
581              
582             =item * AnnoCPAN: Annotated CPAN documentation
583              
584             L
585              
586             =item * CPAN Ratings
587              
588             L
589              
590             =item * Search CPAN
591              
592             L
593              
594             =back
595              
596              
597             =head1 ACKNOWLEDGEMENTS
598              
599              
600             =head1 LICENSE AND COPYRIGHT
601              
602             Copyright 2013-2015 Alexander Nalobin.
603              
604             This program is free software; you can redistribute it and/or modify it
605             under the terms of either: the GNU General Public License as published
606             by the Free Software Foundation; or the Artistic License.
607              
608             See http://dev.perl.org/licenses/ for more information.
609              
610              
611             =cut
612              
613             1; # End of WWW::Wappalyzer