From e374dc195b69897b6d2bef00eb24247f4cb3d80a Mon Sep 17 00:00:00 2001 From: Luke Tainton Date: Sat, 25 Jun 2022 22:27:01 +0100 Subject: [PATCH] RELEASE: Version 1.0 (#3) --- .github/workflows/ci.yml | 41 +++++++++++++++++++++ README.md | 5 ++- app/__init__.py | 0 app/_version.py | 5 +++ app/args.py | 33 +++++++++++++++++ app/ip_info.py | 28 ++++++++++++++ app/main.py | 61 ++++++++++++++++++++++++++++++- app/print_table.py | 22 +++++++++++ app/query_normalisation.py | 31 ++++++++++++++++ requirements.txt | 6 +++ setup.py | 29 +++++++++++++++ tests/__init__.py | 0 tests/test_ip_info.py | 30 +++++++++++++++ tests/test_main.py | 20 ++++++++++ tests/test_query_normalisation.py | 46 +++++++++++++++++++++++ 15 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/_version.py create mode 100644 app/args.py create mode 100644 app/ip_info.py create mode 100644 app/print_table.py create mode 100644 app/query_normalisation.py create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_ip_info.py create mode 100644 tests/test_query_normalisation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f7495a..bfad360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,12 @@ on: - 'README.md' - 'LICENSE.md' - '.gitignore' + - 'renovate.json' pull_request: jobs: skip_duplicate: + name: Skip if duplicate run runs-on: ubuntu-latest outputs: should_skip: ${{ steps.skip_check.outputs.should_skip }} @@ -43,6 +45,7 @@ jobs: uses: github/codeql-action/analyze@v2 lint: + name: Lint needs: skip_duplicate if: ${{ needs.skip_duplicate.outputs.should_skip == 'false' }} runs-on: ubuntu-latest @@ -59,6 +62,7 @@ jobs: run: pylint --recursive=yes . test: + name: Run unit tests needs: skip_duplicate if: ${{ needs.skip_duplicate.outputs.should_skip == 'false' }} runs-on: ubuntu-latest @@ -78,3 +82,40 @@ jobs: run: coverage run -m py.test -v - name: Upload Coverage to Codecov uses: codecov/codecov-action@v3 + + build: + name: Build + needs: + - lint + - test + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v3 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: "3.10" + # - name: Install dependencies + # run: pip install -r requirements.txt && pip install -r requirements-dev.txt + - name: Install build dependencies + run: pip install setuptools wheel + - name: Build wheel file + run: python setup.py bdist_wheel + - id: skip_check + uses: actions/upload-artifact@v3 + with: + name: whl + path: dist/ + + publish: + name: Publish + needs: build + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + steps: + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index 86b71e3..15f746b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ -# template +# iPilot [![CI](https://github.com/luketainton/pypilot/actions/workflows/ci.yml/badge.svg)](https://github.com/luketainton/pypilot/actions/workflows/ci.yml) ## Description +IP Information Lookup Tool ## How to install +`pip install ipilot` ## How to use +`ipilot --help` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/_version.py b/app/_version.py new file mode 100644 index 0000000..c51437b --- /dev/null +++ b/app/_version.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +"""MODULE: Specifies app version.""" + +VERSION = "1.0" diff --git a/app/args.py b/app/args.py new file mode 100644 index 0000000..400b448 --- /dev/null +++ b/app/args.py @@ -0,0 +1,33 @@ +#!/usr/local/env python3 + +"""MODULE: Provides CLI arguments to the application.""" + +import argparse + +from app.query_normalisation import get_public_ip + + +def parse_args() -> argparse.Namespace: + """Get arguments from user via the command line.""" + parser = argparse.ArgumentParser( + description="Query information about an IP address or domain name." + ) + parser.add_argument( + "-q", + "--query", + help="IP/domain name to query (default: current public IP)", + default=get_public_ip(), + ) + parser.add_argument( + "-p", + "--prefixes", + help="show advertised prefixes", + action="store_true", + ) + parser.add_argument( + "-n", + "--noheader", + help="do not print header", + action="store_true", + ) + return parser.parse_args() diff --git a/app/ip_info.py b/app/ip_info.py new file mode 100644 index 0000000..ade9dde --- /dev/null +++ b/app/ip_info.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + +"""MODULE: Provides functions to call various APIs to retrieve IP/prefix information.""" + +import ipaddress +import requests + + +def get_ip_information(ipv4_address: ipaddress.IPv4Address) -> dict: + """Retrieves information about a given IPv4 address from IP-API.com.""" + api_endpoint = f"http://ip-api.com/json/{ipv4_address}" + resp = requests.get(api_endpoint).json() + return resp + + +def get_autonomous_system_number(as_info: str) -> str: + """Parses AS number from provided AS information.""" + as_number = as_info.split(" ")[0] + return as_number + + +def get_prefix_information(autonomous_system: int) -> list: + """Retrieves prefix information about a given autonomous system.""" + api_endpoint = f"https://api.hackertarget.com/aslookup/?q={str(autonomous_system)}" + resp = requests.get(api_endpoint).text + prefixes = resp.split("\n") + prefixes.pop(0) + return prefixes diff --git a/app/main.py b/app/main.py index a6053f8..898afb8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,65 @@ #!/usr/local/bin/python3 +"""MODULE: Main application module.""" + +import sys + +from app.args import parse_args +from app.print_table import print_table, generate_prefix_string +from app.query_normalisation import is_ip_address, resolve_domain_name +from app.ip_info import ( # pragma: no cover + get_ip_information, + get_autonomous_system_number, + get_prefix_information, +) + + +HEADER = """----------------------------------------------- +| IP Address Information Lookup Tool (iPilot) | +| By Luke Tainton (@luketainton) | +-----------------------------------------------\n""" + + def main(): - # Commands here - + """Main function.""" + args = parse_args() + if not args.noheader: + print(HEADER) + + # Set IP to passed IP address, or resolve passed domain name to IPv4 + ip_address = ( + resolve_domain_name(args.query) if not is_ip_address(args.query) else args.query + ) + + # If not given an IPv4, and can't resolve to IPv4, then throw error and exit + if not ip_address: + print("ERROR: could not resolve query to IPv4 address.") + sys.exit(1) + + # Get information from API + ip_info = get_ip_information(ip_address) + as_number = get_autonomous_system_number(ip_info.get("as")) + + # Assemble list for table generation + table_data = [ + ["IP Address", ip_info.get("query")], + ["Organization", ip_info.get("org")], + [ + "Location", + f"{ip_info.get('country')}/{ip_info.get('regionName')}/{ip_info.get('city')}", + ], + ["Timezone", ip_info.get("timezone")], + ["Internet Service Provider", ip_info.get("isp")], + ["Autonomous System", as_number], + ] + + # If wanted, get prefix information + if args.prefixes: + prefix_info = get_prefix_information(as_number) + table_data.append(["Prefixes", generate_prefix_string(prefix_info)]) + + print_table(table_data) + if __name__ == "__main__": main() diff --git a/app/print_table.py b/app/print_table.py new file mode 100644 index 0000000..fd9f118 --- /dev/null +++ b/app/print_table.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +"""MODULE: Provides functions for preparing, then printing, retrieved data.""" + +from tabulate import tabulate + + +def generate_prefix_string(prefixes: list) -> str: + """Generate a string that spilts prefixes into rows of 4.""" + num_per_row = 4 + try: + ret = "" + for i in range(0, len(prefixes), num_per_row): + ret += ", ".join(prefixes[i : i + num_per_row]) + "\n" + return ret + except AttributeError: + return None + + +def print_table(table_data) -> None: + """Print table generated by tabulate.""" + print(tabulate(table_data)) diff --git a/app/query_normalisation.py b/app/query_normalisation.py new file mode 100644 index 0000000..5bf32fd --- /dev/null +++ b/app/query_normalisation.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +"""MODULE: Provides functions that ensure an IP address is available to query the APIs for.""" + +import socket +import ipaddress +import requests + + +def is_ip_address(query: str) -> bool: + """Verifies if a given query is a valid IPv4 address.""" + try: + ipaddress.ip_address(query) + return True + except ValueError: + return False + + +def resolve_domain_name(domain_name: str) -> ipaddress.IPv4Address: + """Resolve a domain name via DNS or return None.""" + try: + ip_address = socket.gethostbyname(domain_name) + except socket.gaierror: + ip_address = None + return ip_address + + +def get_public_ip() -> ipaddress.IPv4Address: + """Get the user's current public IPv4 address.""" + ip_address = requests.get("https://api.ipify.org").text + return ip_address diff --git a/requirements.txt b/requirements.txt index e69de29..cad61e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,6 @@ +certifi==2022.6.15 +charset-normalizer==2.0.12 +idna==3.3 +requests==2.28.0 +tabulate==0.8.10 +urllib3==1.26.9 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..255974b --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +"""SETUP: Build application .whl file.""" + +from setuptools import setup + +from app._version import VERSION + + +dependencies = [] +with open("requirements.txt", "r", encoding="ascii") as dep_file: + for dep_line in dep_file.readlines(): + dependencies.append(dep_line.replace("\n", "")) + + +setup( + name="ipilot", + version=VERSION, + description="IP Information Lookup Tool", + author="Luke Tainton", + author_email="luke@tainton.uk", + packages=["app"], + install_requires=dependencies, + entry_points={ + "console_scripts": [ + "ipilot = app.main:main", + ], + }, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ip_info.py b/tests/test_ip_info.py new file mode 100644 index 0000000..ea84846 --- /dev/null +++ b/tests/test_ip_info.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +"""MODULE: Provides test cases for app/ip_info.py.""" + +from app.ip_info import ( # pragma: no cover + get_ip_information, + get_autonomous_system_number, + get_prefix_information, +) + + +def test_get_ip_information() -> None: + """TEST: ensure that the IP information API is working correctly.""" + test_query = "1.2.3.4" + ip_info = get_ip_information(test_query) + assert ip_info.get("status") == "success" and ip_info.get("query") == test_query + + +def test_get_autonomous_system_number() -> None: + """TEST: ensure that AS information is parsed into AS number correctly.""" + as_info = "AS5089 Virgin Media Limited" + as_number = get_autonomous_system_number(as_info) + assert as_number == "AS5089" + + +def test_get_prefix_information() -> None: + """TEST: ensure that advertised prefixes are retrieved correctly.""" + autonomous_system = "AS109" + prefixes = get_prefix_information(autonomous_system) + assert "144.254.0.0/16" in prefixes diff --git a/tests/test_main.py b/tests/test_main.py index e69de29..ebbdd22 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +"""MODULE: Provides test cases for app/main.py.""" + +# from app.ip import is_ip_address + + +# def test_is_ip_address_true() -> None: +# test_query = "1.2.3.4" +# assert is_ip_address(test_query) + + +# def test_is_ip_address_false_ip() -> None: +# test_query = "256.315.16.23" +# assert not is_ip_address(test_query) + + +# def test_is_ip_address_false_fqdn() -> None: +# test_query = "google.com" +# assert not is_ip_address(test_query) diff --git a/tests/test_query_normalisation.py b/tests/test_query_normalisation.py new file mode 100644 index 0000000..6fdd6a6 --- /dev/null +++ b/tests/test_query_normalisation.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +"""MODULE: Provides test cases for app/query_normalisation.py.""" + +from app.query_normalisation import ( # pragma: no cover + is_ip_address, + resolve_domain_name, + get_public_ip, +) + + +def test_is_ip_address_true() -> None: + """TEST: Verifies if a given query is a valid IPv4 address.""" + test_query = "1.2.3.4" + assert is_ip_address(test_query) + + +def test_is_ip_address_false_ip() -> None: + """TEST: Verifies that None is returned if an invalid IP is given.""" + test_query = "256.315.16.23" + assert not is_ip_address(test_query) + + +def test_is_ip_address_false_fqdn() -> None: + """TEST: Verifies that None is returned if a domain name is given.""" + test_query = "google.com" + assert not is_ip_address(test_query) + + +def test_resolve_domain_name_true() -> None: + """TEST: Verifies that DNS resolution is working correctly.""" + domain_name = "one.one.one.one" + expected_results = ["1.1.1.1", "1.0.0.1"] # Could resolve to either IP + assert resolve_domain_name(domain_name) in expected_results + + +def test_resolve_domain_name_false() -> None: + """TEST: Verifiees that a non-existent domain is not resolved.""" + domain_name = "hrrijoresdo.com" + assert not resolve_domain_name(domain_name) + + +def test_get_public_ip() -> None: + """TEST: Verifies that the current public IP is retrieved correctly.""" + public_ip = get_public_ip() + assert is_ip_address(public_ip)