package pf::fingerbank;

=head1 NAME

pf::fingerbank

=head1 DESCRIPTION

Methods to interact with Fingerbank librairy

=cut

use strict;
use warnings;

use JSON::MaybeXS;

use fingerbank::Model::DHCP_Fingerprint;
use fingerbank::Model::DHCP_Vendor;
use fingerbank::Model::MAC_Vendor;
use fingerbank::Query;
use fingerbank::FilePath;
use fingerbank::Model::Endpoint;
use fingerbank::Util;
use fingerbank::DB_Factory;
use fingerbank::Constant qw($UPSTREAM_SCHEMA);
use pf::cluster;
use pf::constants;
use pf::constants::fingerbank qw($RATE_LIMIT);
use pf::error qw(is_success);
use pf::file_paths qw($network_behavior_policy_config_file);

use pf::node;
use pf::client;
use pf::error qw(is_error);
use pf::CHI;
use pf::log;
use pf::node qw(node_modify node_attributes);
use pf::dal::node;
use pf::StatsD::Timer;
use fingerbank::Config;
use fingerbank::Collector;
use pf::AtFork;
use DateTime;
use DateTime::Format::RFC3339;
use pf::config qw(%Config);
use pf::util qw(isdisabled isenabled valid_mac);

# Do not remove, even if its not explicitely used. When taking collector requests out of the cache, this must be imported.
use URI::http;

our @fingerbank_based_security_event_triggers = ('Device', 'DHCP_Fingerprint', 'DHCP_Vendor', 'MAC_Vendor');

our %ACTION_MAP = (
    "update-upstream-db" => sub {
        pf::fingerbank::_update_fingerbank_component("Upstream database", sub{
            my ($status, $status_msg) = fingerbank::DB::update_upstream();
            return ($status, $status_msg);
        });
    },
);

our %ACTION_MAP_CONDITION = (
    "update-upstream-db" => sub {
        return $TRUE;
    },
);

our %RECORD_RESULT_ATTR_MAP = (
    most_accurate_user_agent => "user_agent",
    hostname => "computername",
    map { $_ => $_ } qw(dhcp_fingerprint dhcp_vendor dhcp6_fingerprint dhcp6_enterprise),
);

use fingerbank::Config;
$fingerbank::Config::CACHE = cache();

my $collector;
my $collector_ua;
my $api_client;

=head1 METHODS

=head2 process

=cut

sub process {
    my $timer = pf::StatsD::Timer->new();
    my ( $mac, $force ) = @_;
    my $logger = pf::log::get_logger;

    unless(fingerbank::Config::is_api_key_configured()) {
        $logger->debug("Skipping Fingerbank processing because no API key is configured");
        return $FALSE;
    }

    $force //= $FALSE;
    
    my $node_before = node_attributes($mac);

    my $cache = cache();
    my $cache_key = "pf::fingerbank::process($mac)";

    my $process_timestamp = $cache->compute($cache_key, sub {
        $force = $TRUE;
        return DateTime->now(time_zone => "local");
    });

    # Querying for a resultset
    my $query_args = endpoint_attributes($mac);

    unless(defined($query_args)) {
        $logger->error("Unable to fetch query arguments for Fingerbank query. Aborting.");
        return $FALSE;
    }

    $query_args->{mac} = $mac;

    if(!$force && $query_args->{last_updated}->epoch <= $process_timestamp->epoch) {
        $logger->debug("No recent data found for $mac, will not trigger device profiling. Was last updated at $query_args->{last_updated} and last process timestamp is $process_timestamp");
        return $TRUE;
    }
    else {
        $cache->set($cache_key, DateTime->now(time_zone => "local"));
        my $query_success = $TRUE;

        my $query_result = _query($query_args);

        unless(defined($query_result)) {
            $logger->warn("Unable to perform a Fingerbank lookup for device with MAC address '$mac'");
            $query_success = $FALSE;
        }

        if($query_success) {
            # Processing the device class based on it's parents
            my ( $top_level_parent, $parents ) = _parse_parents($query_result);
            $query_result->{device_class} = find_device_class($top_level_parent, $query_result->{'device'}{'name'});

            if(!defined($query_result->{device_class})) {
                $logger->error("Issue figuring out device class.");
                $query_success = $FALSE;
            }
            $query_result->{parents} = $parents;

            record_result($mac, $query_args, $query_result);
        }
    
        my $node_after = node_attributes($mac);

        check_device_class_change($node_before, $node_after);
        _trigger_new_dhcp_security_event($mac);
        return $query_success;
    }
}

