File Coverage

blib/lib/Net/SecurityCenter/REST.pm
Criterion Covered Total %
statement 163 202 80.6
branch 37 74 50.0
condition 28 77 36.3
subroutine 22 26 84.6
pod 12 13 92.3
total 262 392 66.8


line stmt bran cond sub pod time code
1             package Net::SecurityCenter::REST;
2              
3 2     2   13 use warnings;
  2         4  
  2         62  
4 2     2   10 use strict;
  2         4  
  2         36  
5              
6 2     2   877 use version;
  2         3604  
  2         11  
7 2     2   156 use Carp ();
  2         4  
  2         33  
8 2     2   985 use HTTP::Cookies;
  2         18372  
  2         60  
9 2     2   1373 use JSON;
  2         18589  
  2         10  
10 2     2   1523 use LWP::UserAgent;
  2         63096  
  2         77  
11              
12 2     2   968 use Net::SecurityCenter::Error;
  2         6  
  2         63  
13 2     2   938 use Net::SecurityCenter::Utils qw(trim dumper);
  2         6  
  2         1208  
14              
15             our $VERSION = '0.311';
16             our $ERROR;
17              
18             #-------------------------------------------------------------------------------
19             # CONSTRUCTOR
20             #-------------------------------------------------------------------------------
21              
22             sub new {
23              
24 1     1 1 11 my ( $class, $host, $options ) = @_;
25              
26 1 50       14 if ( !$host ) {
27 0         0 Carp::croak 'Specify the Tenable.sc hostname or IP address';
28             }
29              
30 1         34 my $agent = LWP::UserAgent->new();
31 1         3522 my $cookie_jar = HTTP::Cookies->new();
32              
33 1         64 $agent->agent( _agent() );
34 1         89 $agent->ssl_opts( verify_hostname => 0 ); # Disable Host verification
35              
36 1         50 my $timeout = delete( $options->{'timeout'} );
37 1   50     35 my $ssl_opts = delete( $options->{'ssl_options'} ) || {};
38 1   50     27 my $logger = delete( $options->{'logger'} ) || undef;
39 1   50     18 my $scheme = delete( $options->{'scheme'} ) || 'https';
40              
41 1         10 my $url = "$scheme://$host/rest";
42              
43 1 50       9 if ($timeout) {
44 0         0 $agent->timeout($timeout);
45             }
46              
47 1 50       9 if ($ssl_opts) {
48 1         4 $agent->ssl_opts( %{$ssl_opts} );
  1         6  
49             }
50              
51 1         21 $agent->cookie_jar($cookie_jar);
52              
53 1         184 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         4 bless $self, $class;
65              
66 1 50       6 if ( !$self->_check ) {
67 0         0 Carp::croak $self->{error}->message;
68             }
69              
70 1         12 return $self;
71              
72             }
73              
74             #-------------------------------------------------------------------------------
75             # UTILS
76             #-------------------------------------------------------------------------------
77              
78             sub _agent {
79              
80 1     1   5 my $class = __PACKAGE__;
81 1         15 ( my $agent = $class ) =~ s{::}{-}g;
82              
83 1         43 return "$agent/" . $class->VERSION;
84              
85             }
86              
87             #-------------------------------------------------------------------------------
88              
89             sub _check {
90              
91 1     1   2 my ($self) = @_;
92              
93 1         11 my $response = $self->request( 'GET', '/system' );
94              
95 1 50       4 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         3 $self->{'build_id'} = $response->{'buildID'};
102 1         5 $self->{'license'} = $response->{'licenseStatus'};
103 1         3 $self->{'uuid'} = $response->{'uuid'};
104              
105 1         7 $self->logger( 'info', 'Tenable.sc ' . $self->{'version'} . ' (Build ID:' . $self->{'build_id'} . ')' );
106 1         82 return 1;
107              
108             }
109              
110             #-------------------------------------------------------------------------------
111              
112             sub error {
113              
114 1     1 1 6 my ( $self, $message, $code ) = @_;
115              
116 1 50       5 if ( defined $message ) {
117 1         21 $self->{error} = Net::SecurityCenter::Error->new( $message, $code );
118 1         3 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   17 no strict 'refs'; ## no critic
  2         4  
  2         2951  
133 2 0 0 2 1 27 eval <<"HERE"; ## no critic
  2 50 33 34 1 7  
  2 0 50 0 0 11  
  2 0 33 0 1 26  
  34 50 66 5 1 143  
  34 0 100 0 1 76  
  34   0     210  
  34   0     163  
  0   0     0  
  0   0     0  
  0   0     0  
  0   0     0  
  0   33     0  
  0   66     0  
  0   100     0  
  0   0     0  
  5   0     27  
  5   0     21  
  5         41  
  5         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 47     47 1 155 my ( $self, $method, $path, $params ) = @_;
150              
151 47 50 66     203 ( @_ == 3 || @_ == 4 )
152             or Carp::croak( 'Usage: ' . __PACKAGE__ . '->request(GET|POST|PUT|DELETE|PATCH, $PATH, [\%PARAMS])' );
153              
154 47         104 $method = uc($method);
155 47         184 $path =~ s{^/}{};
156              
157 47 50       282 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 47         151 my $url = $self->{'url'} . "/$path";
163 47         73 my $agent = $self->{'agent'};
164 47         296 my $request = HTTP::Request->new( $method => $url );
165              
166 47         6206 $self->logger( 'debug', "Method: $method" );
167 47         235 $self->logger( 'debug', "Path: $path" );
168 47         186 $self->logger( 'debug', "URL: $url" );
169              
170             # Don't log credential
171 47 100       185 if ( $path !~ /token/ ) {
172 44         160 $self->logger( 'debug', "Params: " . dumper($params) );
173             }
174              
175 47 50       339 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 47         199 $request->header( 'Content-Type', 'application/json' );
190              
191 47 50       2872 if ($params) {
192 47         274 $request->content( encode_json($params) );
193             }
194              
195             }
196              
197             # Reset error
198 47         981 $self->{'error'} = undef;
199              
200 47         174 my $response = $agent->request($request);
201 47         337500 my $response_content = $response->content();
202 47         815 my $response_ctype = $response->headers->{'content-type'};
203 47         315 my $response_code = $response->code();
204              
205 47         446 my $result = {};
206 47         165 my $is_json = ( $response_ctype =~ /application\/json/ );
207              
208             # Force JSON decode for 403 Forbidden message without JSON Content-Type header
209 47 50 33     190 if ( $response_code == 403 && $response_ctype !~ /application\/json/ ) {
210 0         0 $is_json = 1;
211             }
212              
213 47 50       144 if ($is_json) {
214 47         82 $result = eval { decode_json($response_content) };
  47         3826  
215             }
216              
217 47         189 $self->logger( 'debug', 'Response status: ' . $response->status_line );
218              
219 47 100       205 if ( ref $result->{warnings} eq 'ARRAY' ) {
220 43         78 foreach my $warning ( @{ $result->{'warnings'} } ) {
  43         109  
221 0         0 Carp::carp( $warning->{code} . ': ' . $warning->{warning} );
222             }
223             }
224              
225 47 100       165 if ( $response->is_success() ) {
226              
227 46 50       443 if ($is_json) {
228              
229 46 50       118 if ( defined( $result->{'response'} ) ) {
    0          
230 46         675 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 1 50 33     15 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 1         6 $self->logger( 'error', $response_content );
261 1         15 $self->error( $response_content, $response_code );
262              
263 1         12 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 240     240 1 3992 my ( $self, $level, $message ) = @_;
287              
288 240 50       638 return if ( !$self->{'logger'} );
289              
290 240         452 $level = lc($level);
291              
292 240   50     1651 my $caller = ( caller(1) )[3] || q{};
293 240         2309 $caller =~ s/(::)(\w+)$/->$2/;
294              
295 240         1262 $self->{'logger'}->$level("$caller - $message");
296              
297 240         85189 return 1;
298              
299             }
300              
301             #-------------------------------------------------------------------------------
302              
303             sub login {
304              
305 3     3 1 9 my ( $self, %args ) = @_;
306              
307             # Detect "flat" login argument with username and password
308 3 100 66     36 if ( !( defined( $args{'access_key'} ) && defined( $args{'secret_key'} ) )
      66        
      66        
309             && !( defined( $args{'username'} ) && defined( $args{'password'} ) ) )
310             {
311              
312 1         5 my $username = ( keys %args )[0];
313 1         7 my $password = $args{$username};
314              
315 1         13 %args = (
316             username => $username,
317             password => $password,
318             );
319              
320             }
321              
322 3         8 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         5 my $secret_key = delete( $args{'secret_key'} );
326              
327 3 50 66     10 if ( !$username && !$access_key ) {
328 0         0 Carp::croak('Specify username/password or API Key');
329             }
330              
331 3 100       10 if ($username) {
332              
333 2         12 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         150 $self->logger( 'info', 'Connected to Tenable.sc (' . $self->{'host'} . ')' );
347 2         14 $self->logger( 'debug', "User: $username" );
348              
349             }
350              
351 3 100       11 if ($access_key) {
352              
353 1         24 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         3 $self->{'api_key'} = 1;
360 1         7 $self->{'agent'}->default_header( 'X-APIKey', "accessKey=$access_key; secretKey=$secret_key" );
361              
362 1         71 my $response = $self->request( 'GET', '/currentUser' );
363              
364 1 50       4 return if ( !$response );
365              
366 1         8 $self->logger( 'info', 'Connected to Tenable.sc (' . $self->{'host'} . ') using API Key' );
367              
368             }
369              
370 3         14 return 1;
371              
372             }
373              
374             #-------------------------------------------------------------------------------
375              
376             sub logout {
377              
378 1     1 1 4 my ($self) = @_;
379              
380 1 50       6 if ( $self->{'token'} ) {
381 1         5 $self->request( 'DELETE', '/token' );
382 1         6 $self->{'token'} = undef;
383             }
384              
385 1 50       5 if ( $self->{'api_key'} ) {
386 1         7 $self->{'agent'}->default_header( 'X-APIKey', undef );
387 1         59 $self->{'api_key'} = undef;
388             }
389              
390 1         7 $self->logger( 'info', 'Disconnected from Tenable.sc (' . $self->{'host'} . ')' );
391              
392 1         5 return 1;
393              
394             }
395              
396             #-------------------------------------------------------------------------------
397              
398             sub DESTROY {
399              
400 1     1   751 my ($self) = @_;
401              
402 1 50       5 if ( $self->{'token'} ) {
403 0         0 $self->logout();
404             }
405              
406 1         46 return;
407              
408             }
409              
410             #-------------------------------------------------------------------------------
411              
412             1;
413              
414             __END__