├── requirements.txt ├── Sample Data ├── SampleInput.csv └── SampleOutput.csv ├── LICENSE.txt ├── README.md └── GeoLogonalyzer.py /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # Python dependencies for running on GeoLogonalyzer.py 3 | # 4 | # Usage: 5 | # pip install -r requirements.txt 6 | # 7 | netaddr 8 | python-geoip-python3 9 | win_inet_pton 10 | geopy 11 | geoip2>=2.9.0 12 | importlib-metadata 13 | -------------------------------------------------------------------------------- /Sample Data/SampleInput.csv: -------------------------------------------------------------------------------- 1 | 2017-11-23 10:05:02, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 2 | 2017-11-23 11:06:03, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 3 | 2017-11-23 12:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 4 | 2017-11-23 13:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 5 | 2017-11-24 10:07:05, Meghan, 72.229.28.185, Meghan-Tablet, OpenSourceVPNClient 6 | 2017-11-24 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 7 | 2017-11-24 17:15:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 8 | 2017-11-24 17:30:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 9 | 2017-11-24 20:00:00, Meghan, 104.175.79.199, android, AndroidVPNClient 10 | 2017-11-24 21:00:00, Meghan, 104.175.79.199, android, AndroidVPNClient 11 | 2017-11-25 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 12 | 2017-11-25 17:05:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 13 | 2017-11-25 17:10:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 14 | 2017-11-25 17:11:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 15 | 2017-11-25 19:00:00, Harry, 101.0.64.1, andy-pc, OpenSourceVPNClient 16 | 2017-11-26 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 17 | 2017-11-26 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 18 | 2017-11-27 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 19 | 2017-11-27 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 20 | 2017-11-28 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 21 | 2017-11-28 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 22 | 2017-11-29 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 23 | 2017-11-29 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient -------------------------------------------------------------------------------- /Sample Data/SampleOutput.csv: -------------------------------------------------------------------------------- 1 | User,Anomalies,1st Time,1st IP,1st DCH,1st Country,1st Region,1st Coords,1st ASN #,1st ASN Name,1st VPN Client,1st Hostname,1st Streak,2nd Time,2nd IP,2nd DCH,2nd Country,2nd Region,2nd Coords,2nd ASN #,2nd ASN Name,2nd VPN Client,2nd Hostname,Miles Diff,Seconds Diff,Miles/Hour 2 | Meghan,CLIENT|HOSTNAME,2017-11-23 13:00:00,72.229.28.185, ,US,NY,"(40.7605, -73.9933)",12271,Time Warner Cable Internet LLC,CorpVPNClient,Meghan-Laptop,4,2017-11-24 10:07:05,72.229.28.185, ,US,NY,"(40.7605, -73.9933)",12271,Time Warner Cable Internet LLC,OpenSourceVPNClient,Meghan-Tablet,0.0,76025.0,0.0 3 | Meghan,DISTANCE|CLIENT|HOSTNAME,2017-11-24 10:07:05,72.229.28.185, ,US,NY,"(40.7605, -73.9933)",12271,Time Warner Cable Internet LLC,OpenSourceVPNClient,Meghan-Tablet,1,2017-11-24 20:00:00,104.175.79.199, ,US,CA,"(34.1454, -117.8514)",20001,Time Warner Cable Internet LLC,AndroidVPNClient,android,2428.24357463,35575.0,245.725280918 4 | Harry,DISTANCE|DCH|HOSTNAME|FAST|CLIENT|ASN,2017-11-25 17:11:00,97.105.140.66, ,US,TX,"(32.8339, -96.7715)",11427,Time Warner Cable Internet LLC,CorpVPNClient,Harry-Laptop,7,2017-11-25 19:00:00,101.0.64.1,Digital Pacific,AU,VIC,"(-37.9833, 145.2)",55803,Digital Pacific Pty Ltd Australia,OpenSourceVPNClient,andy-pc,8990.82294534,6540.0,4949.07685065 5 | Meghan,DISTANCE|CLIENT|HOSTNAME,2017-11-24 21:00:00,104.175.79.199, ,US,CA,"(34.1454, -117.8514)",20001,Time Warner Cable Internet LLC,AndroidVPNClient,android,2,2017-11-26 10:00:00,72.229.28.185, ,US,NY,"(40.7605, -73.9933)",12271,Time Warner Cable Internet LLC,CorpVPNClient,Meghan-Laptop,2428.24357463,133200.0,65.6282047197 6 | Harry,DISTANCE|CLIENT|ASN|DCH|HOSTNAME,2017-11-25 19:00:00,101.0.64.1,Digital Pacific,AU,VIC,"(-37.9833, 145.2)",55803,Digital Pacific Pty Ltd Australia,OpenSourceVPNClient,andy-pc,1,2017-11-26 17:00:00,97.105.140.66, ,US,TX,"(32.8339, -96.7715)",11427,Time Warner Cable Internet LLC,CorpVPNClient,Harry-Laptop,8990.82294534,79200.0,408.673770243 7 | Meghan, ,2017-11-29 10:00:00,72.229.28.185, ,US,NY,"(40.7605, -73.9933)",12271,Time Warner Cable Internet LLC,CorpVPNClient,Meghan-Laptop,4,,,,,,,,,,,,, 8 | Harry, ,2017-11-29 17:00:00,97.105.140.66, ,US,TX,"(32.8339, -96.7715)",11427,Time Warner Cable Internet LLC,CorpVPNClient,Harry-Laptop,4,,,,,,,,,,,,, 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright (C) 2023 Mandiant, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _ 2 | | \ 3 | ,---------------------------------, _/ > 4 | | 1 \____ __/ / 5 | | \ \ _/ \ 6 | | \ 3 '-, | ,-' 7 | ______ | \_ / \ \_/ / 8 | / ____/_| ____ / / ____ ___/ _\__ ____ ____ / /_ ____|_ ___ _____ 9 | / / __/ _ \/ __ \/ / \ / __ \/ __ \/ __ \/ __ \/ __ \/ / / / /_ / / _ \/ ___/ 10 | / /_/ / __/ /_/ / /___/ /_/ / /_/ / /_/ / / / / /_/ / / /_/ / / /_/ __/ / 11 | \____/\___/\____/_____/\____/\__, /\____/_/ /_/\__,_/_/\__, / /___/\___/_/ 12 | \ \ /____/ \ /____/ / 13 | |_ \ / \ / 14 | \ 2 \ / 15 | ----. \ / 16 | '-,_ 4 \ 17 | `-----, ,-------, \ 18 | \,~. ,---^---' | \ 19 | \ / \ | 20 | \ | \_| 21 | `-' 22 | 23 | GeoLogonalyzer is a utility to perform location and metadata lookups on source IP addresses of 24 | remote access logs. This analysis can identify anomalies based on speed of required travel, 25 | distance, hostname changes, ASN changes, VPN client changes, etc. 26 | 27 | GeoLogonalyzer extracts and processes changes in logon characteristics to reduce analysis requirements. 28 | For example, if a user logs on 500 times from 1.1.1.1 and then 1 time from 2.2.2.2, GeoLogonalyzer 29 | will create one line of output that shows information related to the change such as: 30 | * Detected anomalies 31 | * Data Center Hosting information identified 32 | * Location information 33 | * ASN information 34 | * Time and distance metrics 35 | ---- 36 | # Preparation 37 | 38 | ### MaxMind Databases 39 | 1. Make a free account for MaxMind GeoLite at https://www.maxmind.com/en/geolite2/signup 40 | 2. Download the 'GeoLite2 City - MaxMind DB binary' from https://www.maxmind.com/en/accounts/current/geoip/downloads 41 | 3. Be sure to download and 42 | 4. Extract the MMDB files from the tar.gz files. 43 | 5. Place them in the same folder as GeoLogonalyzer.py 44 | 45 | ### Python 46 | If you need to use the python source code (such as for modifiying configurations, adding custom 47 | log parsing, or running on *nix/OSX), you will need to install the following dependencies which 48 | you may not already have: 49 | 50 | netaddr 51 | python-geoip-python3 52 | win_inet_pton 53 | geopy 54 | geoip2>=2.9.0 55 | importlib-metadata 56 | 57 | A pip requirements.txt is provided for your convenience. 58 | 59 | pip install -r requirements.txt 60 | 61 | ##### Constants Configuration 62 | The following constants can be modified when running the Python source code to suite your analysis needs: 63 | 64 | | Constant Name | Default Value | Description | 65 | |---------------|---------------|-------------| 66 | | RESERVED_IP_COORDINATES | (0, 0) | Default Lat\Long coordinates for IP addresses identified as reserved | 67 | | FAR_DISTANCE | 500 | Threshold in miles for determining if two logons are "far" away from eachother | 68 | | FAST_MPH | 500 | Threshold in miles per hour for determining if two logons are "geoinfeasible" based on distance and time | 69 | 70 | 71 | ### Input 72 | ##### CSV (Default) 73 | By default, Geologonalyzer supports **_time sorted_** remote access logs in the following CSV format: 74 | 75 | YYYY-MM-DD HH:MM:SS,user,10.10.10.10,hostname(optional),VPN client (optional) 76 | 77 | Example CSV input.csv file (created entirely for demonstration purposes): 78 | 79 | 2017-11-23 10:05:02, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 80 | 2017-11-23 11:06:03, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 81 | 2017-11-23 12:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 82 | 2017-11-23 13:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 83 | 2017-11-24 10:07:05, Meghan, 72.229.28.185, Meghan-Tablet, OpenSourceVPNClient 84 | 2017-11-24 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 85 | 2017-11-24 17:15:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 86 | 2017-11-24 17:30:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 87 | 2017-11-24 20:00:00, Meghan, 104.175.79.199, android, AndroidVPNClient 88 | 2017-11-24 21:00:00, Meghan, 104.175.79.199, android, AndroidVPNClient 89 | 2017-11-25 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 90 | 2017-11-25 17:05:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 91 | 2017-11-25 17:10:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 92 | 2017-11-25 17:11:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 93 | 2017-11-25 19:00:00, Harry, 101.0.64.1, andy-pc, OpenSourceVPNClient 94 | 2017-11-26 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 95 | 2017-11-26 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 96 | 2017-11-27 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 97 | 2017-11-27 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 98 | 2017-11-28 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 99 | 2017-11-28 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 100 | 2017-11-29 10:00:00, Meghan, 72.229.28.185, Meghan-Laptop, CorpVPNClient 101 | 2017-11-29 17:00:00, Harry, 97.105.140.66, Harry-Laptop, CorpVPNClient 102 | 103 | ##### Custom Log Formats 104 | If you have a log format that is difficult to convert to CSV, GeoLogonalyzer supports 105 | custom log format parsing through modification of the "get_custom_details" function. 106 | 107 | For this function, input will be a line of text, and output must contain: 108 | 109 | return time, ip_string, user, hostname, client 110 | 111 | Here is a Juniper PulseSecure log format and the sample code to extract required fields: 112 | 113 | # Example Juniper Firewall Input line (wrapped on new lines): 114 | # Mar 12 10:59:33 FW_JUNIPER PulseSecure: id=firewall time="2018-03-12 10:59:33" pri=6 115 | # fw= vpn= user=System realm="" roles="" type=mgmt proto= src= dst= 116 | 117 | # Example function to fill in for "get_custom_details(line): 118 | # Create regex match object to find data 119 | juniper_2_ip_user_mo = re.compile("(time=\")([\d\ \-\:]{19})(\" .*)( user\=)(.*?)" 120 | "( realm.*? src=)(.*?)( )") 121 | 122 | # Match the regex 123 | ip_user_match = re.search(juniper_2_ip_user_mo, line) 124 | 125 | # Extract timestamp and convert to datetime object from "2017-03-30 00:22:42" format 126 | time = datetime.strptime(ip_user_match.group(2).strip(), '%Y-%m-%d %H:%M:%S') 127 | 128 | # Extract username and source IP (not the 129 | user = ip_user_match.group(5).strip() 130 | ip_string = ip_user_match.group(7).strip() 131 | 132 | # Set empty hostname and client since they were not included in input 133 | hostname = "" 134 | client = "" 135 | 136 | return time, ip_string, user, hostname, client 137 | ------ 138 | # Execution Syntax 139 | The following command will parse the input.csv shown above and save results to output.csv: 140 | 141 | GeoLogonalyzer --csv input.csv --output output.csv 142 | ------ 143 | # Output 144 | The output.csv file will include the following column headers: 145 | 146 | | Column Header | Description | 147 | |-------------|-----------| 148 | | User | Username of logons compared | 149 | | Anomalies | Flags for anomalies detailed in "Automatic Anomaly Detection" section below | 150 | | 1st Time | Time of 1st compared logon | 151 | | 1st IP | IP Address of 1st compared logon | 152 | | 1st DCH | Datacenter hosting information of 1st compared logon | 153 | | 1st Country | Country associated with IP address of 1st compared logon | 154 | | 1st Region | Region associated with IP address of 1st compared logon | 155 | | 1st Coords | Lat/Long coordinates associated with IP address of 1st compared logon | 156 | | 1st ASN # | ASN number associated with IP address of 1st compared logon | 157 | | 1st ASN Name | ASN name associated with IP address of 1st compared logon | 158 | | 1st VPN Client | VPN client name associated with 1st compared logon | 159 | | 1st Hostname | Hostname associated with 1st compared logon | 160 | | 1st Streak | Count of logons by user from 1st compared source IP address before change | 161 | | 2nd Time | Time of 2nd compared logon | 162 | | 2nd IP | IP Address of 2nd compared logon | 163 | | 2nd DCH | Datacenter hosting information of 2nd compared logon | 164 | | 2nd Country | Country associated with IP address of 2nd compared logon | 165 | | 2nd Region | Region associated with IP address of 2nd compared logon | 166 | | 2nd Coords | Lat/Long coordinates associated with IP address of 2nd compared logon | 167 | | 2nd ASN # | ASN number associated with IP address of 2nd compared logon | 168 | | 2nd ASN Name | ASN name associated with IP address of 2nd compared logon | 169 | | 2nd VPN Client | VPN client name associated with 2nd compared logon | 170 | | 2nd Hostname | Hostname associated with 2nd compared logon | 171 | | Miles Diff | Difference in miles between two associated coordinates of two compared IP addresses | 172 | | Seconds Diff | Difference in time between two compared authentications | 173 | | Miles/Hour | Speed required to physically move from 1st logon location to 2nd logon location by time difference between compared logons. Miles Diff / Seconds Diff | 174 | 175 | 176 | ------- 177 | # Analysis Tips 178 | 1. Unless otherwise configured (as described above), RFC1918 and other reserved IP addresses are assigned a geolocation of (0,0) which is located in the Atlantic Ocean near Africa which will skew results. 179 | a. Use the --skip_rfc1918 command line parameter to completely skip any reserved source IP address such as RFC1918. This is useful to reduce false positives if your data includes connections from internal networks such as 10.10.10.10 or 192.168.1.100. 180 | 181 | 2. Use the Automatic Anomaly Detection flags listed below to quickly identify anomalies. Examples include changes in logons that: 182 | a. require require an infeasible rate of travel (FAST) 183 | b. involve a large change in distance (DISTANCE) 184 | c. involvce a source IP address registered to a datacenter hosting provider such as Digital Ocean or AWS (DCH) 185 | d. changes in ASN (ASN), VPN client name (CLIENT), or source system hostname (HOSTNAME) 186 | 187 | 3. Look for IP addresses registered to unexpected countries. 188 | 4. Analyze the "Streak" count to develop a pattern of logon behavior from a source IP address before a change occurs. 189 | 5. Analyze all hostnames to ensure they match standard naming conventions. 190 | 6. Analyze all software client names to identify unapproved software. 191 | 192 | ### Automatic Anomaly Detection 193 | GeoLogonalyzer will try to automatically flag on the following anomalies: 194 | 195 | | Flag | Description | 196 | |------|-------------| 197 | | DISTANCE | This flag indicates the distance between the two compared source IP addresses exceeded the configured FAR_DISTANCE constant. This is 500 miles by default. | 198 | | FAST | This flag indicates the speed required to travel between the two compared source IP addresses in the time between the two compared authentications exceeded the configured IMPOSSIBLE_MPH constant. This is 500 MPH by default. Estimate source: https://www.flightdeckfriend.com/how-fast-do-commercial-aeroplanes-fly | 199 | | DCH | This flag indicates that one of the compared IP Addresses is registered to a datacenter hosting provider. | 200 | | ASN | This flag indicates the ASN of the two compared source IP addresses was *_not_* identical. Filtering out source IP address changes *_that do not have this flag*_ may cut down on legitimate logons from nearby locations to review. | 201 | | CLIENT | If VPN client information is processed by GeoLogonalyzer, this flag indicates a change in VPN client name between the two compared authentications. This can help identify use of unapproved VPN client software. | 202 | | HOSTNAME | If hostname information is processed by GeoLogonalyzer, this flag indicates a change in hostname between the two compared authentications. This can help identify use of unapproved systems connecting to your remote access solution. | 203 | 204 | ------ 205 | # Alternate Usage 206 | GeoLogonalyzer can be used to provide metadata lookups on a text file that lists IP addresses one per line. Example ip-input.txt file (created entirely for demonstration purposes): 207 | 208 | 1.3.5.7 209 | 10.39.4.5 210 | 127.9.4.5 211 | 34.78.32.14 212 | 192.4.4.3 213 | asdffasdf 214 | 2.4.5.0 215 | 216 | Example execution syntax: 217 | 218 | GeoLogonalyzer --ip_only ip-input.txt --output ip-output.csv 219 | 220 | Example ip-output.csv: 221 | 222 | ip,location,country,subdivisions,dch_company,asn_number,asn_name 223 | 1.3.5.7,"(23.1167, 113.25)",CN,GD, , , 224 | 10.39.4.5,"(0, 0)",PRIVATE,PRIVATE,,, 225 | 127.9.4.5,"(0, 0)",RESERVED,RESERVED,,, 226 | 34.78.32.14,"(29.9668, -95.3454)",US,TX, , , 227 | 192.4.4.3,"(40.6761, -74.573)",US,NJ, ,54735,"TT Government Solutions, Inc." 228 | asdffasdf,"(0, 0)",INVALID,INVALID,,, 229 | 2.4.5.0,"(43.6109, 3.8772)",FR,"OCC, 34", ,3215,Orange 230 | 231 | ----- 232 | # Licenses 233 | ### GeoLogonalyzer License: 234 | https://github.com/mandiant/GeoLogonalyzer/blob/master/LICENSE.txt 235 | 236 | ### MaxMind Attribution and Credit 237 | 238 | This product includes GeoLite2 data created by MaxMind, available from 239 | http://www.maxmind.com provided under the Creative Commons Attribution- 240 | ShareAlike 4.0 International License. Copyright (C) 2012-2018 Maxmind, Inc. 241 | Copyright (C) 2012-2018 Maxmind, Inc. 242 | 243 | ### client9 Attribution and Credit 244 | 245 | This product retrieves and operates on data including datacenter 246 | categorizations retrieved from https://github.com/client9/ipcat/ which 247 | are Copyright 2018 Client9. This data comes with ABSOLUTELY NO 248 | WARRANTY; for details go to: 249 | https://raw.githubusercontent.com/client9/ipcat/master/LICENSE 250 | The data is free software, and you are welcome to redistribute it under 251 | certain conditions. See LICENSE for details. 252 | 253 | # Limitations 254 | 1. All GeoIP lookups are dependent on the accuracy of MaxMind database values 255 | 2. All DCH lookups are dependent on the accuracy of open source data 256 | 3. VPN or network tunneling services may skew results 257 | 258 | # Credits 259 | GeoLogonalyzer was created by David Pany. The project was inspired by research performed by FireEye's data science team including Christopher Schmitt, Seth Summersett, Jeff Johns, Alexander Mulfinger, and more whose work supports live remote access processing in FireEye Helix - https://www.fireeye.com/solutions/helix.html. The "Logonalyzer" name was originally created by @0xF2EDCA5A. 260 | 261 | # Contact 262 | Please contact david.pany@mandiant.com or @davidpany on Twitter for bugs, comments, or suggestions. 263 | -------------------------------------------------------------------------------- /GeoLogonalyzer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | #GeoLogonalyzer.py 4 | #Version 1.11 5 | # Geofeasibility calculator and datacenter cross reference utility 6 | # customizable for various VPN log formats. 7 | # 8 | #Changes: 9 | # 1.02 - Added check for minimum geoip2 dependency version 10 | # 1.10 11 | # - Runs in python3 - Thanks to Colby Lahaie! 12 | # - Clarifies instructions for downloading GeoLite DBs with a free account 13 | # 1.11 - Minor updates 14 | # 1.12 - Updated DCH CSV to more updated source 15 | # 16 | #Description: 17 | # GeoLogonalyzer will perform location and metadata lookups on source IP 18 | # addresses and identify anomalies based on speed of required travel, 19 | # distance, hostname changes, etc. 20 | # 21 | #Usage: 22 | # python GeoLogonalyzer.py --csv input_file --output output_file.csv 23 | # 24 | #Output: 25 | # CSV data with the following fields: 26 | # "User", 27 | # "Anomalies", 28 | # "1st Time", 29 | # "1st IP", 30 | # "1st DCH", 31 | # "1st Country", 32 | # "1st Region", 33 | # "1st Coords", 34 | # "1st ASN #", 35 | # "1st ASN Name", 36 | # "1st VPN Client", 37 | # "1st Hostname", 38 | # "1st Streak", 39 | # "2nd Time", 40 | # "2nd IP", 41 | # "2nd DCH", 42 | # "2nd Country", 43 | # "2nd Region", 44 | # "2nd Coords", 45 | # "2nd ASN #", 46 | # "2nd ASN Name", 47 | # "2nd VPN Client", 48 | # "2nd Hostname", 49 | # "Miles Diff", 50 | # "Seconds Diff", 51 | # "Miles/Hour" 52 | # 53 | #Analysis Examples: 54 | # 55 | # Note that the "anomalies" output column attempts to identify suspicious activity detailed below. 56 | # 57 | # 1. Investigate logons that require travel of infeasible miles per hour based on IP geolocation. 58 | # NOTE: RFC1918 IPs are assigned a lat/long of (0,0) which is in the Atlantic Ocean near Africa 59 | # and will skew results. 60 | # 61 | # 2. Investigate large location_miles_diff values to find logons from distant locations even if 62 | # MPH is low. 63 | # NOTE: RFC1918 IPs are assigned a lat/long of (0,0) which is in the Atlantic Ocean near Africa 64 | # and will skew results. 65 | # 66 | # 3. Look for logons from IPs registered to unexpected countries. 67 | # 68 | # 4. Analyze source IP ASN names for any unexpected ISP information. 69 | # 70 | # 5. Analyze logons from IPs registered to DCH (DataCenter Hosted) providers. 71 | # 72 | # 6. Analyze "Streak" count to determine how many times the user had logged on from FIRST_IP 73 | # before switching to SECOND_IP. 74 | # 75 | # 7. Analyze all and detected changes of source hostnames for unexpected naming conventions 76 | # 77 | # 8. Analyze all and detected changes of VPN clients for unauthorized software 78 | # 79 | #Configuration: 80 | # 1. By default, all RFC1918 IP addresses default to the geo coordinates (0, 0) which is in the 81 | # Atlantic Ocean near Africa. 82 | # Please edit the RESERVED_IP_COORDINATES constant with your organization's actual coordinates 83 | # for more accurate results. 84 | # 85 | # 1a. If you wish to totally skip RFC1918 IP addresses, please use the --skip_rfc1918 parameter 86 | # 87 | # 2. Please see example in the get_custom_details function comments to configure custom log 88 | # parsing. 89 | # 90 | #Limitations: 91 | # 1. All geoip lookups are dependent on accuracy of MaxMind database values 92 | # 2. All CDN lookups are dependent on accuracy of open source data 93 | # 3. VPN or network tunneling services may skew results 94 | # 95 | #Created by David Pany while at Mandiant (FireEye) - 2018 96 | #Email: david.pany@fireeye.com 97 | #Twitter: @davidpany 98 | # 99 | # License: 100 | # 101 | # https://raw.githubusercontent.com/mandiant/GeoLogonalyzer/master/LICENSE.txt 102 | # 103 | # 3rd party code attribution: 104 | # This product retrieves and operates on data including datacenter categorizations retrieved from 105 | # categorizations retrieved from https://github.com/growlfm/ipcat/ which is a version from 106 | # https://github.com/client9/ipcat/ 107 | # 108 | # Licenses: 109 | # https://raw.githubusercontent.com/client9/ipcat/master/LICENSE 110 | # https://raw.githubusercontent.com/growlfm/ipcat/main/LICENSE 111 | # 112 | # This product includes GeoLite2 data created by MaxMind, available from http://www.maxmind.com 113 | # provided under the Creative Commons Attribution-ShareAlike 4.0 International License. 114 | # Copyright (C) 2012-2018 Maxmind, Inc. 115 | # 116 | # Mad gr33tz to @0xF2EDCA5A for the "Logonalyzer" name inspiration. 117 | # 118 | 119 | from __future__ import print_function 120 | import sys 121 | import re # Might be used for custom line parsing 122 | import argparse 123 | from datetime import datetime 124 | from urllib.request import urlopen 125 | import tarfile 126 | import shutil 127 | import os 128 | import csv 129 | import time 130 | import unicodedata 131 | import importlib.metadata 132 | 133 | # Imports that are not likely to be installed by default: 134 | try: 135 | from netaddr import iprange_to_cidrs, IPAddress 136 | from netaddr.core import AddrFormatError 137 | except ImportError: 138 | sys.stderr.write("Please install the netaddr dependency:\n\tpip install netaddr\n") 139 | sys.exit() 140 | 141 | try: 142 | from geoip import open_database 143 | except ImportError: 144 | sys.stderr.write("Please install the geoip dependency:\n\tpip install python-geoip-python3\n") 145 | sys.exit() 146 | 147 | try: 148 | # While not used by GeoLogonalyzer, this import is used by geoip_db.lookup(ip) 149 | import win_inet_pton 150 | except ImportError: 151 | sys.stderr.write("Please install the win_inet_pton dependency:\n\tpip install win_inet_pton\n") 152 | sys.exit() 153 | 154 | try: 155 | from geopy.distance import geodesic 156 | except ImportError: 157 | sys.stderr.write("Please install the geopy dependency:\n\tpip install geopy\n") 158 | sys.exit() 159 | 160 | try: 161 | import geoip2.database 162 | import geoip2.errors 163 | try: #ensure that geopip2 is 2.9.0 or greater. Older versions cause issues. 164 | #assert pkg_resources.get_distribution("geoip2").version >= '2.9.0' 165 | assert importlib.metadata.version("geoip2") >= '2.9.0' 166 | except AssertionError: 167 | sys.stderr.write("Please upgrade the geoip dependency:\n\tpip install geoip2>=2.9.0\n") 168 | sys.exit() 169 | except ImportError: 170 | sys.stderr.write("Please install the geoip dependency:\n\tpip install geoip2>=2.9.0\n") 171 | sys.exit() 172 | 173 | # Constants 174 | RESERVED_IP_COORDINATES = (0, 0) 175 | FAR_DISTANCE = 500 176 | SECONDS_PER_HOUR = 3600 177 | FAST_MPH = 500 178 | IMPOSSIBLE_MPH = 99999999 179 | 180 | def create_geoip_db(pattern=r"GeoLite2-City_\d{8}\.tar\.gz"): 181 | """Open GeoIP DB if available, download if needed""" 182 | try: 183 | # Try to open an existing GeoIP DB 184 | geoip_db = open_database('GeoLite2-City.mmdb') 185 | print("GeoLite2 City Found. Success.") 186 | return geoip_db 187 | 188 | # Handling if file not found 189 | except IOError: 190 | file_found = False 191 | file_name = "" 192 | 193 | # Look through every file in working directory to identify if the tar.gz exists 194 | for filename in os.listdir(): 195 | if re.match(pattern, filename): 196 | # Extract the mmdb from the tar.gz if found 197 | sys.stderr.write("\nExtracting GeoLite2 City Database.\n") 198 | with tarfile.open(filename, "r:gz") as tar: 199 | tar_directory = tar.getnames()[0] 200 | tar.extractall() 201 | 202 | # Clean up unnecessary files 203 | sys.stderr.write("Cleaning up GeoLite2 City Archive.\n") 204 | shutil.move("{}/GeoLite2-City.mmdb".format(tar_directory), "GeoLite2-City.mmdb") 205 | shutil.rmtree(tar_directory) 206 | 207 | os.remove(filename) 208 | 209 | # Open and return GeoIP DB 210 | geoip_db = open_database('GeoLite2-City.mmdb') 211 | print("GeoLite2 City extracted. Success.") 212 | return geoip_db 213 | 214 | # Provide instructions for manually downloading the GeoIP DB if we fail 215 | sys.stderr.write("\nCouldn't find the GeoLite2 City DB. Please do the following:\n") 216 | sys.stderr.write("\t1. Download the 'GeoLite2 City - MaxMind DB binary' from " 217 | "https://www.maxmind.com/en/accounts/current/geoip/downloads\n") 218 | sys.stderr.write("\t\t1a. NOTE: MAXMIND NOW REQUIRES YOU TO CREATE A FREE ACCOUNT TO DOWNLOAD GEOLITE2 GEOLOCATION DATA\n") 219 | sys.stderr.write("\t2. Make sure the downloaded archive file is named '' and placed " 220 | "in the 'GeoLogonalyzer.py' working directory\n") 221 | sys.stderr.write("\t3. Extract the contents of '' and make sure the DB file is named " 222 | "'GeoLite2-City.mmdb'\n") 223 | sys.stderr.write("\t4. Place 'GeoLite2-City.mmdb' in the 'GeoLogonalyzer.py' working " 224 | "directory\n") 225 | sys.stderr.write("\t5. Rerun the script\n") 226 | sys.exit() 227 | 228 | def create_asn_db(pattern=r"GeoLite2-ASN_\d{8}\.tar\.gz"): 229 | """Open ASN DB if available, download if needed""" 230 | try: 231 | # Try to open an existing ASN DB 232 | asn_db_reader = geoip2.database.Reader("GeoLite2-ASN.mmdb") 233 | print("GeoLite2 ASN Found. Success.") 234 | return asn_db_reader 235 | 236 | except IOError: 237 | file_found = False 238 | file_name = "" 239 | 240 | for filename in os.listdir(): 241 | if re.match(pattern, filename): 242 | 243 | sys.stderr.write("Extracting GeoLite2 ASN Database.\n") 244 | with tarfile.open(filename, "r:gz") as tar: 245 | tar_directory = tar.getnames()[0] 246 | tar.extractall() 247 | 248 | # Clean up unnecessary files 249 | sys.stderr.write("Cleaning up GeoLite2 ASN Archive.\n") 250 | shutil.move("{}/GeoLite2-ASN.mmdb".format(tar_directory), "GeoLite2-ASN.mmdb") 251 | shutil.rmtree(tar_directory) 252 | 253 | os.remove(filename) 254 | 255 | # Open and return ASN DB 256 | asn_db_reader = geoip2.database.Reader("GeoLite2-ASN.mmdb") 257 | print("GeoLite2 ASN extracted. Success.") 258 | return asn_db_reader 259 | 260 | # Provide instructions for manually downloading the ASN DB if we fail 261 | sys.stderr.write("\nCouldn't find the GeoLite2 ASN DB. Please do the following:\n") 262 | sys.stderr.write("\t1. Download the 'GeoLite2 ASN - MaxMind DB binary' from " 263 | "https://www.maxmind.com/en/accounts/current/geoip/downloads\n") 264 | sys.stderr.write("\t\t1a. NOTE: MAXMIND NOW REQUIRES YOU TO CREATE A FREE ACCOUNT TO DOWNLOAD GEOLITE2 GEOLOCATION DATA\n") 265 | sys.stderr.write("\t2. Make sure the downloaded archive file is named '' and placed " 266 | "in the 'GeoLogonalyzer.py' working directory\n") 267 | sys.stderr.write("\t3. Extract the contents of '' and make sure the DB file is named " 268 | "'GeoLite2-ASN.mmdb'\n") 269 | sys.stderr.write("\t4. Place 'GeoLite2-ASN.mmdb' in the 'GeoLogonalyzer.py' working " 270 | "directory\n") 271 | sys.stderr.write("\t5. Rerun the script\n") 272 | sys.exit() 273 | 274 | def create_dch_dict(): 275 | """Download datacenter CSV and create dictionary of cidr ranges""" 276 | 277 | sys.stderr.write("\nDownloading DCH (data center hosting) data from " 278 | "https://raw.githubusercontent.com/growlfm/ipcat/main/datacenters.csv\n") 279 | dch_response = urlopen('https://raw.githubusercontent.com/growlfm/ipcat/main/datacenters.csv') 280 | 281 | dch_file = dch_response.read() 282 | dch_dict = {} 283 | dch_list = dch_file.decode('utf-8').split("\n") 284 | 285 | # Read downloaded DCH CSV and parse into dch_list 286 | sys.stderr.write("Creating DCH Database.\n") 287 | for line in dch_list: 288 | if line: 289 | line_list = line.split(",") 290 | first_ip = line_list[0].strip() 291 | last_ip = line_list[1].strip() 292 | dch_company = line_list[2] 293 | for cidr_range in iprange_to_cidrs(first_ip, last_ip): 294 | dch_dict[cidr_range] = dch_company 295 | 296 | sys.stderr.write("Completed DCH Database. Parsing log now.\n\n") 297 | return dch_dict 298 | 299 | # Sections that convert lines to time, user, ip_string, hostname, client 300 | def get_csv_details(line): 301 | """Convert predefined csv format to time, user, ip_string, hostname, client""" 302 | line_list = line.split(",") 303 | time = datetime.strptime(line_list[0].strip(), '%Y-%m-%d %H:%M:%S') # ex. 2017-05-15 13:56:23 304 | user = line_list[1].strip() 305 | ip_string = line_list[2].strip() 306 | 307 | # Try to parse hostname and client which are optional 308 | try: 309 | hostname = line_list[3].strip() 310 | except IndexError: 311 | hostname = " " 312 | try: 313 | client = line_list[4].strip() 314 | except IndexError: 315 | client = " " 316 | return time, ip_string, user, hostname, client 317 | 318 | def get_custom_details(line): 319 | """Reserved for custom line parsing. Be sure to remove sys.exit() when using.""" 320 | # This function should be used to parse custom line formats and return: 321 | # time, ip_string, user, hostname, client 322 | # 323 | # Example Juniper Firewall Input line (wrapped on new lines): 324 | # Mar 12 10:59:33 FW_JUNIPER PulseSecure: id=firewall time="2018-03-12 10:59:33" pri=6 325 | # fw= vpn= user=System realm="" roles="" type=mgmt proto= src= dst= 326 | # 327 | # Example function to fill in for "get_custom_details(line): 328 | # # Create regex match object to find data 329 | # juniper_2_ip_user_mo = re.compile("(time=\")([\d\ \-\:]{19})(\" .*)( user\=)(.*?)" 330 | # "( realm.*? src=)(.*?)( )") 331 | # 332 | # # Match the regex 333 | # ip_user_match = re.search(juniper_2_ip_user_mo, line) 334 | # 335 | # # Extract timestamp and convert to datetime object from "2017-03-30 00:22:42" format 336 | # time = datetime.strptime(ip_user_match.group(2).strip(), '%Y-%m-%d %H:%M:%S') 337 | # 338 | # # Extract username and source IP (not the 339 | # user = ip_user_match.group(5).strip() 340 | # ip_string = ip_user_match.group(7).strip() 341 | # 342 | # # Set empty hostname and client since they were not included in input 343 | # hostname = "" 344 | # client = "" 345 | # 346 | # return time, ip_string, user, hostname, client 347 | 348 | sys.stderr.write("### It doesn't appear that the custom argument is configured. Quitting.\n\n") 349 | sys.exit() 350 | 351 | def calculate_logon_differences(user_list): 352 | """Calculate differences when a user has a source IP change""" 353 | 354 | difference_dict = {} 355 | 356 | # Calculate location difference, miles per second, and any DCH info 357 | difference_dict["user"] = user_list[0]["user"] 358 | 359 | # Create empty anomalies set to track suspicious flags 360 | difference_dict["anomalies"] = set() 361 | 362 | # "location" is coordinates and vincentrify calculates miles between coordinates 363 | difference_dict["first_location"] = user_list[0]["location"] 364 | difference_dict["second_location"] = user_list[1]["location"] 365 | difference_dict["location_miles_diff"] = geodesic(difference_dict["first_location"], 366 | difference_dict["second_location"]).miles 367 | 368 | # Add anomaly if distance is far 369 | if difference_dict["location_miles_diff"] >= FAR_DISTANCE: 370 | difference_dict["anomalies"].add("DISTANCE") 371 | 372 | # Calculate time between logons of changed source IP 373 | difference_dict["first_time"] = user_list[0]["time"] 374 | difference_dict["second_time"] = user_list[1]["time"] 375 | difference_dict["time_seconds_diff"] = abs((difference_dict["second_time"] - 376 | difference_dict["first_time"]).total_seconds()) 377 | 378 | # Calculate miles per hour required to logon physically from source IP addresses 379 | try: 380 | difference_dict["miles_per_hour"] = ((difference_dict["location_miles_diff"] / 381 | difference_dict["time_seconds_diff"]) * 382 | SECONDS_PER_HOUR) 383 | except ZeroDivisionError: 384 | if difference_dict["location_miles_diff"] == 0: 385 | difference_dict["miles_per_hour"] = 0 386 | else: 387 | difference_dict["miles_per_hour"] = IMPOSSIBLE_MPH 388 | 389 | # Add an anomaly if travel is fast 390 | if difference_dict["miles_per_hour"] >= FAST_MPH: 391 | difference_dict["anomalies"].add("FAST") 392 | 393 | # Find country registered to IP address 394 | difference_dict["first_country"] = user_list[0]["country"] 395 | difference_dict["second_country"] = user_list[1]["country"] 396 | 397 | # Find subdivision such as state, territory, city, etc. registered to source IP 398 | difference_dict["first_subdivision"] = user_list[0]["subdivisions"] 399 | difference_dict["second_subdivision"] = user_list[1]["subdivisions"] 400 | 401 | # Find source IP addresses 402 | difference_dict["first_ip"] = user_list[0]["ip"] 403 | difference_dict["second_ip"] = user_list[1]["ip"] 404 | 405 | # Find datacenter hosting company if any for IP addresses 406 | difference_dict["first_ip_dch_company"] = user_list[0]["dch_company"] 407 | difference_dict["second_ip_dch_company"] = user_list[1]["dch_company"] 408 | 409 | # Add anomaly if DCH detected 410 | if (difference_dict["first_ip_dch_company"] != " " or 411 | difference_dict["second_ip_dch_company"] != " "): 412 | difference_dict["anomalies"].add("DCH") 413 | 414 | # Find ASN Numbers 415 | difference_dict["first_asn_number"] = user_list[0]["asn_number"] 416 | difference_dict["second_asn_number"] = user_list[1]["asn_number"] 417 | 418 | # Find ASN Names 419 | difference_dict["first_asn_name"] = user_list[0]["asn_name"] 420 | difference_dict["second_asn_name"] = user_list[1]["asn_name"] 421 | 422 | # Add anomaly if ASN change detected 423 | if difference_dict["first_asn_name"] != difference_dict["second_asn_name"]: 424 | difference_dict["anomalies"].add("ASN") 425 | 426 | # Find VPN Client Names 427 | difference_dict["first_client"] = user_list[0]["client"] 428 | difference_dict["second_client"] = user_list[1]["client"] 429 | 430 | # Add anomaly if VPN Client change detected 431 | if difference_dict["first_client"] != difference_dict["second_client"]: 432 | difference_dict["anomalies"].add("CLIENT") 433 | 434 | # Find System Hostnames 435 | difference_dict["first_hostname"] = user_list[0]["hostname"] 436 | difference_dict["second_hostname"] = user_list[1]["hostname"] 437 | 438 | # Add anomaly if source hostname change detected 439 | if difference_dict["first_hostname"] != difference_dict["second_hostname"]: 440 | difference_dict["anomalies"].add("HOSTNAME") 441 | 442 | # Find streak of previous logon information 443 | difference_dict["first_streak"] = user_list[0]["ip_streak"] 444 | 445 | # Combine anomalies into string for output 446 | difference_dict["anomalies_string"] = "|".join(difference_dict["anomalies"]) 447 | 448 | return difference_dict 449 | 450 | def diff_dict_to_list(logon_diff_dict): 451 | """Convert logon_diff_dict to list for printing""" 452 | return ([str(logon_diff_dict.get("user", "")), 453 | str(logon_diff_dict.get("anomalies_string", "")), 454 | str(logon_diff_dict.get("first_time", "")), 455 | str(logon_diff_dict.get("first_ip", "")), 456 | str(logon_diff_dict.get("first_ip_dch_company", "")), 457 | str(logon_diff_dict.get("first_country", "")), 458 | str(logon_diff_dict.get("first_subdivision", "")), 459 | str(logon_diff_dict.get("first_location", "")), 460 | str(logon_diff_dict.get("first_asn_number", "")), 461 | str(logon_diff_dict.get("first_asn_name", "")), 462 | str(logon_diff_dict.get("first_client", "")), 463 | str(logon_diff_dict.get("first_hostname", "")), 464 | str(logon_diff_dict.get("first_streak", "")), 465 | str(logon_diff_dict.get("second_time", "")), 466 | str(logon_diff_dict.get("second_ip", "")), 467 | str(logon_diff_dict.get("second_ip_dch_company", "")), 468 | str(logon_diff_dict.get("second_country", "")), 469 | str(logon_diff_dict.get("second_subdivision", "")), 470 | str(logon_diff_dict.get("second_location", "")), 471 | str(logon_diff_dict.get("second_asn_number", "")), 472 | str(logon_diff_dict.get("second_asn_name", "")), 473 | str(logon_diff_dict.get("second_client", "")), 474 | str(logon_diff_dict.get("second_hostname", "")), 475 | str(logon_diff_dict.get("location_miles_diff", "")), 476 | str(logon_diff_dict.get("time_seconds_diff", "")), 477 | str(logon_diff_dict.get("miles_per_hour", ""))]) 478 | 479 | def reserved_ip_check(ip_string): 480 | """determine if IP address in RFC1918 or reserved""" 481 | 482 | # IP details for invalid IP addresses 483 | invalid_ip_details = {"country":"INVALID", 484 | "location":RESERVED_IP_COORDINATES, 485 | "subdivisions":"INVALID", 486 | "dch_company":"", 487 | "asn_number":"", 488 | "asn_name":""} 489 | 490 | # IP details for MULTICAST IP addresses 491 | multicast_ip_details = {"country":"MULTICAST", 492 | "location":RESERVED_IP_COORDINATES, 493 | "subdivisions":"MULTICAST", 494 | "dch_company":"", 495 | "asn_number":"", 496 | "asn_name":""} 497 | 498 | # IP details for PRIVATE IP addresses 499 | private_ip_details = {"country":"PRIVATE", 500 | "location":RESERVED_IP_COORDINATES, 501 | "subdivisions":"PRIVATE", 502 | "dch_company":"", 503 | "asn_number":"", 504 | "asn_name":""} 505 | 506 | # IP details for RESERVED IP addresses 507 | reserved_ip_details = {"country":"RESERVED", 508 | "location":RESERVED_IP_COORDINATES, 509 | "subdivisions":"RESERVED", 510 | "dch_company":"", 511 | "asn_number":"", 512 | "asn_name":""} 513 | 514 | # IP details for NETMASK IP addresses 515 | netmask_ip_details = {"country":"NETMASK", 516 | "location":RESERVED_IP_COORDINATES, 517 | "subdivisions":"NETMASK", 518 | "dch_company":"", 519 | "asn_number":"", 520 | "asn_name":""} 521 | 522 | # IP details for HOSTMASK IP addresses 523 | hostmask_ip_details = {"country":"HOSTMASK", 524 | "location":RESERVED_IP_COORDINATES, 525 | "subdivisions":"HOSTMASK", 526 | "dch_company":"", 527 | "asn_number":"", 528 | "asn_name":""} 529 | 530 | # IP details for LOOPBACK IP addresses 531 | loopback_ip_details = {"country":"LOOPBACK", 532 | "location":RESERVED_IP_COORDINATES, 533 | "subdivisions":"LOOPBACK", 534 | "dch_company":"", 535 | "asn_number":"", 536 | "asn_name":""} 537 | 538 | # Check to see if IP matches a reserved category 539 | try: 540 | ip_address = IPAddress(ip_string) 541 | except AddrFormatError: 542 | return invalid_ip_details 543 | 544 | if ip_address.is_multicast(): 545 | return multicast_ip_details 546 | 547 | elif ip_address.is_ipv4_private_use(): 548 | return private_ip_details 549 | 550 | elif ip_address.is_reserved(): 551 | return reserved_ip_details 552 | 553 | elif ip_address.is_netmask(): 554 | return netmask_ip_details 555 | 556 | elif ip_address.is_hostmask(): 557 | return hostmask_ip_details 558 | 559 | elif ip_address.is_loopback(): 560 | return loopback_ip_details 561 | 562 | elif ip_address.is_unicast() and not ip_address.is_ipv4_private_use(): 563 | # Boolean to be returned if IP is Public 564 | ip_reserved = False 565 | return ip_reserved 566 | 567 | else: 568 | return invalid_ip_details 569 | 570 | def find_dch(ip_string, dch_dict): 571 | """Find if the IP exists in a DCH subnet from our created database""" 572 | 573 | for cidr_range, company in dch_dict.items(): 574 | if IPAddress(ip_string) in cidr_range: 575 | return company 576 | 577 | # If we didn't find a DCH Match, return "" 578 | return "" 579 | 580 | def main(args): 581 | """Main Function""" 582 | 583 | # Create a cache of IP address metadata to avoid looking up location and DCH data for known IPs 584 | ip_cache = {} 585 | 586 | # Create user_dict to keep track of user sessions 587 | user_dict = {} 588 | 589 | # Create MaxMind ASN DB 590 | asn_db_reader = create_asn_db() 591 | 592 | # Create MaxMind city DB 593 | geoip_db = create_geoip_db() 594 | 595 | # Create DCH dict 596 | dch_dict = create_dch_dict() 597 | 598 | # Ddetermine which type of log we have based on argument 599 | if args.csv: 600 | input_path = args.csv 601 | elif args.ip_only: 602 | input_path = args.ip_only 603 | elif args.custom: 604 | input_path = args.custom 605 | 606 | # Print an error message if the argument is not recognized and exit 607 | else: 608 | sys.stderr.write("\n\nDidn't recognize your input argument! Please try again.\n") 609 | sys.exit() 610 | 611 | # Determine if user wants to skip RFC1918 source IP addresses 612 | if args.skip_rfc1918: 613 | skip_rfc1918 = True 614 | else: 615 | skip_rfc1918 = False 616 | 617 | # Create output file 618 | output_file = open("{}".format(args.output), "w", newline='') 619 | csv_writer = csv.writer(output_file, delimiter=',', quotechar='"', 620 | quoting=csv.QUOTE_MINIMAL) 621 | 622 | # Print appropriate headers to output file 623 | if args.ip_only: 624 | csv_writer.writerow(["ip", "location", "country", "subdivisions", "dch_company", 625 | "asn_number", "asn_name"]) 626 | else: 627 | diff_dict = {"user":"User", 628 | "anomalies_string":"Anomalies", 629 | "first_time":"1st Time", 630 | "first_ip":"1st IP", 631 | "first_ip_dch_company":"1st DCH", 632 | "first_country":"1st Country", 633 | "first_subdivision":"1st Region", 634 | "first_location":"1st Coords", 635 | "first_asn_number":"1st ASN #", 636 | "first_asn_name":"1st ASN Name", 637 | "first_client":"1st VPN Client", 638 | "first_hostname":"1st Hostname", 639 | "first_streak":"1st Streak", 640 | "second_time":"2nd Time", 641 | "second_ip":"2nd IP", 642 | "second_ip_dch_company":"2nd DCH", 643 | "second_country":"2nd Country", 644 | "second_subdivision":"2nd Region", 645 | "second_location":"2nd Coords", 646 | "second_asn_number":"2nd ASN #", 647 | "second_asn_name":"2nd ASN Name", 648 | "second_client":"2nd VPN Client", 649 | "second_hostname":"2nd Hostname", 650 | "location_miles_diff":"Miles Diff", 651 | "time_seconds_diff":"Seconds Diff", 652 | "miles_per_hour":"Miles/Hour"} 653 | 654 | csv_writer.writerow(diff_dict_to_list(diff_dict)) 655 | 656 | # Open input file and pull time, ip, user, hostname, client out of each line as specified by 657 | # argument 658 | with open(input_path, "r") as input_file: 659 | 660 | # Look at every line 661 | for line in input_file: 662 | 663 | try: 664 | if args.csv: 665 | # Parse predetermined CSV format 666 | time, ip_string, user, hostname, client = get_csv_details(line) 667 | 668 | elif args.ip_only: 669 | # Parse a file of only IP addresses 670 | ip_string = line.strip() 671 | time = " " 672 | user = " " 673 | 674 | elif args.custom: 675 | # Reserved for custom use 676 | time, ip_string, user, hostname, client = get_custom_details(line) 677 | 678 | else: 679 | sys.stderr.write("Unsupported log type! Try 'GeoLogonalyzer.py -h'\n\n" 680 | "Quitting!\n") 681 | sys.exit() 682 | 683 | # If a line has errors, print the error and keep going 684 | except AttributeError as errormessage: 685 | sys.stderr.write("### Attribute Error with line: {}\n".format(line)) 686 | sys.stderr.write("{}\t\n".format(errormessage)) 687 | continue 688 | except ValueError as errormessage: 689 | sys.stderr.write("### ValueError with line: {}\n".format(line)) 690 | sys.stderr.write("{}\t\n".format(errormessage)) 691 | continue 692 | 693 | # Skip lines without usernames or IPs since there is no value to add 694 | if not user: 695 | continue 696 | if not ip_string: 697 | continue 698 | 699 | # Check if ip is reserved or doesn't exist 700 | reserved_ip_details = reserved_ip_check(ip_string) 701 | if reserved_ip_check(ip_string): 702 | country = reserved_ip_details["country"] 703 | location = reserved_ip_details["location"] 704 | subdivisions = reserved_ip_details["subdivisions"] 705 | dch_company = reserved_ip_details["dch_company"] 706 | asn_number = reserved_ip_details["asn_number"] 707 | asn_name = reserved_ip_details["asn_name"] 708 | 709 | # Skip RFC1918 source IP Addresses if desired 710 | if skip_rfc1918: 711 | continue 712 | 713 | else: 714 | #if we have a non-reserved IP, look up location and DCH 715 | 716 | if ip_string in ip_cache: 717 | # see if we have seen this IP before and looked it up in the DB 718 | country = ip_cache[ip_string]["country"] 719 | location = ip_cache[ip_string]["location"] 720 | subdivisions = ip_cache[ip_string]["subdivisions"] 721 | dch_company = ip_cache[ip_string]["dch_company"] 722 | asn_number = ip_cache[ip_string]["asn_number"] 723 | asn_name = ip_cache[ip_string]["asn_name"] 724 | 725 | else: 726 | # If we haven't looked up this IP before, let's get the info and cache it 727 | 728 | # MaxMind geoip DB lookup 729 | geoip_db_match = geoip_db.lookup(ip_string) 730 | 731 | # Find Country from MaxMind geoip DB 732 | try: 733 | country = geoip_db_match.country 734 | except AttributeError: 735 | country = "None" 736 | ip_cache[ip_string] = {"country":country} 737 | 738 | # Find Coordinates from MaxMind geoip DB 739 | try: 740 | location = geoip_db_match.location 741 | except AttributeError: 742 | location = (0, 0) 743 | ip_cache[ip_string]["location"] = location 744 | 745 | # Find Subdivisions from MaxMind geoip DB 746 | try: 747 | subdivisions = ", ".join(geoip_db_match.subdivisions) 748 | except AttributeError: 749 | subdivisions = "None" 750 | ip_cache[ip_string]["subdivisions"] = subdivisions 751 | 752 | # Find DataCenter Hosting Information from open source data 753 | try: 754 | dch_company = find_dch(ip_string, dch_dict) 755 | if dch_company == "": 756 | dch_company = " " 757 | except AttributeError: 758 | dch_company = " " 759 | ip_cache[ip_string]["dch_company"] = dch_company 760 | 761 | # MaxMind asn DB lookup 762 | try: 763 | asn_db_match = asn_db_reader.asn(ip_string) 764 | except geoip2.errors.AddressNotFoundError: 765 | sys.stderr.write("\n {} not found in ASN database.\n".format(ip_string)) 766 | asn_db_match = None 767 | 768 | # Find ASN number from MaxMind ASN DB 769 | try: 770 | asn_number = asn_db_match.autonomous_system_number 771 | except AttributeError: 772 | asn_number = " " 773 | ip_cache[ip_string]["asn_number"] = asn_number 774 | 775 | # Find ASN organization name from MaxMind ASN DB 776 | try: 777 | asn_name = asn_db_match.autonomous_system_organization 778 | except AttributeError: 779 | asn_name = " " 780 | ip_cache[ip_string]["asn_name"] = asn_name 781 | 782 | # If the input is IPs only 783 | if args.ip_only: 784 | csv_writer.writerow([str(ip_string), str(location), str(country), str(subdivisions), 785 | str(dch_company), str(asn_number), str(asn_name)]) 786 | 787 | # If the input is an actual log, start doing user matching or tracking 788 | else: 789 | 790 | # If there was a previous logon of this user account detected 791 | if user in user_dict: 792 | 793 | # Just confirm that there is only 1 previous logon, no reason this should fail 794 | if len(user_dict[user]) == 1: 795 | 796 | # Add the second logon to the tracker 797 | user_dict[user].append({"user":user, 798 | "time":time, 799 | "ip":ip_string, 800 | "dch_company":dch_company, 801 | "country":country, 802 | "location":location, 803 | "subdivisions":subdivisions, 804 | "ip_streak":1, 805 | "asn_number":asn_number, 806 | "asn_name":asn_name, 807 | "hostname":hostname, 808 | "client":client}) 809 | 810 | # If the second logon has a different source IP, source hostname, or 811 | # VPN client than the previously seen logon, calculate the differences 812 | if user_dict[user][0]["ip"] != user_dict[user][1]["ip"]: 813 | logon_diff_dict = calculate_logon_differences(user_dict[user]) 814 | logon_diff_list = diff_dict_to_list(logon_diff_dict) 815 | csv_writer.writerow(logon_diff_list) 816 | 817 | elif user_dict[user][0]["hostname"] != user_dict[user][1]["hostname"]: 818 | logon_diff_dict = calculate_logon_differences(user_dict[user]) 819 | logon_diff_list = diff_dict_to_list(logon_diff_dict) 820 | csv_writer.writerow(logon_diff_list) 821 | 822 | elif user_dict[user][0]["client"] != user_dict[user][1]["client"]: 823 | logon_diff_dict = calculate_logon_differences(user_dict[user]) 824 | logon_diff_list = diff_dict_to_list(logon_diff_dict) 825 | csv_writer.writerow(logon_diff_list) 826 | 827 | # If it's the same source IP, just increment the counter for the newest 828 | # logon 829 | else: 830 | user_dict[user][1]["ip_streak"] = user_dict[user][0]["ip_streak"] + 1 831 | 832 | # Since we only care about diffs, drop the older logon and wait to see if 833 | # the next one is different 834 | user_dict[user].pop(0) 835 | 836 | # If for some reason there is not exactly 1 previous logon recorded, raise an 837 | # error 838 | else: 839 | assert "error" == "too many records in list" 840 | 841 | else: 842 | # If we have never seen this user before 843 | user_dict[user] = [{"user":user, 844 | "time":time, 845 | "ip":ip_string, 846 | "dch_company":dch_company, 847 | "country":country, 848 | "location":location, 849 | "subdivisions":subdivisions, 850 | "ip_streak":1, 851 | "asn_number":asn_number, 852 | "asn_name":asn_name, 853 | "hostname":hostname, 854 | "client":client}] 855 | 856 | # Print information for the last logon streak of each user 857 | # Useful if there are no source IP changes for that user 858 | for user, logon_info in user_dict.items(): 859 | 860 | if len(logon_info) != 1: 861 | # Catch if a user has more than 1 logon remaining, which should not happen 862 | assert "more than one (1)" == " logon session remaining" 863 | 864 | else: 865 | # Prepare data of last streak for printing 866 | first_time = logon_info[0]["time"] 867 | first_ip = logon_info[0]["ip"] 868 | first_ip_dch_company = logon_info[0]["dch_company"] 869 | first_country = logon_info[0]["country"] 870 | first_subdivision = logon_info[0]["subdivisions"] 871 | first_location = logon_info[0]["location"] 872 | first_streak = logon_info[0]["ip_streak"] 873 | first_asn_number = logon_info[0]["asn_number"] 874 | first_asn_name = logon_info[0]["asn_name"] 875 | first_client = logon_info[0]["client"] 876 | first_hostname = logon_info[0]["hostname"] 877 | 878 | # The only possible anomaly for unchanged or last logon records could be DCH, 879 | # so add that in here if applicable 880 | if first_ip_dch_company not in [" ", ""]: 881 | first_anomalies = "DCH" 882 | else: 883 | first_anomalies = " " 884 | 885 | # Prepare last streak data for output 886 | last_streak_dict = {"user":user, 887 | "anomalies_string":".".join(first_anomalies), 888 | "first_time":first_time, 889 | "first_ip":first_ip, 890 | "first_ip_dch_company":first_ip_dch_company, 891 | "first_country":first_country, 892 | "first_subdivision":first_subdivision, 893 | "first_location":first_location, 894 | "first_asn_number":first_asn_number, 895 | "first_asn_name":first_asn_name, 896 | "first_client":first_client, 897 | "first_hostname":first_hostname, 898 | "first_streak":first_streak} 899 | 900 | # Convert data to list and write to output 901 | last_streak_list = diff_dict_to_list(last_streak_dict) 902 | csv_writer.writerow(last_streak_list) 903 | 904 | # Always be polite! 905 | sys.stderr.write("\n\nComplete! Thanks for using GeoLogonalyzer.py.") 906 | output_file.close() 907 | 908 | if __name__ == "__main__": 909 | 910 | # Welcome art 911 | 912 | art = ("\n\n\n _\n" 913 | " | \\\n" 914 | " ,---------------------------------, _/ >\n" 915 | " | 1 \\____ __/ /\n" 916 | " | \\ \\ _/ \\\n" 917 | " | \\ 3 '-, | ,-'\n" 918 | " ______ | \\_ / \\ \\_/ /\n" 919 | " / ____/_| ____ / / ____ ___/ _\\__ ____ ____ / /_ ____|_ ___ _____\n" 920 | " / / __/ _ \\/ __ \\/ / \\ / __ \\/ __ \\/ __ \\/ __ \\/ __ \\/ / / / /_ / / _ \\/ ___/\n" 921 | " / /_/ / __/ /_/ / /___/ /_/ / /_/ / /_/ / / / / /_/ / / /_/ / / /_/ __/ /\n" 922 | " \\____/\\___/\\____/_____/\\____/\\__, /\\____/_/ /_/\\__,_/_/\\__, / /___/\\___/_/\n" 923 | " \\ \\ /____/ \\ /____/ /\n" 924 | " |_ \\ / \\ /\n" 925 | " \\ 2 \\ /\n" 926 | " ----. \\ /\n" 927 | " '-,_ 4 \\\n" 928 | " `-----, ,-------, \\\n" 929 | " \\,~. ,---^---' | \\\n" 930 | " \\ / \\ |\n" 931 | " \\ | \\_|\n" 932 | " `-'\n\n\n") 933 | sys.stderr.write(art) 934 | 935 | # Welcome Message 936 | sys.stderr.write("\n Thank you for using GeoLogonAnalyzer.py, created by David Pany at" 937 | " Mandiant\n Version 1.10\n\n") 938 | sys.stderr.write(" Example command syntax:\n") 939 | sys.stderr.write(" python GeoLogonalyzer.py --csv VPNLogs.csv --output output.csv\n\n") 940 | 941 | # Sleep for 1 second after welcome before showing licenses 942 | time.sleep(1) 943 | 944 | sys.stderr.write("Licenses:\n" 945 | "\tThe license for GeoLogonalyzer can be found at:\n" 946 | "\t\thttps://raw.githubusercontent.com/mandiant/GeoLogonalyzer/master/LICENSE.txt\n\n") 947 | 948 | # Attribution and license information for MaxMind 949 | sys.stderr.write("\tThis product includes GeoLite2 data created by MaxMind, available from\n" 950 | "\thttps://www.maxmind.com\n\n") 951 | 952 | # Attribution and license information for Client9 953 | sys.stderr.write("\tThis product retrieves and operates on data including datacenter\n" 954 | "\tcategorizations retrieved from https://github.com/growlfm/ipcat/\n" 955 | "\twhich is a version from https://github.com/client9/ipcat/.\n" 956 | "\tLicenses:\n" 957 | "\t\thttps://raw.githubusercontent.com/client9/ipcat/master/LICENSE\n" 958 | "\t\thttps://raw.githubusercontent.com/growlfm/ipcat/main/LICENSE\n\n") 959 | 960 | # Sleep for 2 seconds after displaying license 961 | time.sleep(2) 962 | 963 | #Parse arguments 964 | parser = argparse.ArgumentParser() 965 | parser.add_argument("--csv", help='CSV like "YYYY-MM-DD HH:MM:SS,user,10.10.10.10,hostname' 966 | '(optional),VPN client (optional)"', required=False) 967 | parser.add_argument("--custom", help='Custom line parsing to be implemented by user', 968 | required=False) 969 | parser.add_argument("--ip_only", help='TXT file of IP Addresses only, one per line', required=False) 970 | parser.add_argument("--output", help='Output CSV file', required=True) 971 | parser.add_argument("--skip_rfc1918", help='Skip RFC1918 source IP addresses', required=False, 972 | action='store_true') 973 | args = parser.parse_args() 974 | main(args) 975 | --------------------------------------------------------------------------------