=head2 endpoint_attributes

Given a MAC address, collect all the latest known data profiling attributes.

Currently done via a call to the Fingerbank collector

=cut

sub endpoint_attributes {
    my ($mac) = @_;
    my $timer = pf::StatsD::Timer->new({level => 7});

    return undef unless(valid_mac($mac));

    $collector //= fingerbank::Collector->new_from_config;
    $collector_ua //= $collector->get_lwp_client();
    
    my $req = cache()->compute("pf::fingerbank::endpoint_attributes::request::$mac", sub {
        $collector->build_request("GET", "/endpoint_data/$mac");
    });

    my $res = $collector_ua->request($req);
    if ($res->is_success) {
        my $data = decode_json($res->decoded_content);
        # Change the last_updated into a DateTime
        my $f = DateTime::Format::RFC3339->new();
        $data->{last_updated} = $f->parse_datetime( $data->{last_updated} );
        return $data;
    }
    else {
        get_logger->error("Error while communicating with the Fingerbank collector. ".$res->status_line);
        return undef;
    }
}

=head2 record_result

Given a MAC address, the endpoint attributes (from the collector) and the Fingerbank result, record the necessary attributes in the database.

=cut

sub record_result {
    my ($mac, $attributes, $query_result) = @_;
    my $timer = pf::StatsD::Timer->new({level => 7});
    my %set = (
        'device_type'   => $query_result->{'device'}{'name'},
        'device_class'  => $query_result->{device_class},
        'device_version' => $query_result->{'version'},
        'device_score' => $query_result->{'score'},
        'device_manufacturer' => $query_result->{'manufacturer'}->{'name'} // "",
        map { $RECORD_RESULT_ATTR_MAP{$_} => $attributes->{$_} } grep { my $v = $attributes->{$_}; defined $v && length($v) } keys(%RECORD_RESULT_ATTR_MAP),
    );
    pf::dal::node->update_items(
        -set => \%set,
        -where => {
            mac => $mac,
        },
    );
}

=head2 update_collector_endpoint_data

Updates the endpoint data in the collector for a specific MAC address

=cut

sub update_collector_endpoint_data {
    my ($mac, $data) = @_;
    my $timer = pf::StatsD::Timer->new({level => 7});

    return undef unless(valid_mac($mac));

    $collector //= fingerbank::Collector->new_from_config;
    $collector_ua //= $collector->get_lwp_client();
    
    my $req = cache()->compute("pf::fingerbank::update_collector_endpoint_data::request::$mac", sub {
        $collector->build_request("PATCH", "/endpoint_data/$mac");
    });
    $req->content(encode_json($data));

    my $res = $collector_ua->request($req);
    if ($res->is_success) {
        return decode_json($res->decoded_content);
    }
    else {
        get_logger->error("Error while communicating with the Fingerbank collector. ".$res->status_line);
        return undef;
    }
}

=head2 _query

=cut

sub _query {
    my $timer = pf::StatsD::Timer->new({level => 7});
    my ( $args ) = @_;
    my $logger = pf::log::get_logger;

    my $fingerbank = fingerbank::Query->new(cache => cache());
    return $fingerbank->match($args);
}

=head2 _trigger_security_events

=cut

