File Coverage

blib/lib/Net/SecurityCenter/REST.pm
Criterion Covered Total %
statement 150 201 74.6
branch 34 74 45.9
condition 25 77 32.4
subroutine 20 26 76.9
pod 12 13 92.3
total 241 391 61.6


line stmt bran cond sub pod time code
1             package Net::SecurityCenter::REST;
2              
3 2     2   13 use warnings;
  2         6  
  2         67  
4 2     2   10 use strict;
  2         4  
  2         37  
5              
6 2     2   902 use version;
  2         3765  
  2         11  
7 2     2   154 use Carp ();
  2         4  
  2         34  
8 2     2   1070 use HTTP::Cookies;
  2         19085  
  2         66  
9 2     2   1347 use JSON;
  2         18962  
  2         11  
10 2     2   1606 use LWP::UserAgent;
  2         54847  
  2         84  
11              
12 2     2   1086 use Net::SecurityCenter::Error;
  2         6  
  2         67  
13 2     2   955 use Net::SecurityCenter::Utils qw(trim dumper);
  2         8  
  2         1264  
14              
15             our $VERSION = '0.300';
16             our $ERROR;
17              
18             #-------------------------------------------------------------------------------
19             # CONSTRUCTOR
20             #-------------------------------------------------------------------------------
21              
22             sub new {
23              
24 1     1 1 10 my ( $class, $host, $options ) = @_;
25              
26 1 50       6 if ( !$host ) {
27 0         0 Carp::croak 'Specify the Tenable.sc hostname or IP address';
28             }
29              
30 1         37 my $agent = LWP::UserAgent->new();
31 1         3362 my $cookie_jar = HTTP::Cookies->new();
32              
33 1         54 $agent->agent( _agent() );
34 1         123 $agent->ssl_opts( verify_hostname => 0 );
35              
36 1         40 my $timeout = delete( $options->{'timeout'} );
37 1   50     28 my $ssl_opts = delete( $options->{'ssl_options'} ) || {};
38 1   50     25 my $logger = delete( $options->{'logger'} ) || undef;
39 1   50     14 my $scheme = delete( $options->{'scheme'} ) || 'https';
40              
41 1         8 my $url = "$scheme://$host/rest";
42              
43 1 50       4 if ($timeout) {
44 0         0 $agent->timeout($timeout);
45             }
46              
47 1 50       11 if ($ssl_opts) {
48 1         6 $agent->ssl_opts($ssl_opts);
49             }
50              
51 1         13 $agent->cookie_jar($cookie_jar);
52              
53 1         185 my $self = {
54             host => $host,
55             options => $options,
56             url => $url,
57             token => undef,
58             api_key => undef,
59             agent => $agent,
60             logger => $logger,
61             error => undef,
62             };
63              
64 1         3 bless $self, $class;
65              
66 1 50       5 if ( !$self->_check ) {
67 0         0 Carp::croak $self->{error}->message;
68             }
69              
70 1         11 return $self;
71              
72             }
73              
74             #-------------------------------------------------------------------------------
75             # UTILS
76             #-------------------------------------------------------------------------------
77              
78             sub _agent {
79              
80 1     1   6 my $class = __PACKAGE__;
81 1         17 ( my $agent = $class ) =~ s{::}{-}g;
82              
83 1         53 return "$agent/" . $class->VERSION;
84              
85             }
86              
87             #-------------------------------------------------------------------------------
88              
89             sub _check {
90              
91 1     1   7 my ($self) = @_;
92              
93 1         13 my $response = $self->request( 'GET', '/system' );
94              
95 1 50       5 if ( !$response ) {
96 0         0 $self->error( 'Failed to connect to Tenable.sc (' . $self->{'host'} . ')', 500 );
97 0         0 return;
98             }
99              
100 1         4 $self->{'version'} = $response->{'version'};
101 1         2 $self->{'build_id'} = $response->{'buildID'};
102 1         3 $self->{'license'} = $response->{'licenseStatus'};
103 1         2 $self->{'uuid'} = $response->{'uuid'};
104              
105 1         7 $self->logger( 'info', 'Tenable.sc ' . $self->{'version'} . ' (Build ID:' . $self->{'build_id'} . ')' );
106 1         87 return 1;
107              
108             }
109              
110             #-------------------------------------------------------------------------------
111              
112             sub error {
113              
114 0     0 1 0 my ( $self, $message, $code ) = @_;
115              
116 0 0       0 if ( defined $message ) {
117 0         0 $self->{error} = Net::SecurityCenter::Error->new( $message, $code );
118 0         0 return;
119             } else {
120 0         0 return $self->{error};
121             }
122              
123             }
124              
125             #-------------------------------------------------------------------------------
126             # REST HELPER METHODS (get, head, put, post, delete and patch)
127             #-------------------------------------------------------------------------------
128              
129             for my $sub_name (qw/get head put post delete patch/) {
130              
131             my $req_method = uc $sub_name;
132 2     2   20 no strict 'refs'; ## no critic
  2         5  
  2         2981  
133 0 0 0 0 1 0 eval <<"HERE"; ## no critic
  0 50 0 32 1 0  
  0 0 0 0 0 0  
  0 0 33 0 1 0  
  32 50 66 4 1 138  
  32 0 100 0 1 87  
  32   0     259  
  32   0     177  
  0   0     0  
  0   0     0  
  0   0     0  
  0   0     0  
  0   33     0  
  0   66     0  
  0   100     0  
  0   0     0  
  4   0     17  
  4   0     10  
  4         39  
  4         38  
  0         0  
  0         0  
  0         0  
  0         0  
134             sub $sub_name {
135             my ( \$self, \$path, \$params ) = \@_;
136             my \$class = ref \$self;
137             ( \@_ == 2 || ( \@_ == 3 && ref \$params eq 'HASH' ) )
138             or Carp::croak("Usage: \$class->$sub_name( PATH, [HASHREF] )\n");
139             return \$self->request('$req_method', \$path, \$params || {});
140             }
141             HERE
142              
143             }
144              
145             #-------------------------------------------------------------------------------
146              
147             sub request {
148              
149 42     42 1 155 my ( $self, $method, $path, $params ) = @_;
150              
151 42 50 66     213 ( @_ == 3 || @_ == 4 )
152             or Carp::croak( 'Usage: ' . __PACKAGE__ . '->request(GET|POST|PUT|DELETE|PATCH, $PATH, [\%PARAMS])' );
153              
154 42         134 $method = uc($method);
155 42         196 $path =~ s{^/}{};
156              
157 42 50       259 if ( $method !~ m/(?:GET|POST|PUT|DELETE|PATCH)/ ) {
158 0         0 Carp::carp( $method . ' is an unsupported request method' );
159 0         0 Croak::croak( 'Usage: ' . __PACKAGE__ . '->request(GET|POST|PUT|DELETE|PATCH, $PATH, [\%PARAMS])' );
160             }
161              
162 42         146 my $url = $self->{'url'} . "/$path";
163 42         76 my $agent = $self->{'agent'};
164 42         321 my $request = HTTP::Request->new( $method => $url );
165              
166 42         6047 $self->logger( 'debug', "Method: $method" );
167 42         204 $self->logger( 'debug', "Path: $path" );
168 42         189 $self->logger( 'debug', "URL: $url" );
169              
170             # Don't log credential
171 42 100       176 if ( $path !~ /token/ ) {
172 39         199 $self->logger( 'debug', "Params: " . dumper($params) );
173             }
174              
175 42 50       378 if ( $params->{'file'} ) {
176              
177 0         0 require HTTP::Request::Common;
178              
179             $request = HTTP::Request::Common::POST(
180             $url,
181             'Content-Type' => 'multipart/form-data',
182             'Content' => [
183 0         0 Filedata => [ $params->{'file'}, undef, 'Content-Type' => 'application/octet-stream' ]
184             ],
185             );
186              
187             } else {
188              
189 42         228 $request->header( 'Content-Type', 'application/json' );
190              
191 42 50       2734 if ($params) {
192 42         295 $request->content( encode_json($params) );
193             }
194              
195             }
196              
197             # Reset error
198 42         978 $self->{'error'} = undef;
199              
200 42         187 my $response = $agent->request($request);
201 42         317853 my $response_content = $response->content();
202 42         848 my $response_ctype = $response->headers->{'content-type'};
203 42         284 my $response_code = $response->code();
204              
205 42         464 my $result = {};
206 42         151 my $is_json = ( $response_ctype =~ /application\/json/ );
207              
208             # Force JSON decode for 403 Forbidden message without JSON Content-Type header
209 42 50 33     195 if ( $response_code == 403 && $response_ctype !~ /application\/json/ ) {
210 0         0 $is_json = 1;
211             }
212              
213 42 50       127 if ($is_json) {
214 42         100 $result = eval { decode_json($response_content) };
  42         3978  
215             }
216              
217 42         231 $self->logger( 'debug', 'Response status: ' . $response->status_line );
218              
219 42 100       206 if ( ref $result->{warnings} eq 'ARRAY' ) {
220 41         105 foreach my $warning ( @{ $result->{'warnings'} } ) {
  41         137  
221 0         0 Carp::carp( $warning->{code} . ': ' . $warning->{warning} );
222             }
223             }
224              
225 42 50       183 if ( $response->is_success() ) {
226              
227 42 50       491 if ($is_json) {
228              
229 42 50       134 if ( defined( $result->{'response'} ) ) {
    0          
230 42         637 return $result->{'response'};
231              
232             } elsif ( $result->{'error_msg'} ) {
233              
234 0         0 my $error_msg = trim( $result->{'error_msg'} );
235              
236 0         0 $self->logger( 'error', $error_msg );
237 0         0 $self->error( $error_msg, $response_code );
238              
239 0         0 return;
240              
241             }
242              
243             }
244              
245 0         0 return $response_content;
246              
247             }
248              
249 0 0 0     0 if ( $is_json && exists( $result->{'error_msg'} ) ) {
250              
251 0         0 my $error_msg = trim( $result->{'error_msg'} );
252              
253 0         0 $self->logger( 'error', $error_msg );
254 0         0 $self->error( $error_msg, $response_code );
255              
256 0         0 return;
257              
258             }
259              
260 0         0 $self->logger( 'error', $response_content );
261 0         0 $self->error( $response_content, $response_code );
262              
263 0         0 return;
264              
265             }
266              
267             #-------------------------------------------------------------------------------
268             # HELPER METHODS
269             #-------------------------------------------------------------------------------
270              
271             sub upload {
272              
273 0     0 1 0 my ( $self, $file ) = @_;
274              
275 0 0       0 ( @_ == 2 )
276             or Carp::croak( 'Usage: ' . __PACKAGE__ . '->upload( $FILE )' );
277              
278 0         0 return $self->request( 'POST', '/file/upload', { 'file' => $file } );
279              
280             }
281              
282             #-------------------------------------------------------------------------------
283              
284             sub logger {
285              
286 214     214 1 4289 my ( $self, $level, $message ) = @_;
287              
288 214 50       619 return if ( !$self->{'logger'} );
289              
290 214         388 $level = lc($level);
291              
292 214   50     1687 my $caller = ( caller(1) )[3] || q{};
293 214         2266 $caller =~ s/(::)(\w+)$/->$2/;
294              
295 214         1223 $self->{'logger'}->$level("$caller - $message");
296              
297 214         80690 return 1;
298              
299             }
300              
301             #-------------------------------------------------------------------------------
302              
303             sub login {
304              
305 3     3 1 10 my ( $self, %args ) = @_;
306              
307             # Detect "flat" login argument with username and password
308 3 100 66     51 if ( !( defined( $args{'access_key'} ) && defined( $args{'secret_key'} ) )
      66        
      66        
309             && !( defined( $args{'username'} ) && defined( $args{'password'} ) ) )
310             {
311              
312 1         7 my $username = ( keys %args )[0];
313 1         4 my $password = $args{$username};
314              
315 1         14 %args = (
316             username => $username,
317             password => $password,
318             );
319              
320             }
321              
322 3         12 my $username = delete( $args{'username'} );
323 3         7 my $password = delete( $args{'password'} );
324 3         6 my $access_key = delete( $args{'access_key'} );
325 3         7 my $secret_key = delete( $args{'secret_key'} );
326              
327 3 50 66     15 if ( !$username && !$access_key ) {
328 0         0 Carp::croak('Specify username/password or API Key');
329             }
330              
331 3 100       7 if ($username) {
332              
333 2         15 my $response = $self->request(
334             'POST', '/token',
335             {
336             username => $username,
337             password => $password
338             }
339             );
340              
341 2 50       9 return if ( !$response );
342              
343 2         6 $self->{'token'} = $response->{'token'};
344 2         12 $self->{'agent'}->default_header( 'X-SecurityCenter', $self->{'token'} );
345              
346 2         151 $self->logger( 'info', 'Connected to Tenable.sc (' . $self->{'host'} . ')' );
347 2         9 $self->logger( 'debug', "User: $username" );
348              
349             }
350              
351 3 100       11 if ($access_key) {
352              
353 1         31 my $version_check = ( version->parse( $self->{'version'} ) <=> version->parse('5.13.0') );
354              
355 1 50       7 if ( $version_check < 0 ) {
356 0         0 Carp::croak "API Key Authentication require Tenable.sc v5.13.0 or never";
357             }
358              
359 1         4 $self->{'api_key'} = 1;
360 1         8 $self->{'agent'}->default_header( 'X-APIKey', "accessKey=$access_key; secretKey=$secret_key" );
361              
362 1         75 my $response = $self->request( 'GET', '/currentUser' );
363              
364 1 50       5 return if ( !$response );
365              
366 1         7 $self->logger( 'info', 'Connected to Tenable.sc (' . $self->{'host'} . ') using API Key' );
367              
368             }
369              
370 3         15 return 1;
371              
372             }
373              
374             #-------------------------------------------------------------------------------
375              
376             sub logout {
377              
378 1     1 1 3 my ($self) = @_;
379              
380 1 50       5 if ( $self->{'token'} ) {
381 1         4 $self->request( 'DELETE', '/token' );
382 1         3 $self->{'token'} = undef;
383             }
384              
385 1 50       5 if ( $self->{'api_key'} ) {
386 1         8 $self->{'agent'}->default_header( 'X-APIKey', undef );
387 1         58 $self->{'api_key'} = undef;
388             }
389              
390 1         7 $self->logger( 'info', 'Disconnected from Tenable.sc (' . $self->{'host'} . ')' );
391              
392 1         7 return 1;
393              
394             }
395              
396             #-------------------------------------------------------------------------------
397              
398             sub DESTROY {
399              
400 1     1   802 my ($self) = @_;
401              
402 1 50       6 if ( $self->{'token'} ) {
403 0         0 $self->logout();
404             }
405              
406 1         64 return;
407              
408             }
409              
410             #-------------------------------------------------------------------------------
411              
412             1;
413              
414             __END__