sub _trigger_new_dhcp_security_event {
    my $timer = pf::StatsD::Timer->new({level => 7});
    my ( $mac ) = @_;
    my $logger = pf::log::get_logger;

    my $apiclient = pf::client::getClient;

    my $security_event_data = {
        'mac'   => $mac,
        'tid'   => 'new_dhcp_info',
        'type'  => 'internal',
    };

    $apiclient->notify('trigger_security_event', %$security_event_data);

    my $cache = pf::CHI->new( namespace => 'trigger_security_event' );

    $security_event_data = $cache->get($mac);

    if ($security_event_data) {
        $apiclient->notify('trigger_security_event', %$security_event_data);
        $cache->remove($mac);
    }
}

=head2 _parse_parents

Parsing the parents into an array of IDs to be able to trigger security_events based on them.

Also, looking at the top-level parent to determine the device class

=cut

sub _parse_parents {
    my ( $args ) = @_;
    my $timer = pf::StatsD::Timer->new({level => 7});
    my $logger = pf::log::get_logger;

    my $class;
    my @parents = ();

    # It is possible that a device doesn't have any parent. We need to handle that case first
    if ( !defined($args->{'device'}{'parents'}) || !@{ $args->{'device'}{'parents'} } ) {
        $class = $args->{'device'}{'name'};
        $logger->debug("Device doesn't have any parent. We use the device name '$class' as class.");
        return ( $class, \@parents );
    }

    foreach my $parent ( @{ $args->{'device'}{'parents'} } ) {
        push @parents, $parent->{'id'};
        next if $parent->{'parent_id'};
        $class = $parent->{'name'};
        $logger->debug("Device does have parent(s). Returning top-level parent name '$class' as class");
    }

    return ( $class, \@parents );
}

=head2 find_device_class

Given a device, find its device class

If the device is one of %fingerbank::Constant::DEVICE_CLASS_IDS, then that will be the device class.

Otherwise, the top level parent will be the device class

=cut

sub find_device_class {
    my ($top_level_parent, $device_name) = @_;
    my $timer = pf::StatsD::Timer->new({level => 7});

    my $logger = get_logger;
    my $result = cache()->compute("pf::fingerbank::find_device_class($top_level_parent,$device_name)", sub {
        my $timer = pf::StatsD::Timer->new({level => 7, stat => "pf::fingerbank::find_device_class::cache-compute"});
        foreach my $k (@fingerbank::Constant::DEVICE_CLASS_LOOKUP_ORDER) {
            my $other_device_id = $fingerbank::Constant::DEVICE_CLASS_IDS{$k};
            $logger->debug("Checking if device $device_name is a $other_device_id");
            my $is_a = fingerbank::Model::Device->is_a($device_name, $other_device_id);
            if(!defined($is_a)) {
                $logger->error("Didn't get a valid result when checking if $device_name is a $other_device_id");
                return undef;
            }
            elsif($is_a) {
                my $other_device_name = fingerbank::Model::Device->read($other_device_id)->name; 
                $logger->info("Device $device_name is a $other_device_name");
                return $other_device_name;
            }
        }
        $logger->debug("Device $device_name is not part of any special OS class, taking top level parent $top_level_parent");
        return $top_level_parent;
    });
    return $result;
}

sub sync_configuration {
    pf::cluster::sync_files([$fingerbank::FilePath::CONF_FILE]);
}

sub sync_local_db {
    pf::cluster::sync_files([$fingerbank::FilePath::LOCAL_DB_FILE]);
    clear_cache();
}

sub sync_upstream_db {
    pf::cluster::sync_files([$fingerbank::FilePath::UPSTREAM_DB_FILE], async => $TRUE);
    clear_cache();
}

sub sync_nba_conf {
    pf::cluster::sync_files([$network_behavior_policy_config_file]);
}

sub clear_cache {
    pf::cluster::notify_each_server('chi_cache_clear', 'fingerbank');
}

=head2 mac_vendor_from_mac

=cut

sub mac_vendor_from_mac {
    my $timer = pf::StatsD::Timer->new({level => 8});
    my ($mac) = @_;
    my $mac_oui = $mac;
    $mac_oui =~ s/[:|\s|-]//g;          # Removing separators
    $mac_oui = lc($mac_oui);            # Lowercasing
    $mac_oui = substr($mac_oui, 0, 6);  # Only keep first 6 characters (OUI)
    my $trigger_query;
    $trigger_query->{'mac'} = $mac_oui;
    my ( $status, $result ) = "fingerbank::Model::MAC_Vendor"->find([$trigger_query, { columns => ['id'] }]);
    return undef if is_error($status);

    ( $status, $result ) = "fingerbank::Model::MAC_Vendor"->read($result->id);
    return $result;
}

=head2 _update_fingerbank_component

Update a Fingerbank component and validate that it succeeds

=cut

sub _update_fingerbank_component {
    my ($name, $sub) = @_;
    my $logger = get_logger;

    my ($status, $status_msg) = $sub->();

    if(fingerbank::Util::is_success($status)){
        $logger->info("Successfully updated $name");
    }
    else {
        my $msg = "Couldn't update $name, code : $status";
        $msg .= ", msg : $status_msg" if(defined($status_msg));
        $logger->error($msg);
    }
    return ($status, $status_msg);
}

sub cache {
    return pf::CHI->new( namespace => 'fingerbank' );
}

=head2 device_name_to_device_id

Find the device ID given its name
Also makes use of the cache

=cut

sub device_name_to_device_id {
    my ($device_name) = @_;
    my $timer = pf::StatsD::Timer->new({level => 7});

    my $id = cache()->compute_with_undef("device_name_to_device_id-$device_name", sub {
        my $timer = pf::StatsD::Timer->new({level => 7, stat => "pf::fingerbank::device_name_to_device_id::cache-compute"});
        my ($status, $fbdevice) = fingerbank::Model::Device->find([{name => $device_name}]);
        if(is_success($status)) {
            return $fbdevice->id;
        }
        else {
            return undef;
        }
    });
    return $id;
}

=head2 check_device_class_change

Check for a device class change and trigger any security_event if necessary

=cut

sub check_device_class_change {
    my ($node_before, $node_after) = @_;
    
    my $logger = pf::log::get_logger;

    my $allowed = device_class_transition_allowed($node_before->{device_class}, $node_before->{device_type}, $node_after->{device_class}, $node_after->{device_type});
    if(!defined($allowed)) {
        $logger->warn("Invalid result when checking for device class transition");
    }
    elsif(!$allowed) {
        $logger->info("Endpoint has changed device class, triggering internal::fingerbank_device_change");
        my $apiclient = pf::client::getClient;

        my %security_event_data = (
            'mac'   => $node_before->{mac},
            'tid'   => 'fingerbank_device_change',
            'type'  => 'internal',
            'notes' => "Previous device detected: ".$node_before->{device_class},
        );

        $apiclient->notify('trigger_security_event', %security_event_data);
    }
    else {
        my $before = $node_before->{device_class} // "Unknown";
        my $after = $node_after->{device_class} // "Unknown";
        $logger->debug("Endpoint has made a valid device class transition from $before to $after");
    }
}

=head2 device_class_transition_allowed

Checks if a device class transition is allowed according to the configuration

=cut

sub device_class_transition_allowed {
    my ($previous_device_class, $previous_device_type, $new_device_class, $new_device_type) = @_;

    my $config = $Config{fingerbank_device_change};

    my $logger = pf::log::get_logger;
    
    if(isdisabled($config->{enable})) {
        $logger->trace("Not checking Fingerbank device change because its disabled");
        return $TRUE;
    }

    # Check if going from nothing to something or the opposite
    if(!$previous_device_class || !$new_device_class) {
        $logger->info("One of the two device class is empty in the transition. Not evaluating it.");
        return $TRUE;
    }

    my $previous_device_class_id = device_name_to_device_id($previous_device_class);
    return undef unless(defined($previous_device_class_id));
    my $new_device_class_id = device_name_to_device_id($new_device_class);
    return undef unless(defined($new_device_class_id));

    if($previous_device_class_id eq $fingerbank::Constant::HARDWARE_MANUFACTURER_ID) {
        $logger->info("The device is going from Hardware Manufacturer to another device. Not evaluating it.");
        return $TRUE;
    }

    # Check for manual triggers
    foreach my $transition (@{$config->{triggers}}) {
        my $from = $transition->{from};
        my $to = $transition->{to};

        # Handle wildcard transitions
        if($from eq "*") {
            $from = $previous_device_class_id;
        }

        if($to eq "*") {
            $to = $new_device_class_id;
        }

        if($previous_device_class_id eq $from && $new_device_class_id eq $to) {
            $logger->info("Transition from $previous_device_class to $new_device_class is not allowed in configuration.");
            return $FALSE;
        }
    }
    
    # Check if device class change is enabled
    if(isdisabled($config->{trigger_on_device_class_change})) {
        $logger->trace("Not checking device class change because its disabled");
        return $TRUE;
    }

    # Check if both device classes are the same
    return $TRUE if($previous_device_class eq $new_device_class);

    ## Check if device is_a the previous device class 
    # Was disabled, shouldn't be necessary, but leaving this in place in the event we need to perform derivation analysis to reduce the amount of false positives
    # jsemaan@inverse.ca
    #my $is_a = fingerbank::Model::Device->is_a($new_device_type, $previous_device_class);
    #if(!defined($is_a)) {
    #    $logger->error("Didn't get a valid result when checking if $new_device_type is a $previous_device_class");
    #    return undef;
    #}
    #elsif($is_a) {
    #    $logger->debug("Device $new_device_type is a $previous_device_class");
    #    return $TRUE;
    #}

    ## Check if device is_a the previous device type 
    #$is_a = fingerbank::Model::Device->is_a($new_device_type, $previous_device_type);
    #if(!defined($is_a)) {
    #    $logger->error("Didn't get a valid result when checking if $new_device_type is a $previous_device_type");
    #    return undef;
    #}
    #elsif($is_a) {
    #    $logger->debug("Device $new_device_type is a $previous_device_type");
    #    return $TRUE;
    #}

    # Check if the transition is whitelisted
    foreach my $transition (@{$config->{device_class_whitelist}}) {
        my $from = $transition->{from};
        my $to = $transition->{to};

        # Handle wildcard transitions
        if($from eq "*") {
            $from = $previous_device_class_id;
        }

        if($to eq "*") {
            $to = $new_device_class_id;
        }

        if($previous_device_class_id eq $from && $new_device_class_id eq $to) {
            $logger->info("Transition from $previous_device_class to $new_device_class is allowed in configuration.");
            return $TRUE;
        }
    }

    # Check if the transition goes from a device class to another
    if($previous_device_class ne $new_device_class) {
        $logger->info("Detected device class transition from $previous_device_class to $new_device_class.");
        return $FALSE;
    }
}

sub get_hosts_ports {
    my ($mac) = @_;

    my $logger = get_logger;

    $api_client //= fingerbank::API->new_from_config;

    my $node = node_view($mac);
    my $device_type = $node->{device_type};

    unless(defined($device_type)) {
        $logger->error("Unable to compute hosts/ports for device because we don't have the Fingerbank device profiling information for it.");
        return;
    }

    my $device_id = device_name_to_device_id($device_type);

    if(!defined($device_id)) {
        $logger->error("Unable to find device ID for $mac. Unable to obtain hosts and ports it should communicate with through Fingerbank.");
        return;
    }

    my $rules = cache()->compute_with_undef("get_hosts_ports-$device_id", sub {
        return $api_client->device_outbound_communications($device_id);
    });
    
    return $rules;
}

=head2 CLONE

Clear the cache in a thread environment

=cut

sub CLONE {
    $collector_ua = undef;
    $collector = undef;
    $api_client = undef;
}

pf::AtFork->add_to_child(\&CLONE);

=head1 AUTHOR

Inverse inc. <info@inverse.ca>

=head1 COPYRIGHT

Copyright (C) 2005-2023 Inverse inc.

=head1 LICENSE

This program is free software; you can redistribute it and::or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
USA.

=cut

1;
