diff --git a/.codacy.yaml b/.codacy.yaml new file mode 100644 index 0000000..367c386 --- /dev/null +++ b/.codacy.yaml @@ -0,0 +1,29 @@ +--- +# Codacy configuration for wifi_db +# +# Exclusions below remove noise and false positives so the dashboard reflects +# real issues only. +exclude_paths: + # Local virtualenv committed in the repo: not project source code. + - 'wifi_db_env/**' + # Generated / cache directories. + - '__pycache__/**' + - '**/__pycache__/**' + - '.pytest_cache/**' + # Empty package marker (0 statements). Codacy renders its 0/0 coverage as a + # misleading 0%; there is nothing to analyse or cover, so exclude it. + - 'utils/__init__.py' + # Vendor data dump (OUI MAC vendor list), not hand-written code. + - 'utils/mac-vendors-export.csv' + # pip requirements file, not Python. Codacy routes it to Bandit, which + # AST-parses it and fails on the version specifiers ("syntax error while + # parsing AST from file", e.g. `cryptography>=48.0.1`). Dependency/CVE + # scanning is a separate Codacy feature and is unaffected by this exclusion. + - 'requirements.txt' + # Static, trusted SQLite schema/view definitions. These are plain SQLite DDL + # shipped with the project and executed via sqlite3.executescript(). Codacy's + # SQL analysis parses them with a SQL Server (T-SQL) dialect, which produces + # false positives ("syntax error near NOT/CONSTRAINT", "SET ANSI_NULLS", + # "queries must target RAC_* tables", etc.) that do not apply to SQLite. + - 'utils/view.sql' + - 'utils/wifi_db_database.sql' diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..9336186 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,32 @@ +# Coverage.py configuration. `pytest --cov` (pytest-cov) reads this and, with +# --cov-report=xml, writes a Cobertura coverage.xml -- the format Codacy expects +# for Python (https://docs.codacy.com/coverage-reporter/). +[run] +branch = True +# Store repo-root-relative file paths (utils/foo.py, wifi_db.py) instead of +# absolute ones, so Codacy can match the report to the repository files. +relative_files = True +# Measure from the repo root so paths stay repo-relative, then omit everything +# that is not application code (tests, helper scripts, the local virtualenv). +source = . +omit = + tests/* + scripts/* + conftest.py + wifi_db_env/* + */__pycache__/* + # Empty package marker: 0 statements. coverage.py calls that vacuously + # 100%, but Codacy computes covered/total and renders 0/0 as a misleading + # 0%, so keep it out of the report entirely. + utils/__init__.py + +[report] +show_missing = True +# Non-executable lines that should never count against coverage. +exclude_lines = + pragma: no cover + if __name__ == .__main__.: + raise NotImplementedError + +[xml] +output = coverage.xml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2e4bdf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Keep the build context (and the COPY layer) small. Anything not needed to +# run wifi_db inside the container is excluded here. Tests are not run during +# the build; they run against the finished image (see test_docker.sh). + +# VCS / CI +.git +.github +.gitignore + +# Local virtualenv and Python caches +wifi_db_env +__pycache__ +**/__pycache__ +*.pyc +.pytest_cache + +# Coverage artifacts (regenerated inside the image when running the suite) +.coverage +.coverage.* +coverage.xml +.coverage_out.* +htmlcov + +# Test fixtures: not baked into the image. The release pipeline / test_docker.sh +# mounts this directory into the built container to run the suite against it. +test_data + +# README image assets and the roadmap/changelog are not needed at runtime. +# README.md itself is kept: the test suite hashes it as a sample input file. +resources +ROADMAP*.md +CHANGELOG.md + +# Local databases / editor / tooling +*.sqlite +*.SQLITE +*.sqlite-journal +.vscode +.codacy.yaml +.flake8 + +# Docker files themselves +Dockerfile +.dockerignore diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml index 47110ae..28cc44a 100644 --- a/.github/workflows/docker-image-dev.yml +++ b/.github/workflows/docker-image-dev.yml @@ -10,45 +10,77 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - + # v3 ships a recent QEMU; old QEMU emulation crashes arm64 apt-get + # (exit code 100). Required for the linux/arm64 build below. + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0 - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + + # Build the amd64 image locally first and run the test suite against it. + # The image is test-free; test_docker.sh mounts the fixtures and runs + # pytest with the image's own Python/tshark/hcxtools. Nothing is pushed + # until this passes, so a failing build never reaches a registry. + - name: Build image for testing + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # v2 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: wifi_db:test + + - name: Test the built image + run: | + chmod +x ./test_docker.sh + ./test_docker.sh wifi_db:test + + # test_docker.sh leaves the Cobertura report at ./coverage.xml. Upload it + # so the Codacy dashboard reflects the coverage of the code just built, + # instead of freezing on whatever was last sent by hand. This workflow + # only runs on push to dev, so the CODACY_PROJECT_TOKEN secret is always + # available here. + - name: Upload coverage to Codacy + uses: codacy/codacy-coverage-reporter-action@89d6c85cfafaec52c72b6c5e8b2878d33104c699 # v1.3.0 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: coverage.xml # manager docker - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: - images: ${{ github.actor }}/wifi_db + images: ${{ github.repository }} - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # v1 with: username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push docker id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # v2 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/wifi_db:dev + tags: ${{ github.repository }}:dev - name: Login to GitHub Container Registry run: echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Build and push docker id: docker_build_ghcr - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: ghcr.io/${{ github.actor }}/wifi_db:dev \ No newline at end of file + tags: ghcr.io/${{ github.repository }}:dev \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index aa9f430..e6f4384 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -12,29 +12,61 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up QEMU - uses: docker/setup-qemu-action@v1 - + # v3 ships a recent QEMU; old QEMU emulation crashes arm64 apt-get + # (exit code 100). Required for the linux/arm64 build below. + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0 - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 + + # Build the amd64 image locally first and run the test suite against it. + # The image is test-free; test_docker.sh mounts the fixtures and runs + # pytest with the image's own Python/tshark/hcxtools. Nothing is pushed + # until this passes, so a failing build never reaches a registry. + - name: Build image for testing + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # v2 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: wifi_db:test + + - name: Test the built image + run: | + chmod +x ./test_docker.sh + ./test_docker.sh wifi_db:test + + # test_docker.sh leaves the Cobertura report at ./coverage.xml. Upload it + # so the Codacy dashboard reflects the coverage of the code just built, + # instead of freezing on whatever was last sent by hand. This workflow + # only runs on push to master / tags, so the CODACY_PROJECT_TOKEN secret + # is always available here. + - name: Upload coverage to Codacy + uses: codacy/codacy-coverage-reporter-action@89d6c85cfafaec52c72b6c5e8b2878d33104c699 # v1.3.0 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: coverage.xml # manager docker - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: - images: ${{ github.actor }}/wifi_db + images: ${{ github.repository }} - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@dd4fa0671be5250ee6f50aedf4cb05514abda2c7 # v1 with: username: ${{ github.actor }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push docker id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a # v2 with: context: . file: ./Dockerfile @@ -48,11 +80,11 @@ jobs: - name: Build and push github id: docker_build_ghcr - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: ghcr.io/${{ github.actor }}/wifi_db:latest, ghcr.io/${{ steps.meta.outputs.tags }} + tags: ghcr.io/${{ github.repository }}:latest, ghcr.io/${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/sync_readme.yml b/.github/workflows/sync_readme.yml index 108ad85..8839030 100644 --- a/.github/workflows/sync_readme.yml +++ b/.github/workflows/sync_readme.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - name: Docker Hub Description - uses: peter-evans/dockerhub-description@v2 + uses: peter-evans/dockerhub-description@616d1b63e806b630b975af3b4fe3304307b20f40 # v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} diff --git a/.gitignore b/.gitignore index b5353c1..ac333c3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,11 @@ __pycache__/ .flake8 .vscode/ *.sqlite-journal + +# Coverage artifacts (generated by pytest --cov / Coverage.py) +.coverage +.coverage.* +coverage.xml +.coverage_out.* +htmlcov/ +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eb6e159 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## v1.6.0 + +### Added +- Per-AP RSN/WPA security breakdown (WPA version, AKM suites, pairwise/group ciphers, enterprise flag, PMF) in a new `SecurityAP` view, with human-readable decodes of the `wps_config_methods` and `rsn_capabilities` bitmasks (e.g. `0x00c0` → `MFPR, MFPC`) stored beside the raw values; existing databases migrate automatically. +- X.509 certificate extraction from enterprise (802.1X) EAP into the new `Certificate` table and `CertificateAP` view (based on the @x4v1l0k idea in PR #57). +- EAP enrichment on identities: realm and method-type lookup, plus EAP-MD5 challenge/response capture for offline cracking (`EAPMD5` table, `hashcat -m 4800`). +- Probe-request fingerprinting (`fingerprint`, `ie_order`) with randomized-MAC detection, and hidden (cloaked) SSID recovery from probe responses and (re)association requests. +- AP management-capability detection from beacons/probe responses (802.11r/k/v fast roaming, Multiple BSSID, Channel Switch Announcement) in a new `CapabilitiesAP` view. + +### Fixed +- Parsing/detection corrections: inflated WPS error count and SSID-dependent WPS 2.0 detection; MFP/PMF now read from RSN capability bits (capable vs required); `cloaked` and `firstTimeSeen` no longer clobbered when a later frame enriches an AP; EAP Success/Failure frames no longer counted as `Identity` errors. +- asyncio child-watcher crash on Python 3.14. +- Container fixes: in-container self-update (`git` now shipped) and non-root/podman writes to the bind-mounted `db.SQLITE`; plus Docker build, dependency CVEs and Codacy findings (including a SQL injection). + +### Updated +- Merged the 1:1 Security, WPS and probe-fingerprint attributes onto the `AP`/`Probe` rows, and improved the `SummaryAP` view (grouped by SSID **and** encryption, showing WPA version, PMF, every manufacturer and client counts). +- Slimmed the Docker image ~360 MB → ~220 MB (Alpine base, ship only `hcxpcapngtool`, drop build caches); amd64/arm64 builds, the full test suite and the built image now verified in CI. +- Refactored internals with no behaviour change: split the oversized `utils/wifi_db_aircrack.py` into modules behind a re-export facade and cut parser/DB cyclomatic complexity below the Codacy limit (shared `safe_insert`/`cap_runner`, lookup tables); public `parse_*` API and callers unchanged. +- Refreshed the bundled IEEE OUI / mac-vendors database (~16k new vendor prefixes). diff --git a/Dockerfile b/Dockerfile index 51a33ca..e5a2d57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,51 +1,77 @@ -# Compile hcxtools -FROM ubuntu:22.04 as hcxtools-builder +# syntax=docker/dockerfile:1 -WORKDIR /app +# --------------------------------------------------------------------------- +# Stage 1: compile hcxtools +# +# Built on the same Alpine base as the final stage so the resulting binary +# links against the exact musl / libcurl / libssl / zlib present at runtime. +# All build tooling and -dev headers stay in this stage and never reach the +# final image. +# +# wifi_db only ever calls hcxpcapngtool, so only that target is built and only +# that one binary is shipped. hcxtools 6.3.1 calls basename() without including +# ; that compiles under glibc but not under musl, so the header is +# injected before building. +# --------------------------------------------------------------------------- +FROM python:3.12-alpine AS hcxtools-builder -RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install python3-pip make git zlib1g-dev -y \ - && apt-get install pkg-config libcurl4-openssl-dev libssl-dev zlib1g-dev make gcc -y \ - && apt-get clean && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache \ + build-base git pkgconf \ + curl-dev openssl-dev zlib-dev linux-headers -# Clone hcxtools and install -RUN git clone -b 6.3.1 https://github.com/ZerBea/hcxtools.git /app/hcxtools \ - && cd /app/hcxtools \ - && make \ - && make install \ - && cd /app \ - && rm -rf /app/hcxtools +RUN git clone --depth 1 -b 6.3.1 https://github.com/ZerBea/hcxtools.git /tmp/hcxtools \ + && sed -i '1i #include ' /tmp/hcxtools/hcxpcapngtool.c \ + && make -C /tmp/hcxtools hcxpcapngtool \ + && install -m 0755 /tmp/hcxtools/hcxpcapngtool /usr/bin/hcxpcapngtool \ + && strip /usr/bin/hcxpcapngtool \ + && rm -rf /tmp/hcxtools -FROM ubuntu:22.04 +# --------------------------------------------------------------------------- +# Stage 2: final runtime image +# +# Alpine + musl keeps the image small (~230 MB, vs ~360 MB on Debian slim). +# This image is intentionally test-free: the suite is NOT run during the build +# and the test fixtures are not copied in (see .dockerignore). Tests run against +# the built image afterwards by the release pipeline / test_docker.sh, so a +# failing test blocks the release instead of every developer build. +# --------------------------------------------------------------------------- +FROM python:3.12-alpine WORKDIR /app -# Install dependencies -ENV DEBIAN_FRONTEND noninteractive -RUN DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get update && apt-get install -y --no-install-recommends python3-pip tshark git libcurl4-openssl-dev libssl-dev -y \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Copy hcxtools binaries -COPY --from=hcxtools-builder /usr/bin/hcx* /usr/bin/ +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Runtime dependencies only: tshark for pyshark, and the shared libraries the +# hcxpcapngtool binary links against (the -dev packages and their headers stay +# in the builder stage). ca-certificates is needed for the update HTTPS check, +# and git for the self-update check (adds ~7 MB: git + libexpat + +# git-init-template). --no-cache leaves no apk index behind. +RUN apk add --no-cache \ + ca-certificates git tshark libcurl libcrypto3 libssl3 zlib +# Copy only the single hcxtools binary wifi_db uses, from the builder stage. +COPY --from=hcxtools-builder /usr/bin/hcxpcapngtool /usr/bin/hcxpcapngtool -# Copy and install Python dependencies -RUN python3 -m pip install --no-cache-dir -U pip \ - && python3 -m pip install --no-cache-dir pytest - +# Install Python dependencies first so the layer is cached across code changes. +# --no-compile keeps .pyc bytecode out of the image; PYTHONDONTWRITEBYTECODE +# stops it being written at runtime too, so imports recompile on first use. COPY requirements.txt requirements.txt -RUN pip3 install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --no-compile -r requirements.txt -# Copy your application code +# Application code. The .dockerignore keeps the test fixtures, .git, the local +# virtualenv and docs out of this layer. COPY . . -# Run tests and remove test data -RUN python3 -m pytest \ - && rm -rf test_data +# Create a captures directory and a non-root user to run the app. +# /app holds the default database (db.SQLITE) so SQLite can also create its +# journal/WAL files there; both /app and /captures are owned by the user. +RUN mkdir -p /captures/ \ + && adduser -D -s /sbin/nologin wifidb \ + && chown -R wifidb:wifidb /app /captures -# Create a captures directory -RUN mkdir /captures/ +USER wifidb # Set the entry point -ENTRYPOINT ["python3", "/app/wifi_db.py", "/captures/", "-d", "/db.SQLITE"] - +ENTRYPOINT ["python3", "/app/wifi_db.py", "/captures/", "-d", "/app/db.SQLITE"] diff --git a/README.md b/README.md index bd6a3d1..0262f24 100755 --- a/README.md +++ b/README.md @@ -1,60 +1,67 @@

- + wifi_db

- - GitHub releases - - - GitHub stars - - - GitHub forks - - - GitHub issues - - - CodeFactor - - - LoC - - - GitHub license - - -
- - Docker Image - - - Docker Image dev - + Latest release + License + Last commit (master) + Last commit (dev) + Top language +

+

+ Docker Image build + Docker Image (dev) build + CodeFactor + Lines of code + Docker image size +

+ +

+ Stars + Forks + Open issues + Contributors + Docker pulls

# wifi_db -Script to parse Aircrack-ng captures into a SQLite database and extract useful information like handshakes (in 22000 hashcat format), MGT identities, interesting relations between APs, clients and it's Probes, WPS information and a global view of all the APs seen. +Script to parse Aircrack-ng captures into a SQLite database and extract useful information like handshakes (in 22000 hashcat format), enterprise (MGT) identities and EAP-MD5 challenge/response pairs, the X.509 certificates and RSN/WPA security configuration of each network, the 802.11r/k/v, Multiple BSSID and Channel Switch capabilities advertised by the APs, interesting relations between APs, clients and their Probes, WPS information, and a global view of all the APs seen. + +## Table of Contents +- [Features](#features) +- [Install](#install) +- [Usage](#usage) +- [Database](#database) +- [Views](#views) +- [TODO](#todo) +- [License](#license) ## Features - Displays if a network is cloaked (hidden) even if you have the ESSID. - Shows a detailed table of connected clients and their respective APs. -- Identifies client probes connected to APs, providing insight into potential security risks usin Rogue APs. +- Identifies client probes connected to APs, providing insight into potential security risks using Rogue APs. - Extracts handshakes for use with hashcat, facilitating password cracking. - Displays identity information from enterprise networks, including the EAP method used for authentication. -- Generates a summary of each AP group by ESSID and encryption, giving an overview of the security status of nearby networks. -- Provides a WPS info table for each AP, detailing information about the Wi-Fi Protected Setup configuration of the network. +- Extracts the X.509 certificates exchanged in enterprise (802.1X) EAP-TLS/PEAP/TTLS authentications (both AP/server and client certificates), storing all certificate fields per AP BSSID in the `Certificate` table. +- Breaks down the RSN/WPA security of each AP (WPA version, AKM suites, pairwise/group ciphers, enterprise flag, and management-frame protection) from beacons into the `AP` table. +- Captures EAP-MD5 challenge/response pairs for offline cracking (`hashcat -m 4800`) into the `EAPMD5` table. +- Detects randomized (locally administered) client MAC addresses and fingerprints clients by their probe-request information elements (stored on the `Probe` table). +- Generates a summary (`SummaryAP` view) of the APs grouped by ESSID and encryption, showing the AP and client counts, the WPA version and PMF state, and every manufacturer per group, to give a quick overview of the security status of nearby networks (and to spot SSIDs running mixed/downgraded security). +- Records the Wi-Fi Protected Setup (WPS) configuration of each AP directly on its `AP` row. - Logs all instances when a client or AP has been seen with the GPS data and timestamp, enabling location-based analysis. - Upload files with capture folder or file. This option supports the use of wildcards (*) to select multiple files or folders. - Docker version in Docker Hub to avoid dependencies. - Obfuscated mode for demonstrations and conferences. - Possibility to add static GPS data. -- Management Frame Protection (MFP) capable and required column in AP table. +- Reports the Management Frame Protection (802.11w / PMF) status of each AP: the `mfpc` (capable) and `mfpr` (required) flags read bitwise from the RSN capabilities, and the derived `pmf` state (`Required`, `Capable` when it is optional, or `Disabled`), stored on the `AP` table. +- Detects fast-roaming and management support advertised by each AP: 802.11r Fast BSS Transition (`ft_80211r`, with the `mobility_domain_id`), 802.11k Radio Resource Measurement / neighbor reports (`rrm_80211k`), and 802.11v BSS Transition Management (`bss_transition_80211v`). +- Flags access points that advertise a Multiple BSSID set (`mbssid`, with the `max_bssid_indicator`) and that send Channel Switch Announcements (`csa`, with the `csa_new_channel` target channel). +- Reveals cloaked (hidden) SSIDs from probe responses and (re)association requests, filling the AP name even when the beacon hides it (`ssid_revealed` marks names learned this way). ## Install @@ -91,30 +98,25 @@ cd .. ``` -Installation +Installation (using a virtual environment) ``` bash -git clone https://github.com/r4ulcl/wifi_db -cd wifi_db -pip3 install -r requirements.txt -``` - -##### Install using venv - -``` bash # Download repo git clone https://github.com/r4ulcl/wifi_db cd wifi_db -# Create venv +# Create and activate a venv sudo apt update ; sudo apt install python3-venv python3 -m venv wifi_db_env source wifi_db_env/bin/activate # Install dependencies -pip3 install -r requirements.txt +pip3 install -r requirements.txt ``` +> The venv must be activated (`source wifi_db_env/bin/activate`) in every new +> shell before running `wifi_db.py`. Use `deactivate` to leave it. + #### Arch Dependencies: @@ -136,19 +138,29 @@ sudo make install cd .. ``` -Installation +Installation (using a virtual environment) ``` bash +# Download repo git clone https://github.com/r4ulcl/wifi_db cd wifi_db -pip3 install -r requirements.txt + +# Create and activate a venv +python3 -m venv wifi_db_env +source wifi_db_env/bin/activate + +# Install dependencies +pip3 install -r requirements.txt ``` +> The venv must be activated (`source wifi_db_env/bin/activate`) in every new +> shell before running `wifi_db.py`. Use `deactivate` to leave it. + ## Usage -### Usage example in [WiFiChallenge Lab](https://wifichallengelab.com/) +### Usage example in [WiFiChallenge Lab](https://lab.wifichallenge.com/) - https://r4ulcl.com/posts/wifi_db-in-wifichallenge-lab/ @@ -168,11 +180,12 @@ CAPTURESFOLDER=/home/user/wifi # Output database touch db.SQLITE +chmod a+rw db.SQLITE -docker run -t -v $PWD/db.SQLITE:/db.SQLITE -v $CAPTURESFOLDER:/captures/ r4ulcl/wifi_db +docker run -t -v $PWD/db.SQLITE:/app/db.SQLITE -v $CAPTURESFOLDER:/captures/ r4ulcl/wifi_db ``` -- `-v $PWD/db.SQLITE:/db.SQLITE`: To save de output in current folder db.SQLITE file +- `-v $PWD/db.SQLITE:/app/db.SQLITE`: To save de output in current folder db.SQLITE file - `-v $CAPTURESFOLDER:/captures/`: To share the folder with the captures with the docker ![usage docker](./resources/usagedocker.png) @@ -181,7 +194,7 @@ docker run -t -v $PWD/db.SQLITE:/db.SQLITE -v $CAPTURESFOLDER:/captures/ r4ulcl/ ### Create the SQLite database using manual installation -Once the capture is created, we can create the database by importing the capture. To do this, put the name of the capture without format. +Once the capture is created, we can create the database by importing the capture. To do this, put the name of the capture without format. Remember to activate the virtual environment first (`source wifi_db_env/bin/activate`). ``` bash python3 wifi_db.py scan-01 @@ -244,23 +257,25 @@ TODO wifi_db contains several tables to store information related to wireless network traffic captured by airodump-ng. The tables are as follows: -- `AP`: This table stores information about the access points (APs) detected during the captures, including their MAC address (`bssid`), network name (`ssid`), whether the network is cloaked (`cloaked`), manufacturer (`manuf`), channel (`channel`), frequency (`frequency`), carrier (`carrier`), encryption type (`encryption`), and total packets received from this AP (`packetsTotal`). The table uses the MAC address as a primary key. +- `AP`: This table stores information about the access points (APs) detected during the captures, including their MAC address (`bssid`), network name (`ssid`), whether the network is cloaked (`cloaked`), manufacturer (`manuf`), channel (`channel`), frequency (`frequency`), carrier (`carrier`), encryption type (`encryption`), and total packets received from this AP (`packetsTotal`). It also holds the management-frame-protection capability/requirement flags (`mfpc`, `mfpr`) and the RSN/WPA security details parsed from beacons, which are 1:1 AP attributes and therefore live on the AP row itself: the negotiated `wpa_version` (WPA2, WPA3, WPA2/WPA3 transition, OWE), the authentication and key management suites (`akm_suites`, e.g. PSK, SAE, 802.1X), the `pairwise_ciphers` and `group_cipher` (CCMP-128, GCMP-256, TKIP, etc.), an `enterprise` flag set when an 802.1X AKM is present, the management-frame-protection state (`pmf`: Required, Capable or Disabled), the raw `rsn_capabilities` bitfield and its human-readable decode (`rsn_capabilities_text`, e.g. `0x00c0` → `MFPR, MFPC`). It likewise holds the Wi-Fi Protected Setup (WPS) configuration, another 1:1 AP attribute: the advertised network name (`wlan_ssid`), WPS version (`wps_version`), device name (`wps_device_name`), model name (`wps_model_name`), model number (`wps_model_number`), configuration methods (`wps_config_methods`) together with their human-readable decode (`wps_config_methods_text`, e.g. `0x218c` → `Label, PushButton, Keypad, Virtual Display PIN`), and keypad configuration methods (`wps_config_methods_keypad`). Finally it stores the 802.11 management capabilities parsed from beacons and probe responses: the 802.11r Fast BSS Transition flag (`ft_80211r`) and its `mobility_domain_id`, the 802.11k Radio Resource Measurement flag (`rrm_80211k`), the 802.11v BSS Transition Management flag (`bss_transition_80211v`), the Multiple BSSID advertisement (`mbssid`) and its `max_bssid_indicator`, the Channel Switch Announcement flag (`csa`) and its target channel (`csa_new_channel`), and `ssid_revealed`, set when the network name was recovered from a probe response / (re)association request instead of a beacon. The table uses the MAC address as a primary key. -- `Client`: This table stores information about the wireless clients detected during the captures, including their MAC address (`mac`), network name (`ssid`), manufacturer (`manuf`), device type (`type`), and total packets received from this client (`packetsTotal`). The table uses the MAC address as a primary key. +- `Client`: This table stores information about the wireless clients detected during the captures, including their MAC address (`mac`), network name (`ssid`), manufacturer (`manuf`), device type (`type`), total packets received from this client (`packetsTotal`), and a `randomized` flag set when the MAC is locally administered (a randomized/private address). The table uses the MAC address as a primary key. - `SeenClient`: This table stores information about the clients seen during the captures, including their MAC address (`mac`), time of detection (`time`), tool used to capture the data (`tool`), signal strength (`signal_rssi`), latitude (`lat`), longitude (`lon`), altitude (`alt`). The table uses the combination of MAC address and detection time as a primary key, and has a foreign key relationship with the `Client` table. - `Connected`: This table stores information about the wireless clients that are connected to an access point, including the MAC address of the access point (`bssid`) and the client (`mac`). The table uses a combination of access point and client MAC addresses as a primary key, and has foreign key relationships with both the `AP` and `Client` tables. -- `WPS`: This table stores information about access points that have Wi-Fi Protected Setup (WPS) enabled, including their MAC address (`bssid`), network name (`wlan_ssid`), WPS version (`wps_version`), device name (`wps_device_name`), model name (`wps_model_name`), model number (`wps_model_number`), configuration methods (`wps_config_methods`), and keypad configuration methods (`wps_config_methods_keypad`). The table uses the MAC address as a primary key, and has a foreign key relationship with the `AP` table. - - `SeenAp`: This table stores information about the access points seen during the captures, including their MAC address (`bssid`), time of detection (`time`), tool used to capture the data (`tool`), signal strength (`signal_rssi`), latitude (`lat`), longitude (`lon`), altitude (`alt`), and timestamp (`bsstimestamp`). The table uses the combination of access point MAC address and detection time as a primary key, and has a foreign key relationship with the `AP` table. -- `Probe`: This table stores information about the probes sent by clients, including the client MAC address (`mac`), network name (`ssid`), and time of probe (`time`). The table uses a combination of client MAC address and network name as a primary key, and has a foreign key relationship with the `Client` table. +- `Probe`: This table stores information about the probe requests sent by clients, including the client MAC address (`mac`), network name (`ssid`), and time of probe (`time`). It also carries the probe-request fingerprint, which is a 1:1 attribute of the probe and therefore lives on the Probe row: the ordered list of information elements (tags) the client included in the request (`ie_order`) and its hash (`fingerprint`), characteristic of a device model/OS, plus the source capture `file`. The fingerprint columns are populated for probe requests parsed from `.cap` files (`ssid` is empty for broadcast probe requests); probes parsed from CSV/netxml leave them empty. The table uses a combination of client MAC address and network name as a primary key, and has a foreign key relationship with the `Client` table. - `Handshake`: This table stores information about the handshakes captured during the captures, including the MAC address of the access point (`bssid`), the client (`mac`), the file name (`file`), and the hashcat format (`hashcat`). The table uses a combination of access point and client MAC addresses, and file name as a primary key, and has foreign key relationships with both the `AP` and `Client` tables. -- `Identity`: This table represents EAP (Extensible Authentication Protocol) identities and methods used in wireless authentication. The `bssid` and `mac` fields are foreign keys that reference the `AP` and `Client` tables, respectively. Other fields include the identity and method used in the authentication process. +- `Identity`: This table represents EAP (Extensible Authentication Protocol) identities and methods used in wireless authentication. The `bssid` and `mac` fields are foreign keys that reference the `AP` and `Client` tables, respectively. Other fields include the `identity` and `method` used in the authentication process, and the `realm` (the part after `@` in a `user@realm` identity, useful for the anonymous outer identities seen in PEAP/TTLS). + +- `Certificate`: This table stores the X.509 certificates exchanged in enterprise (WPA-Enterprise / 802.1X) EAP-TLS/PEAP/TTLS authentications, both the server certificate sent by the access point and the client certificate sent by the supplicant. Every row is associated with the access point through the `bssid` field (a foreign key referencing the `AP` table) and the client through the `mac` field; the `cert_type` field indicates whose certificate it is (`AP`, `Client`, or `Unknown` when the EAP direction cannot be determined). It also keeps the source capture `file`. The table stores all the relevant certificate fields: the position in the certificate chain (`cert_index`), `version`, `serial_number`, `signature_algorithm`, full `issuer` and `subject` distinguished names, validity dates (`not_before`, `not_after`), the broken-down subject (`subject_cn`, `subject_o`, `subject_ou`) and issuer (`issuer_cn`, `issuer_o`, `issuer_ou`) components, the `public_key_algorithm`, `public_key_size`, `public_key_curve` (EC) and `public_key_exponent` (RSA), and the `sha1_fingerprint` and `sha256_fingerprint`. It also extracts the most relevant X.509 extensions: Subject Alternative Names (`subject_alt_names`), `key_usage`, `ext_key_usage`, Basic Constraints (`is_ca`, `path_length`), a `self_signed` flag, the Authority and Subject Key Identifiers (`authority_key_id`, `subject_key_id`), CRL and OCSP URLs (`crl_urls`, `ocsp_urls`), and the certificate lifetime in days (`validity_days`). The table uses the combination of `bssid` and `sha256_fingerprint` as a primary key. + +- `EAPMD5`: This table stores EAP-MD5 challenge/response pairs captured from enterprise authentications. EAP-MD5 transmits a CHAP-style challenge and MD5 response in cleartext (outside any TLS tunnel), so the pair can be sniffed and cracked offline (`hashcat -m 4800` or `eapmd5pass`) to recover the password. Each row records the `bssid` (AP) and `mac` (client) foreign keys, the `identity`, the EAP identifier (`eap_id`) that links the request and response, the `challenge`, the `response`, a ready-to-use `hashcat` line, and the source `file`. It uses the combination of `bssid`, `mac`, and `eap_id` as a primary key. ## Views @@ -277,7 +292,13 @@ wifi_db contains several tables to store information related to wireless network - `IdentityAP`: This view selects the BSSID of the access point, the SSID of the access point, the MAC address of the client device that performed the identity request, the manufacturer of the client device, the identity string, and the method used for the identity request. It joins the `Identity`, `AP`, and `Client` tables on the BSSID and MAC address, respectively, and orders the results by BSSID. -- `SummaryAP`: This view selects the SSID, the count of access points broadcasting the SSID, the encryption type, the manufacturer of the access point, and whether the SSID is cloaked. It groups the results by SSID and orders them by the count of access points in descending order. +- `CertificateAP`: This view selects the BSSID and SSID of the access point together with the certificate type (`cert_type`), the subject and issuer common names, the full subject and issuer, the validity dates, the public key algorithm and size, the `self_signed` flag, and the `sha256_fingerprint`. It joins the `Certificate` and `AP` tables on the BSSID and orders the results by BSSID. + +- `SecurityAP`: This view selects the BSSID and SSID of the access point together with the `wpa_version`, the `akm_suites`, the `pairwise_ciphers`, the `group_cipher`, the `enterprise` flag, and the management-frame protection columns (`mfpc`, `mfpr`). It reads these columns directly from the `AP` table (limited to the APs that have security details) and orders the results by BSSID. + +- `CapabilitiesAP`: This view selects the BSSID and SSID of the access point together with its 802.11 management capabilities (`ft_80211r`, `mobility_domain_id`, `rrm_80211k`, `bss_transition_80211v`, `mbssid`, `max_bssid_indicator`, `csa`, `csa_new_channel`). It reads these columns directly from the `AP` table, limited to the APs that advertise at least one of them, and orders the results by BSSID. + +- `SummaryAP`: This view summarizes the access points grouped by SSID **and** encryption, so the same SSID running different security settings shows up as separate rows (handy for spotting mixed or downgraded configurations). For each group it selects the SSID, the count of distinct access points (`APs count`), the encryption type, the WPA version (`wpa_version`) and management-frame-protection state (`pmf`), every manufacturer seen in the group (`manuf`, comma separated), whether the SSID is cloaked, and the count of distinct connected clients (`Clients count`). It excludes APs with no encryption recorded and orders the results by the AP count in descending order. ## TODO diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b614fb2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +# The test suite lives in tests/; tests/conftest.py puts the project root and +# tests/ on sys.path so the top-level imports still resolve. +testpaths = tests + +# Measure coverage on every run and emit both a terminal summary and the +# Cobertura coverage.xml that Codacy consumes (config in .coveragerc). Requires +# pytest-cov (see requirements.txt). +addopts = --cov --cov-report=term-missing --cov-report=xml + +# pyshark 0.6 drives tshark through asyncio and calls the child-watcher APIs +# (get_child_watcher / set_child_watcher / SafeChildWatcher), deprecated in +# Python 3.12 and removed in 3.14. These calls live inside pyshark, not our +# code, and the 3.14 removal is already shimmed in utils/asyncio_shim.py. +# Silence only those specific third-party deprecations so the suite output +# stays readable; our own warnings are deliberately left visible. +filterwarnings = + ignore:'(SafeChildWatcher|set_child_watcher|get_child_watcher|AbstractChildWatcher)' is deprecated as of Python 3\.12:DeprecationWarning diff --git a/requirements.txt b/requirements.txt index 5d48e77..fb8ffea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ +cryptography>=48.0.1 defusedxml==0.7.1 -ftfy==6.1.3 -nest_asyncio==1.5.8 +nest_asyncio==1.6.0 +ftfy>=6.3.1 pyshark==0.6 -Requests==2.31.0 -pytest==7.4.2 \ No newline at end of file +pytest==9.1.1 +Requests==2.34.2 +pytest-cov>=6.0.0 \ No newline at end of file diff --git a/delete_from_db.py b/scripts/delete_from_db.py similarity index 59% rename from delete_from_db.py rename to scripts/delete_from_db.py index ce0bde8..903b0ff 100644 --- a/delete_from_db.py +++ b/scripts/delete_from_db.py @@ -9,6 +9,8 @@ def connectDatabase(name, verbose): '''Function to connect to the database''' database = sqlite3.connect(name) database.text_factory = str + # Enable foreign keys so ON DELETE CASCADE removes the related rows + database.execute("PRAGMA foreign_keys = 1") if verbose: print("DB connected OK") return database @@ -16,35 +18,32 @@ def connectDatabase(name, verbose): def delete_ap(database, bssid, verbose): print(bssid) -# DELETE from seenap where bssid="80:35:C1:3E:CD:8C"; -# DELETE from connected where bssid="80:35:C1:3E:CD:8C"; -# DELETE from ap where bssid="80:35:C1:3E:CD:8C"; + + # Fully static DELETE statements (no runtime string building, no + # interpolated identifiers). With foreign keys enabled, deleting from AP + # cascades to the rest, but they are deleted explicitly too so it also + # works if cascade is unavailable. bssid is always a bound parameter. + delete_statements = [ + "DELETE FROM Handshake WHERE bssid = ?", + "DELETE FROM Identity WHERE bssid = ?", + "DELETE FROM Certificate WHERE bssid = ?", + "DELETE FROM EAPMD5 WHERE bssid = ?", + "DELETE FROM SeenAp WHERE bssid = ?", + "DELETE FROM Connected WHERE bssid = ?", + "DELETE FROM AP WHERE bssid = ?", + ] try: cursor = database.cursor() + bssid = bssid.upper() - sql = "DELETE from handshake where bssid = ?" - print(sql, bssid) - cursor.execute(sql, bssid) - - sql = "DELETE from identityap where bssid = ? " - print(sql, bssid) - cursor.execute(sql, bssid) - - sql = "DELETE from seenap where bssid = ? " - print(sql, bssid) - cursor.execute(sql, bssid) - - sql = "DELETE from connected where bssid = ? " - print(sql, bssid) - cursor.execute(sql, bssid) - - sql = "DELETE from ap where bssid = ? " - print(sql, bssid) - cursor.execute(sql, bssid) + for sql in delete_statements: + if verbose: + print(sql, bssid) + cursor.execute(sql, (bssid,)) database.commit() - except sqlite3.IntegrityError as error: + except sqlite3.Error as error: print(error) diff --git a/test_docker.sh b/test_docker.sh new file mode 100755 index 0000000..ce9cb6b --- /dev/null +++ b/test_docker.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Run the wifi_db test suite against an already-built Docker image. +# +# The production image is intentionally test-free and does not contain the +# test fixtures (see .dockerignore). This script mounts test_data into the +# built image and runs pytest with the image's own Python, tshark and hcxtools. +# That way the exact artifact that would be released is what gets tested. +# +# Usage: +# ./test_docker.sh [IMAGE] +# +# IMAGE defaults to "wifi_db:test". Exits non-zero if any test fails, so it can +# gate a release (in CI) or a local build. + +set -euo pipefail + +IMAGE="${1:-wifi_db:test}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ ! -d "${SCRIPT_DIR}/test_data" ]; then + echo "ERROR: ${SCRIPT_DIR}/test_data not found; cannot run tests." >&2 + exit 1 +fi + +echo ">> Testing image: ${IMAGE}" + +# Override the entrypoint to run pytest instead of wifi_db.py. test_data is +# mounted read-only at the path the tests expect (./test_data, cwd is /app). +# +# pytest generates a Cobertura coverage.xml (see pytest.ini / .coveragerc). A +# world-writable scratch directory is mounted at /host_out so that report +# survives the --rm container; it is then moved to ./coverage.xml, ready to +# upload to Codacy: +# export CODACY_PROJECT_TOKEN= +# bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage.xml +# (run from the repo root of the checked-out branch; see +# https://docs.codacy.com/coverage-reporter/). +# +# The scratch dir (rather than mounting the repo root itself) is needed because +# the image runs as the non-root "wifidb" user, whose UID does not match the +# owner of the checkout on the host (e.g. the GitHub Actions "runner" user), +# so writing the report into a mounted repo root is denied. It lives under the +# repo root, not /tmp, because the checkout is already known to be visible to +# the Docker daemon (the test_data mount above relies on that), whereas /tmp +# is not shared with the daemon in every setup. +OUT_DIR="$(mktemp -d "${SCRIPT_DIR}/.coverage_out.XXXXXX")" +chmod 0777 "${OUT_DIR}" +trap 'rm -rf "${OUT_DIR}"' EXIT + +docker run --rm \ + --entrypoint python3 \ + -v "${SCRIPT_DIR}/test_data:/app/test_data:ro" \ + -v "${OUT_DIR}:/host_out" \ + -w /app \ + "${IMAGE}" -m pytest --cov-report=xml:/host_out/coverage.xml + +mv "${OUT_DIR}/coverage.xml" "${SCRIPT_DIR}/coverage.xml" + +echo ">> Tests passed for ${IMAGE}; coverage written to ${SCRIPT_DIR}/coverage.xml" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f5de5f5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +'''pytest bootstrap: the tests import the top-level project modules (`utils`, +`wifi_db`) and the sibling `test_base` helper. pytest loads this conftest before +importing the test modules in tests/, so put the repo root and this tests/ +directory on sys.path here, making those imports resolve no matter how pytest is +invoked (`pytest`, `python -m pytest`, from any cwd).''' +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) # tests/ +_ROOT = os.path.dirname(_HERE) # repo root +for _path in (_ROOT, _HERE): + if _path not in sys.path: + sys.path.insert(0, _path) diff --git a/tests/fake_packets.py b/tests/fake_packets.py new file mode 100644 index 0000000..909d5e9 --- /dev/null +++ b/tests/fake_packets.py @@ -0,0 +1,105 @@ +'''Lightweight pyshark stand-ins for driving the .cap parsers without tshark. + +The .cap parsers all run through utils.cap_runner.run_cap_parse, which opens a +`pyshark.FileCapture` and iterates it. These helpers let a test feed a list of +fully synthetic packets through that loop instead: `capture_patch(packets)` +replaces `pyshark.FileCapture` with a factory that yields the given packets, so +the closure-based per-packet/finalize logic in every parser is exercised +deterministically and offline. + +`FakeLayer`/`FakePkt` mimic just the pieces the parsers touch: attribute access +for scalar fields (`pkt.wlan.sa`, `mgt.wps_device_name`), `pkt['wlan.mgt']` +subscripting, and the `layer.get_field(name)` API used by the cap_common +helpers. A missing attribute raises AttributeError and a missing get_field name +raises KeyError, exactly as pyshark does, so the parsers' defensive branches +behave the same as against real captures.''' +from unittest import mock + +from utils import cap_common + + +class Field: + '''Minimal stand-in for a pyshark field: a single value, or a repeated + field when `all_values` is given (its `.all_fields` then lists one Field + per value).''' + def __init__(self, value, all_values=None): + self._value = value + if all_values is None: + self.all_fields = [self] + else: + self.all_fields = [Field(v) for v in all_values] + + def get_default_value(self): + return self._value + + def __str__(self): + return str(self._value) + + +class FakeLayer: + '''Stand-in pyshark layer. + + `attrs` are returned by attribute access (a missing name raises + AttributeError, like pyshark). `fields` maps names to Field objects for the + `get_field()` API; a name present only in `attrs` is wrapped in a Field on + demand, and an unknown name raises KeyError.''' + def __init__(self, attrs=None, fields=None): + object.__setattr__(self, '_attrs', dict(attrs or {})) + object.__setattr__(self, '_fields', dict(fields or {})) + + def __getattr__(self, name): + attrs = object.__getattribute__(self, '_attrs') + if name in attrs: + return attrs[name] + raise AttributeError(name) + + def get_field(self, name): + fields = object.__getattribute__(self, '_fields') + if name in fields: + return fields[name] + attrs = object.__getattribute__(self, '_attrs') + if name in attrs: + return Field(attrs[name]) + raise KeyError(name) + + +class FakePkt: + '''Stand-in pyshark packet: `layers` maps a layer name to a FakeLayer, + reachable both as an attribute (`pkt.wlan`) and by subscript + (`pkt['wlan.mgt']`), matching how the parsers reach each layer.''' + def __init__(self, layers): + object.__setattr__(self, '_layers', dict(layers)) + + def __getattr__(self, name): + layers = object.__getattribute__(self, '_layers') + if name in layers: + return layers[name] + raise AttributeError(name) + + def __getitem__(self, key): + layers = object.__getattribute__(self, '_layers') + if key in layers: + return layers[key] + raise KeyError(key) + + +def _factory(packets): + class _FakeCapture: + def __init__(self, *args, **kwargs): + pass + + def __iter__(self): + return iter(packets) + + def close(self): + pass + + return _FakeCapture + + +def capture_patch(packets): + '''Return a mock.patch replacing pyshark.FileCapture with a factory that + yields `packets`. Use as a context manager around a parse_* call so + run_cap_parse iterates the synthetic packets instead of opening tshark.''' + return mock.patch.object(cap_common.pyshark, 'FileCapture', + _factory(packets)) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..6549d32 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,194 @@ +import os +import datetime +import tempfile +import unittest +from unittest import mock + +from utils import database_utils + +import wifi_db + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +# Repo root (tests/ lives one level below it). Used to locate real files the +# suite hashes as sample inputs, e.g. README.md, regardless of cwd. +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Fixed validity window shared by every test certificate. +_CERT_NOT_BEFORE = datetime.datetime(2024, 1, 1) +_CERT_NOT_AFTER = datetime.datetime(2030, 1, 1) + + +def mem_db(): + '''A fresh in-memory database with the schema applied, for tests that need + a real database but not the on-disk DBTestBase lifecycle.''' + database = database_utils.connectDatabase(':memory:', False) + database_utils.createDatabase(database, False) + return database + + +def colon_hex(data): + '''Colon-separated hex of a byte string (b'\\xaa\\xbb' -> 'aa:bb'): the + form tshark prints certificate DER and SSID octet fields in.''' + return ':'.join('%02x' % b for b in data) + + +def build_self_signed(key, name, extensions=(), *, sign_hash): + '''Build a self-signed certificate (issuer == subject) over the shared + validity window and return the cryptography Certificate. `extensions` is an + iterable of (extension, critical) pairs; `sign_hash` is the signature hash + (None for Ed25519, which signs without one).''' + builder = (x509.CertificateBuilder() + .subject_name(name).issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(_CERT_NOT_BEFORE) + .not_valid_after(_CERT_NOT_AFTER)) + for extension, critical in extensions: + builder = builder.add_extension(extension, critical) + return builder.sign(key, sign_hash) + + +def run_main(argv): + '''Invoke wifi_db.main() with argv (minus the program name), patching out + the update check and vendor download and forcing tool detection off, so the + CLI runs offline. Shared by the CLI tests.''' + with mock.patch("wifi_db.sys.argv", ["wifi_db.py"] + argv), \ + mock.patch("wifi_db.update.check_for_update"), \ + mock.patch("wifi_db.oui.load_vendors", return_value={}), \ + mock.patch("wifi_db.detect_tools", return_value=(False, False)): + wifi_db.main() + + +class MemDBTempFile(unittest.TestCase): + '''In-memory database plus a throwaway on-disk file. Some inserts hash the + capture/file path, so it must really exist on disk. Subclasses set + `temp_suffix` and read self.database, self.cursor and self.path.''' + temp_suffix = '' + + def setUp(self): + self.database = mem_db() + self.cursor = self.database.cursor() + handle = tempfile.NamedTemporaryFile(suffix=self.temp_suffix, + delete=False) + handle.write(b'capture') + handle.close() + self.path = handle.name + + def tearDown(self): + self.database.close() + if os.path.exists(self.path): + os.remove(self.path) + + +def sample_cert(): + '''A parsed certificate dict as built by _extract_cert_fields, for tests.''' + return { + 'cert_index': 0, + 'version': 'v3', + 'serial_number': 'abcdef', + 'signature_algorithm': 'sha256WithRSAEncryption', + 'issuer': 'CN=Test CA,O=Test Org', + 'subject': 'CN=radius.test.local,O=Test Org', + 'not_before': '2024-01-01 00:00:00', + 'not_after': '2025-01-01 00:00:00', + 'subject_cn': 'radius.test.local', + 'subject_o': 'Test Org', + 'subject_ou': 'IT', + 'issuer_cn': 'Test CA', + 'issuer_o': 'Test Org', + 'issuer_ou': 'IT', + 'public_key_algorithm': 'RSA', + 'public_key_size': 2048, + 'public_key_curve': '', + 'public_key_exponent': '65537', + 'subject_alt_names': 'radius.test.local, 10.0.0.1', + 'key_usage': 'digitalSignature, keyEncipherment', + 'ext_key_usage': 'serverAuth', + 'is_ca': 'False', + 'path_length': None, + 'self_signed': 'False', + 'authority_key_id': 'aabbcc', + 'subject_key_id': 'ddeeff', + 'crl_urls': 'http://crl.test.local/ca.crl', + 'ocsp_urls': 'http://ocsp.test.local', + 'validity_days': 366, + 'sha1_fingerprint': '00aa11bb22cc', + 'sha256_fingerprint': '00aa11bb22cc33dd44ee', + } + + +class DBTestBase(unittest.TestCase): + def setUp(self): + self.verbose = False + self.database_name = 'test_database.db' + self.database = database_utils.connectDatabase(self.database_name, + self.verbose) + database_utils.createDatabase(self.database, self.verbose) + database_utils.createViews(self.database, self.verbose) + self.c = self.database.cursor() + self.bssid = "00:11:22:33:44:55" + self.mac = "55:44:33:22:11:00" + self.test_database_name = 'test_database.db' + self.test_database_conn = None + + def tearDown(self): + self.database.close() + if self.test_database_conn: + self.test_database_conn.close() + if os.path.exists(self.test_database_name): + os.remove(self.test_database_name) + + def insert_test_ap(self, **overrides): + '''Insert a standard test AP (self.bssid), assert success and return the + APRow field values used. Pass keyword overrides to vary a field (e.g. + ``manuf=...``) and read them back from the returned dict for assertions.''' + fields = { + 'essid': "Test_AP", 'manuf': "Test_Manufacturer", 'channel': "6", + 'freqmhz': "2437", 'carrier': "test", 'encryption': "WPA2", + 'packets_total': "10", 'lat': "37.7749", 'lon': "-122.4194", + 'cloaked': 'False', 'mfpc': 'False', 'mfpr': 'False', + 'firstTimeSeen': 0, + } + fields.update(overrides) + result = database_utils.insertAP( + self.c, self.verbose, database_utils.APRow( + bssid=self.bssid, **fields)) + self.assertEqual(result, 0) + return fields + + def insert_test_client(self, **overrides): + '''Insert a standard test Client (self.mac), assert success and return + the ClientRow field values used. Pass keyword overrides (e.g. + ``ssid=...``) and read them back from the returned dict for assertions.''' + fields = { + 'ssid': "", 'manuf': "Test_Manufacturer", 'client_type': "10", + 'packets_total': "-70", 'device': "Misc", 'firstTimeSeen': 0, + } + fields.update(overrides) + result = database_utils.insertClients( + self.c, self.verbose, database_utils.ClientRow( + mac=self.mac, **fields)) + self.assertEqual(result, 0) + return fields + + def insert_test_handshake(self): + '''Insert a handshake (self.bssid/self.mac) referencing README.md, + assert success and return the file path used.''' + path = os.path.join(PROJECT_ROOT, "README.md") + result = database_utils.insertHandshake(self.c, self.verbose, + self.bssid, self.mac, path) + self.assertEqual(result, 0) + return path + + @staticmethod + def _make_cert_hex(cn): + '''Build a real self-signed cert and return its colon-separated hex + DER, exactly as `tshark -e tls.handshake.certificate` emits it.''' + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + cert = build_self_signed(key, name, sign_hash=hashes.SHA256()) + return colon_hex(cert.public_bytes(serialization.Encoding.DER)) diff --git a/tests/test_cap_common.py b/tests/test_cap_common.py new file mode 100644 index 0000000..732dbaa --- /dev/null +++ b/tests/test_cap_common.py @@ -0,0 +1,153 @@ +'''Tests for the pure pyshark-field helpers in utils/cap_common.py. + +These take plain layer/packet stand-ins (no tshark), so they exercise the +field extraction, SSID decoding and suite-name mapping used by every .cap +parser without opening a capture.''' +import unittest + +from utils import cap_common + + +class _Field: + '''Minimal stand-in for a pyshark field object.''' + def __init__(self, value, all_values=None): + self._value = value + if all_values is None: + self.all_fields = [self] + else: + self.all_fields = [_Field(v) for v in all_values] + + def get_default_value(self): + return self._value + + def __str__(self): + return str(self._value) + + +class _Layer: + '''Stand-in pyshark layer: get_field(name) returns a preset _Field.''' + def __init__(self, fields): + self._fields = fields + + def get_field(self, name): + field = self._fields.get(name) + if field is None: + raise KeyError(name) + return field + + +class TestScalarHelpers(unittest.TestCase): + def test_safe(self): + self.assertEqual(cap_common._safe(lambda: 1 / 0, "fallback"), + "fallback") + self.assertEqual(cap_common._safe(lambda: "ok"), "ok") + + def test_to_int(self): + self.assertEqual(cap_common._to_int("10"), 10) + self.assertEqual(cap_common._to_int("ff", 16), 255) + self.assertEqual(cap_common._to_int(7), 7) + self.assertIsNone(cap_common._to_int("nope")) + self.assertIsNone(cap_common._to_int(None)) + + def test_dedupe_preserves_order(self): + self.assertEqual(cap_common._dedupe(["a", "b", "a", "c", "b"]), + ["a", "b", "c"]) + + def test_suite_name(self): + mapping = {"2": "WPA2"} + self.assertEqual(cap_common._suite_name("2", mapping), "WPA2") + self.assertEqual(cap_common._suite_name(2, mapping), "WPA2") + # Unknown selector falls back to its own string form. + self.assertEqual(cap_common._suite_name("99", mapping), "99") + self.assertEqual(cap_common._suite_name(None, mapping), "None") + + def test_field_is_set(self): + for truthy in ("1", "true", "TRUE", "yes", " Yes "): + self.assertTrue(cap_common._field_is_set(truthy)) + for falsy in ("0", "false", "no", ""): + self.assertFalse(cap_common._field_is_set(falsy)) + + +class TestFieldExtraction(unittest.TestCase): + def test_field_value_present_missing_and_error(self): + layer = _Layer({"present": _Field("v"), "empty": _Field(None)}) + self.assertEqual(cap_common._field_value(layer, "present"), "v") + # Field whose value is None normalises to ''. + self.assertEqual(cap_common._field_value(layer, "empty"), "") + # get_field raising (unknown name) also yields ''. + self.assertEqual(cap_common._field_value(layer, "missing"), "") + + def test_first_field_value(self): + layer = _Layer({"a": _Field(None), "b": _Field("second")}) + self.assertEqual( + cap_common._first_field_value(layer, ["a", "b"]), "second") + self.assertEqual( + cap_common._first_field_value(layer, ["missing"]), "") + + def test_all_field_values(self): + layer = _Layer({"tags": _Field(None, all_values=["1", "", "2", None])}) + # Empty and None entries are dropped. + self.assertEqual( + cap_common._all_field_values(layer, "tags"), ["1", "2"]) + self.assertEqual(cap_common._all_field_values(layer, "missing"), []) + + def test_mgt_tag_numbers(self): + layer = _Layer({"wlan_tag_number": + _Field(None, all_values=["0", "1", "bad", "48"])}) + self.assertEqual(cap_common._mgt_tag_numbers(layer), {0, 1, 48}) + + +class TestSsidDecoding(unittest.TestCase): + def test_plaintext(self): + layer = _Layer({"wlan_ssid": _Field("MyNetwork")}) + self.assertEqual(cap_common._ssid_from_mgt(layer), "MyNetwork") + + def test_hidden(self): + layer = _Layer({"wlan_ssid": _Field("")}) + self.assertEqual(cap_common._ssid_from_mgt(layer), "") + + def test_hex_encoded(self): + raw = ":".join("%02x" % b for b in b"HexSSID") + layer = _Layer({"wlan_ssid": _Field(raw)}) + self.assertEqual(cap_common._ssid_from_mgt(layer), "HexSSID") + + def test_hex_nul_padding_stripped(self): + raw = ":".join("%02x" % b for b in b"AP\x00\x00") + layer = _Layer({"wlan_ssid": _Field(raw)}) + self.assertEqual(cap_common._ssid_from_mgt(layer), "AP") + + +class _Pkt: + def __init__(self, sa=None, mgt=None): + if sa is not None: + self.wlan = type("W", (), {"sa": sa})() + self._mgt = mgt + + def __getitem__(self, key): + if key == "wlan.mgt" and self._mgt is not None: + return self._mgt + raise KeyError(key) + + +class TestPacketHelpers(unittest.TestCase): + def test_pkt_bssid_mgt(self): + mgt = _Layer({}) + bssid, layer = cap_common._pkt_bssid_mgt(_Pkt(sa="AA", mgt=mgt)) + self.assertEqual(bssid, "AA") + self.assertIs(layer, mgt) + # No wlan layer at all -> (None, None), never raises. + self.assertEqual(cap_common._pkt_bssid_mgt(_Pkt()), (None, None)) + + def test_seen_or_invalid(self): + seen = {"AA:BB:CC:DD:EE:FF"} + mgt = _Layer({}) + self.assertTrue(cap_common._seen_or_invalid(None, mgt, seen)) + self.assertTrue(cap_common._seen_or_invalid("AA", None, seen)) + self.assertTrue(cap_common._seen_or_invalid( + "aa:bb:cc:dd:ee:ff", mgt, seen)) + self.assertFalse(cap_common._seen_or_invalid( + "11:22:33:44:55:66", mgt, seen)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_capture_pipeline.py b/tests/test_capture_pipeline.py new file mode 100644 index 0000000..0d1cb4d --- /dev/null +++ b/tests/test_capture_pipeline.py @@ -0,0 +1,163 @@ +'''Tests for the capture ingestion pipeline in utils/capture_pipeline.py: +file discovery, folder walking, source handling and the +insert/parse/mark-processed dispatch. + +The capture parsers themselves are exercised by test_parsers/test_realdata; +here they are patched out so the tests only drive the wiring around them.''' +import os +import tempfile +import unittest +from unittest import mock + +from utils import capture_pipeline +from utils import database_utils + + +def _make_ctx(database=None, verbose=False, force=False): + '''A Context with harmless defaults for tests that never parse.''' + return capture_pipeline.Context( + ouiMap={}, database=database, verbose=verbose, fake_lat="", + fake_lon="", hcxpcapngtool=False, tshark=False, force=force) + + +class TestCaptureDiscovery(unittest.TestCase): + def test_collect_capture_files(self): + with tempfile.TemporaryDirectory() as folder: + names = ["a.cap", "b.csv", "c.kismet.csv", "d.kismet.netxml", + "e.log.csv", "readme.txt"] + for name in names: + open(os.path.join(folder, name), "w").close() + files = capture_pipeline.collect_capture_files(folder) + self.assertNotIn("readme.txt", files) + self.assertEqual(sorted(files), sorted(names[:-1])) + # Reverse-sorted so the .cap files end up processed last. + self.assertEqual(files[-1], "a.cap") + + def test_process_folder_absolute(self): + ctx = _make_ctx(verbose=True) + with tempfile.TemporaryDirectory() as folder: + open(os.path.join(folder, "one.csv"), "w").close() + open(os.path.join(folder, "two.csv"), "w").close() + with mock.patch( + "utils.capture_pipeline.process_capture") as process: + capture_pipeline.process_folder(ctx, folder) + self.assertEqual(process.call_count, 2) + + def test_process_folder_relative(self): + ctx = _make_ctx() + with tempfile.TemporaryDirectory() as folder: + open(os.path.join(folder, "one.csv"), "w").close() + parent, name = os.path.split(folder) + with mock.patch("utils.capture_pipeline.os.getcwd", + return_value=parent), \ + mock.patch( + "utils.capture_pipeline.process_capture") as process: + capture_pipeline.process_folder(ctx, name) + process.assert_called_once_with( + ctx, os.path.join(parent, name, "one.csv")) + + +class TestHandleCapture(unittest.TestCase): + def test_aircrack_folder(self): + with tempfile.TemporaryDirectory() as folder, \ + mock.patch("utils.capture_pipeline.process_folder") as process: + capture_pipeline.handle_capture(_make_ctx(), folder, "aircrack-ng") + process.assert_called_once() + + def test_aircrack_file(self): + with mock.patch( + "utils.capture_pipeline.process_capture") as process: + capture_pipeline.handle_capture( + _make_ctx(), "some.csv", "aircrack-ng") + process.assert_called_once() + + def test_kismet_and_wigle_are_stubs(self): + # Both sources are TODO: they must not touch the parse pipeline. + with mock.patch("utils.capture_pipeline.process_capture") as process, \ + mock.patch("utils.capture_pipeline.process_folder") as folder: + capture_pipeline.handle_capture(_make_ctx(), "x", "kismet") + capture_pipeline.handle_capture(_make_ctx(), "x", "wigle") + process.assert_not_called() + folder.assert_not_called() + + +class TestIngestAndProcess(unittest.TestCase): + '''ingest_capture/process_capture against a real (temporary) database, + with run_parser patched out so no capture file is actually parsed.''' + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = os.path.join(self.tmpdir.name, "test.db") + self.database = database_utils.connectDatabase(db_path, False) + database_utils.createDatabase(self.database, False) + self.ctx = _make_ctx(database=self.database) + + def tearDown(self): + self.database.close() + self.tmpdir.cleanup() + + def _capture_file(self, name): + '''Create a real capture file: ingest_capture MD5-hashes its path.''' + path = os.path.join(self.tmpdir.name, name) + with open(path, "w") as handle: + handle.write("data\n") + return path + + def test_run_parser_unknown_name(self): + # Unknown parser names dispatch to nothing (and must not raise). + capture_pipeline.run_parser(self.ctx, "no-such-parser", "x.csv") + + def test_ingest_capture_marks_processed(self): + capture = self._capture_file("a.csv") + with mock.patch("utils.capture_pipeline.run_parser") as parser: + capture_pipeline.ingest_capture( + self.ctx, "csv", capture, announce=True) + parser.assert_called_once() + cursor = self.database.cursor() + self.assertEqual(database_utils.checkFileProcessed( + cursor, False, capture), 1) + + def test_ingest_capture_skips_processed(self): + capture = self._capture_file("a.csv") + with mock.patch("utils.capture_pipeline.run_parser"): + capture_pipeline.ingest_capture(self.ctx, "csv", capture) + with mock.patch("utils.capture_pipeline.run_parser") as parser: + capture_pipeline.ingest_capture(self.ctx, "csv", capture) + parser.assert_not_called() + + def test_ingest_capture_force_reprocesses(self): + capture = self._capture_file("a.csv") + with mock.patch("utils.capture_pipeline.run_parser"): + capture_pipeline.ingest_capture(self.ctx, "csv", capture) + self.ctx.force = True + with mock.patch("utils.capture_pipeline.run_parser") as parser: + capture_pipeline.ingest_capture(self.ctx, "csv", capture) + parser.assert_called_once() + + def test_process_capture_known_extension(self): + with mock.patch("utils.capture_pipeline.ingest_capture") as ingest: + capture_pipeline.process_capture(self.ctx, "sample.kismet.netxml") + ingest.assert_called_once_with(self.ctx, "netxml", + "sample.kismet.netxml") + + def test_process_capture_skips_processed(self): + capture = self._capture_file("b.csv") + with mock.patch("utils.capture_pipeline.run_parser"): + capture_pipeline.ingest_capture(self.ctx, "csv", capture) + with mock.patch("utils.capture_pipeline.ingest_capture") as ingest: + capture_pipeline.process_capture(self.ctx, capture) + ingest.assert_not_called() + + def test_process_capture_fallback_formats(self): + # No recognised extension: every fallback suffix is attempted (and a + # trailing "." is stripped first). + with mock.patch("utils.capture_pipeline.ingest_capture") as ingest: + capture_pipeline.process_capture(self.ctx, "noext.") + suffixes = [call.args[2] for call in ingest.call_args_list] + self.assertEqual(len(suffixes), len(capture_pipeline.FALLBACK_FORMATS)) + for suffix, _name in capture_pipeline.FALLBACK_FORMATS: + self.assertIn("noext" + suffix, suffixes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ad5f6d6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,92 @@ +'''Tests for the wifi_db.py CLI plumbing: argument parsing, tool detection +and main() itself. + +The capture pipeline main() drives (file discovery, dispatch, parsing) lives +in utils/capture_pipeline.py and is tested in test_capture_pipeline.py. +Network-touching pieces (update check, vendor download) are patched out here.''' +import os +import tempfile +import unittest +from unittest import mock + +import wifi_db + +from test_base import run_main + + +class TestSmallHelpers(unittest.TestCase): + def test_banner_and_version(self): + # Pure prints; just make sure they run. + wifi_db.banner() + wifi_db.printVersion() + + def test_replace_multiple_slashes(self): + self.assertEqual(wifi_db.replace_multiple_slashes("/a//b///c"), + "/a/b/c") + self.assertEqual(wifi_db.replace_multiple_slashes("a/b"), "a/b") + + def test_build_arg_parser(self): + parser = wifi_db.build_arg_parser() + args = parser.parse_args(["-v", "--debug", "-o", "-f", + "-t", "1.0", "-n", "2.0", + "--source", "kismet", + "-d", "x.db", "cap1", "cap2"]) + self.assertTrue(args.verbose) + self.assertTrue(args.debug) + self.assertTrue(args.obfuscated) + self.assertTrue(args.force) + self.assertEqual(args.lat, "1.0") + self.assertEqual(args.lon, "2.0") + self.assertEqual(args.source, "kismet") + self.assertEqual(args.database, "x.db") + self.assertEqual(args.capture, ["cap1", "cap2"]) + + +class TestToolDetection(unittest.TestCase): + def test_tool_available(self): + with mock.patch("wifi_db.subprocess.call", return_value=0): + self.assertTrue(wifi_db._tool_available("sometool")) + + def test_tool_available_windows(self): + # On Windows the lookup command is "where" instead of "which". + with mock.patch("wifi_db.platform.system", + return_value="Windows"), \ + mock.patch("wifi_db.subprocess.call", return_value=0) as call: + self.assertTrue(wifi_db._tool_available("sometool")) + call.assert_called_once_with(["where", "sometool"]) + + def test_tool_lookup_fails(self): + with mock.patch("wifi_db.subprocess.call", side_effect=OSError): + self.assertFalse(wifi_db._tool_available("sometool")) + + def test_detect_tools(self): + with mock.patch("wifi_db._tool_available", + side_effect=[True, False]): + self.assertEqual(wifi_db.detect_tools(), (True, False)) + + +class TestMain(unittest.TestCase): + '''main() end to end with a real temporary database and an (empty) + capture folder; the update check and vendor download are patched out + (see test_base.run_main).''' + + def test_version_exits(self): + with self.assertRaises(SystemExit): + run_main(["--version"]) + + def test_missing_capture_exits(self): + with self.assertRaises(SystemExit): + run_main([]) + + def test_full_run(self): + with tempfile.TemporaryDirectory() as workdir: + db_path = os.path.join(workdir, "out.db") + captures = os.path.join(workdir, "captures") + os.mkdir(captures) + # Trailing slash and doubled slash both get normalised. + run_main(["--debug", "-o", "-d", db_path, captures + "//"]) + self.assertTrue(os.path.exists(db_path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_eap_parsers.py b/tests/test_eap_parsers.py new file mode 100644 index 0000000..348c382 --- /dev/null +++ b/tests/test_eap_parsers.py @@ -0,0 +1,156 @@ +'''Tests for the EAP identity and EAP-MD5 logic in utils/eap_parsers.py, +driven by fake pyshark packets. + +The per-packet callbacks are pure state machines once the packet fields are +supplied, so plain stand-in objects exercise them without tshark. Database +inserts are mocked. The probe-fingerprint parser lives in the same module but +is tested separately in test_probe_fingerprint.py to keep each file small.''' +import unittest +from unittest import mock + +from utils import eap_parsers + + +class _Eap: + '''Stand-in pyshark eap layer. Only the fields passed in are set, so a + packet missing a field raises AttributeError just like the real one.''' + # Constructor kwarg -> attribute name (pyshark exposes .type and .id). + _ALIASES = {"etype": "type", "eap_id": "id"} + + def __init__(self, **fields): + for name, value in fields.items(): + setattr(self, self._ALIASES.get(name, name), value) + + +class _Wlan: + def __init__(self, da=None, sa=None): + self.da = da + self.sa = sa + + +class _Pkt: + def __init__(self, eap, wlan=None): + self.eap = eap + if wlan is not None: + self.wlan = wlan + + +class TestIdentityForPkt(unittest.TestCase): + def _call(self, pkt, state=("", "", "", "")): + cursor = mock.Mock() + with mock.patch("utils.eap_parsers.database_utils.insertIdentity") \ + as insert: + errors, new_state = eap_parsers._identity_for_pkt( + cursor, False, pkt, state) + return errors, new_state, insert + + def test_success_and_failure_frames_skipped(self): + # EAP Success (3) / Failure (4) carry no Type field: skip, no error. + for code in ("3", "4"): + errors, state, insert = self._call(_Pkt(_Eap(code=code))) + self.assertEqual(errors, 0) + insert.assert_not_called() + + def test_identity_response_records_identity(self): + pkt = _Pkt(_Eap(code="2", etype="1", identity="alice"), + _Wlan(da="AP", sa="CLIENT")) + errors, state, insert = self._call(pkt) + self.assertEqual(errors, 0) + self.assertEqual(state, ("AP", "CLIENT", "alice", "")) + insert.assert_not_called() + + def test_identity_response_missing_field(self): + # code 2 identity request whose .identity raises is counted once. + eap = _Eap(code="2", etype="1") + pkt = _Pkt(eap, _Wlan(da="AP", sa="CLIENT")) + errors, state, insert = self._call(pkt) + self.assertEqual(errors, 1) + + def test_method_packet_inserts(self): + # A non-identity EAP type stores the method against the last identity. + pkt = _Pkt(_Eap(code="1", etype="13")) # 13 = EAP-TLS + errors, state, insert = self._call( + pkt, state=("AP", "CLIENT", "alice", "")) + self.assertEqual(errors, 0) + self.assertEqual(state[3], "EAP-TLS") + insert.assert_called_once() + + def test_unknown_method_type(self): + pkt = _Pkt(_Eap(code="1", etype="250")) + errors, state, insert = self._call(pkt) + self.assertIn("OTHER (UNKNOWN EAP METHOD)", state[3]) + self.assertIn("250", state[3]) + + +class TestEapMd5(unittest.TestCase): + def test_packet_parsing(self): + pkt = _Pkt(_Eap(code="1", eap_id="5", md5_value="aa:bb:cc"), + _Wlan(da="AP", sa="CLIENT")) + parsed = eap_parsers._eap_md5_packet(pkt) + self.assertEqual(parsed, ("1", "5", "CLIENT", "AP", "aabbcc")) + + def test_packet_parsing_empty_value(self): + pkt = _Pkt(_Eap(code="1", eap_id="5", md5_value=""), + _Wlan(da="AP", sa="CLIENT")) + self.assertIsNone(eap_parsers._eap_md5_packet(pkt)) + + def test_packet_parsing_missing_field(self): + # No md5_value attribute at all -> None (the except branch). + pkt = _Pkt(_Eap(code="1"), _Wlan(da="AP", sa="CLIENT")) + self.assertIsNone(eap_parsers._eap_md5_packet(pkt)) + + def test_hashcat_line_hex_id(self): + # eap_id is decimal from pyshark; the hashcat line hex-encodes it. + line = eap_parsers._eap_md5_hashcat("5", "challenge", "response") + self.assertEqual(line, "response:challenge:05") + + def test_hashcat_line_non_numeric_id(self): + line = eap_parsers._eap_md5_hashcat("zz", "chal", "resp") + self.assertEqual(line, "resp:chal:zz") + + def _for_pkt(self, pkt, challenges): + cursor = mock.Mock() + with mock.patch( + "utils.eap_parsers.database_utils.insertEAPMD5", + return_value=0) as insert: + errors = eap_parsers._eap_md5_for_pkt( + cursor, False, pkt, challenges, "file.cap") + return errors, insert + + def test_request_then_response_pairs_up(self): + challenges = {} + request = _Pkt(_Eap(code="1", eap_id="7", md5_value="ab:cd"), + _Wlan(da="CLIENT", sa="AP")) + errors, insert = self._for_pkt(request, challenges) + self.assertEqual(errors, 0) + insert.assert_not_called() + self.assertEqual(challenges[("AP", "CLIENT", "7")], "abcd") + + response = _Pkt(_Eap(code="2", eap_id="7", md5_value="ef:01"), + _Wlan(da="AP", sa="CLIENT")) + errors, insert = self._for_pkt(response, challenges) + self.assertEqual(errors, 0) + insert.assert_called_once() + + def test_response_without_challenge_ignored(self): + response = _Pkt(_Eap(code="2", eap_id="9", md5_value="ef:01"), + _Wlan(da="AP", sa="CLIENT")) + errors, insert = self._for_pkt(response, {}) + self.assertEqual(errors, 0) + insert.assert_not_called() + + def test_other_codes_ignored(self): + pkt = _Pkt(_Eap(code="4", eap_id="1", md5_value="aa"), + _Wlan(da="AP", sa="CLIENT")) + errors, insert = self._for_pkt(pkt, {}) + self.assertEqual(errors, 0) + insert.assert_not_called() + + def test_unparseable_packet_ignored(self): + pkt = _Pkt(_Eap(code="1"), _Wlan(da="AP", sa="CLIENT")) + errors, insert = self._for_pkt(pkt, {}) + self.assertEqual(errors, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gaps_cap.py b/tests/test_gaps_cap.py new file mode 100644 index 0000000..76e0fa4 --- /dev/null +++ b/tests/test_gaps_cap.py @@ -0,0 +1,402 @@ +'''Coverage for the .cap parser branches that the real test-01.cap capture +never exercises: verbose logging, the WPS / MFP / capability / hidden-SSID +frame types absent from the sample capture, EAP error handling and the +hcxpcapngtool 22000-hash extraction. Every parser is driven through +run_cap_parse with synthetic packets (see tests/fake_packets.py), so no tshark +or real capture is needed.''' +import os +import tempfile +import types +import unittest +from unittest import mock + +from utils import cap_common, cap_runner, cap_parsers +from utils import beacon_parsers, security_parsers, eap_parsers + +from fake_packets import Field, FakeLayer, FakePkt, capture_patch +from test_base import MemDBTempFile, colon_hex + + +class CapGapBase(MemDBTempFile): + # Some inserts (handshake, hcxpcapngtool) hash the capture path, so + # MemDBTempFile writes a real .cap file on disk at self.path. + temp_suffix = '.cap' + + +# -------------------------------------------------------------------------- +# cap_common: the defensive except-branches and the tshark unraisable hook. +# -------------------------------------------------------------------------- +class TestCapCommonEdges(unittest.TestCase): + def test_quiet_unraisablehook_swallows_tshark_crash(self): + crash = cap_common.pyshark.capture.capture.TSharkCrashException("boom") + with mock.patch.object(cap_common, '_default_unraisablehook') as dflt: + cap_common._quiet_tshark_unraisablehook( + types.SimpleNamespace(exc_value=crash)) + dflt.assert_not_called() + + def test_quiet_unraisablehook_defers_other_errors(self): + with mock.patch.object(cap_common, '_default_unraisablehook') as dflt: + unraisable = types.SimpleNamespace(exc_value=ValueError("real")) + cap_common._quiet_tshark_unraisablehook(unraisable) + dflt.assert_called_once_with(unraisable) + + def test_all_field_values_falls_back_to_str(self): + class _BadField: + # Iterating .all_fields blows up; the helper falls back to str(). + @property + def all_fields(self): + raise RuntimeError("no sub-fields") + + def __str__(self): + return "raw-value" + + layer = FakeLayer(fields={'x': _BadField()}) + self.assertEqual(cap_common._all_field_values(layer, 'x'), + ["raw-value"]) + + def test_field_value_swallows_get_default_value_error(self): + class _BadField: + all_fields = [] + + def get_default_value(self): + raise RuntimeError("boom") + + layer = FakeLayer(fields={'x': _BadField()}) + self.assertEqual(cap_common._field_value(layer, 'x'), '') + + def test_ssid_from_mgt_non_hex_colon_value(self): + # A value containing ':' that is not valid hex must not raise; the raw + # text is returned instead. + layer = FakeLayer(attrs={'wlan_ssid': 'zz:zz'}) + self.assertEqual(cap_common._ssid_from_mgt(layer), 'zz:zz') + + def test_field_value_none_field(self): + # get_field returning None (an absent field on a real pyshark layer) + # normalises to ''. + class _NoneFieldLayer: + def get_field(self, _name): + return None + + self.assertEqual(cap_common._field_value(_NoneFieldLayer(), 'x'), '') + + +# -------------------------------------------------------------------------- +# cap_runner: a per-packet error logged in verbose mode. +# -------------------------------------------------------------------------- +class TestCapRunnerVerboseError(CapGapBase): + def test_per_pkt_exception_logged_and_counted(self): + def boom(_cursor, _pkt): + raise ValueError("bad packet") + + # verbose=True prints the error; verbose=False takes the other branch. + for verbose in (True, False): + with capture_patch([FakePkt({})]): + errors = cap_runner.run_cap_parse( + self.database, self.path, verbose, "X", "flt", boom, + catch_pkt_errors=True) + self.assertEqual(errors, 1) + + +# -------------------------------------------------------------------------- +# cap_parsers +# -------------------------------------------------------------------------- +def _eapol_pkt(ta, da, key_info, type_='3'): + eapol = FakeLayer(attrs={'field_names': ['type', 'keydes'], + 'type': type_, + 'wlan_rsna_keydes_key_info': key_info}) + return FakePkt({'eapol': eapol, 'wlan': FakeLayer(attrs={'ta': ta, + 'da': da})}) + + +def _mfp_pkt(rsn_caps, ta='AA:BB:CC:00:00:01'): + return FakePkt({'wlan': FakeLayer(attrs={'ta': ta}), + 'wlan.mgt': FakeLayer(attrs={ + 'wlan_rsn_capabilities': rsn_caps})}) + + +def _wps_pkt(sa='AA:BB:CC:DD:EE:FF'): + ssid_hex = colon_hex(b'TestAP') + mgt = FakeLayer(attrs={ + 'wlan_ssid': ssid_hex, 'wps_ext_version2': '20', + 'wps_device_name': 'Router1', 'wps_model_name': 'ModelX', + 'wps_model_number': 'N100', 'wps_config_methods': '0x0088', + 'wps_config_methods_keypad': '1'}) + return FakePkt({'wlan': FakeLayer(attrs={'sa': sa}), 'wlan.mgt': mgt}) + + +class TestParseCapDispatch(CapGapBase): + def test_parse_cap_no_tools_is_noop(self): + # tshark False and hcxpcapngtool False: neither branch runs. + cap_parsers.parse_cap(self.path, self.database, False, + hcxpcapngtool=False, tshark=False) + + +def _both_verbose(parser, packets, database, capture): + '''Run a parser through the synthetic packets at both verbosity levels so + each `if verbose:` branch (and the verbose=False path realdata would take) + is exercised. `seen` is per-call, so the second pass re-runs cleanly.''' + for verbose in (True, False): + with capture_patch(packets): + parser(capture, database, verbose) + + +class TestParseHandshakes(CapGapBase): + def test_valid_and_unmatched_handshakes(self): + ap, client = 'AA:BB:CC:00:00:AA', 'AA:BB:CC:00:00:CC' + packets = [ + # a non-EAPOL-Key frame (type != '3') is skipped. + _eapol_pkt(ap, client, 'key_info', type_='1'), + # message-1 (from AP): remembered, find('10a') == -1 branch. + _eapol_pkt(ap, client, 'key_info_08a'), + # message-2 (from client): matches -> "Valid handshake" + insert. + _eapol_pkt(client, ap, 'key_info_10a'), + # another '10a' frame that does not match the stored message-1. + _eapol_pkt('AA:BB:CC:00:00:11', 'AA:BB:CC:00:00:22', + 'key_info_10a'), + ] + _both_verbose(cap_parsers.parse_handshakes, packets, + self.database, self.path) + row = self.cursor.execute( + "SELECT bssid, mac FROM Handshake").fetchone() + self.assertEqual(row, (ap, client)) + + +class TestParseMFP(CapGapBase): + def test_mfp_variants(self): + packets = [ + _mfp_pkt(''), # empty caps -> early return (line 78) + _mfp_pkt('0x0001'), # neither MFPC nor MFPR -> return (line 89) + _mfp_pkt('0x00c0'), # MFPC+MFPR set -> verbose + insertMFP + ] + _both_verbose(cap_parsers.parse_MFP, packets, + self.database, self.path) + row = self.cursor.execute( + "SELECT mfpc, mfpr FROM AP WHERE bssid = ?", + ('AA:BB:CC:00:00:01',)).fetchone() + self.assertEqual(row, ('True', 'True')) + + +class TestParseWPS(CapGapBase): + def test_wps_beacon_merged_onto_ap(self): + packets = [ + _wps_pkt(), + # a frame with no wlan.sa yields an empty bssid: per_pkt skips it. + FakePkt({'wlan': FakeLayer(attrs={}), + 'wlan.mgt': FakeLayer(attrs={'wlan_ssid': ''})}), + ] + _both_verbose(cap_parsers.parse_WPS, packets, + self.database, self.path) + row = self.cursor.execute( + "SELECT wps_device_name, wps_version FROM AP WHERE bssid = ?", + ('AA:BB:CC:DD:EE:FF',)).fetchone() + self.assertEqual(row, ('Router1', '2.0')) + + def test_wps_fields_for_pkt_directly(self): + bssid, fields = cap_parsers._wps_fields_for_pkt(_wps_pkt()) + self.assertEqual(bssid, 'AA:BB:CC:DD:EE:FF') + self.assertEqual(fields['wlan_ssid'], 'TestAP') + self.assertEqual(fields['wps_version'], '2.0') + + +# -------------------------------------------------------------------------- +# beacon_parsers +# -------------------------------------------------------------------------- +class TestBeaconParsers(CapGapBase): + def test_capabilities_insert(self): + # tag 54 = Mobility Domain -> 802.11r advertised, so the row is stored. + # A frame without any capability tag returns early (no row). + def cap_pkt(sa): + return FakePkt( + {'wlan': FakeLayer(attrs={'sa': sa}), + 'wlan.mgt': FakeLayer(fields={'wlan_tag_number': Field( + None, all_values=['54'])})}) + + no_cap = FakePkt( + {'wlan': FakeLayer(attrs={'sa': 'AA:BB:CC:00:0F:02'}), + 'wlan.mgt': FakeLayer(fields={'wlan_tag_number': Field( + None, all_values=['0'])})}) + # A repeated BSSID is skipped by the per-AP `seen` guard. + packets = [cap_pkt('AA:BB:CC:00:0F:01'), no_cap, + cap_pkt('AA:BB:CC:00:0F:01')] + _both_verbose(beacon_parsers.parse_capabilities, packets, + self.database, self.path) + row = self.cursor.execute( + "SELECT ft_80211r FROM AP WHERE bssid = ?", + ('AA:BB:CC:00:0F:01',)).fetchone() + self.assertEqual(row, ('True',)) + + def test_hidden_ssid_empty_and_revealed(self): + empty = FakePkt({ + 'wlan': FakeLayer(attrs={'bssid': 'AA:BB:CC:00:0E:01'}), + 'wlan.mgt': FakeLayer(attrs={'wlan_ssid': ''})}) + + def revealed(bssid): + return FakePkt({ + 'wlan': FakeLayer(attrs={'bssid': bssid}), + 'wlan.mgt': FakeLayer(attrs={'wlan_ssid': 'RevealedNet'})}) + + # The repeated BSSID is skipped by the per-AP `seen` guard. + packets = [empty, revealed('AA:BB:CC:00:0E:02'), + revealed('AA:BB:CC:00:0E:02')] + _both_verbose(beacon_parsers.parse_hidden_ssid, packets, + self.database, self.path) + row = self.cursor.execute( + "SELECT ssid FROM AP WHERE bssid = ?", + ('AA:BB:CC:00:0E:02',)).fetchone() + self.assertEqual(row, ('RevealedNet',)) + + +# -------------------------------------------------------------------------- +# security_parsers +# -------------------------------------------------------------------------- +class TestSecurityParsers(CapGapBase): + def test_security_with_and_without_akm_verbose(self): + def akm_pkt(sa, rsn_caps): + return FakePkt({ + 'wlan': FakeLayer(attrs={'sa': sa}), + 'wlan.mgt': FakeLayer( + attrs={'wlan_rsn_capabilities': rsn_caps}, + fields={'wlan_rsn_akms_type': Field( + None, all_values=['2'])})}) + + valid = akm_pkt('AA:BB:CC:00:5E:01', '0x00c0') # MFP set -> insertMFP + # AKM present but no MFP bits -> the insertMFP branch is skipped. + no_mfp = akm_pkt('AA:BB:CC:00:5E:04', '0x0000') + # A repeated BSSID is skipped by the per-AP `seen` guard. + dup = akm_pkt('AA:BB:CC:00:5E:01', '0x00c0') + # No AKM element -> _security_row returns None -> per_pkt returns 0. + no_akm = FakePkt({ + 'wlan': FakeLayer(attrs={'sa': 'AA:BB:CC:00:5E:02'}), + 'wlan.mgt': FakeLayer(attrs={})}) + _both_verbose(security_parsers.parse_security, + [valid, dup, no_mfp, no_akm], + self.database, self.path) + row = self.cursor.execute( + "SELECT wpa_version FROM AP WHERE bssid = ?", + ('AA:BB:CC:00:5E:01',)).fetchone() + self.assertEqual(row, ('WPA2',)) + + def test_security_row_none_without_akm(self): + self.assertIsNone( + security_parsers._security_row(FakeLayer(attrs={}))) + + def test_insert_one_security_none_without_akm(self): + self.assertIsNone(security_parsers._insert_one_security( + self.cursor, False, 'f', 'AA:BB:CC:00:5E:03', FakeLayer(attrs={}))) + + +# -------------------------------------------------------------------------- +# eap_parsers +# -------------------------------------------------------------------------- +class TestEapParsers(CapGapBase): + def test_identity_missing_identity_field_verbose(self): + # EAP-Response Identity (code 2, type 1) whose eap.identity field is + # absent: the AttributeError is caught, logged and counted. + pkt = FakePkt({'eap': FakeLayer(attrs={'code': '2', 'type': '1'}), + 'wlan': FakeLayer(attrs={'da': 'AA:BB:CC:00:1D:01', + 'sa': 'AA:BB:CC:00:1D:02'})}) + # An EAP-Request Identity (code 1) refreshes addresses without reading + # the identity field (the code != '2' branch). + request = FakePkt({ + 'eap': FakeLayer(attrs={'code': '1', 'type': '1'}), + 'wlan': FakeLayer(attrs={'da': 'AA:BB:CC:00:1D:01', + 'sa': 'AA:BB:CC:00:1D:02'})}) + with capture_patch([request, pkt]): + errors = eap_parsers.parse_identities( + self.path, self.database, True) + self.assertEqual(errors, 1) + # verbose=False takes the other branch of the same error path. + with capture_patch([request, pkt]): + eap_parsers.parse_identities(self.path, self.database, False) + + def test_eap_md5_pair_verbose(self): + request = FakePkt({ + 'eap': FakeLayer(attrs={'code': '1', 'id': '7', + 'md5_value': 'aa:bb'}), + 'wlan': FakeLayer(attrs={'sa': 'AA:BB:CC:00:4D:0A', + 'da': 'AA:BB:CC:00:4D:0C'})}) + response = FakePkt({ + 'eap': FakeLayer(attrs={'code': '2', 'id': '7', + 'md5_value': 'cc:dd'}), + 'wlan': FakeLayer(attrs={'sa': 'AA:BB:CC:00:4D:0C', + 'da': 'AA:BB:CC:00:4D:0A'})}) + _both_verbose(eap_parsers.parse_eap_md5, [request, response], + self.database, self.path) + row = self.cursor.execute( + "SELECT challenge, response FROM EAPMD5").fetchone() + self.assertEqual(row, ('aabb', 'ccdd')) + + def test_probe_fingerprint_verbose(self): + mgt = FakeLayer(attrs={'wlan_ssid': ''}, + fields={'wlan_tag_number': Field( + None, all_values=['0', '1', '48'])}) + pkt = FakePkt({'wlan': FakeLayer(attrs={'sa': 'AA:BB:CC:00:9B:01'}), + 'wlan.mgt': mgt}) + _both_verbose(eap_parsers.parse_probe_fingerprint, [pkt], + self.database, self.path) + row = self.cursor.execute( + "SELECT ie_order FROM Probe WHERE mac = ?", + ('AA:BB:CC:00:9B:01',)).fetchone() + self.assertEqual(row, ('0,1,48',)) + + +class TestExecHcxpcapngtool(CapGapBase): + '''exec_hcxpcapngtool with subprocess.Popen faked so no external binary is + needed. Runs in a temporary cwd because the tool writes/reads/removes + ./test.22000.''' + + def _run_in_tmpdir(self, popen_factory, verbose=False): + with tempfile.TemporaryDirectory() as workdir: + prev = os.getcwd() + os.chdir(workdir) + try: + with mock.patch.object(eap_parsers.subprocess, 'Popen', + popen_factory): + eap_parsers.exec_hcxpcapngtool( + self.path, self.database, verbose) + finally: + os.chdir(prev) + + def test_no_output_file_returns_early(self): + class _Popen: # never creates test.22000 + def __init__(self, *a, **k): + pass + + def wait(self): + return 0 + + self._run_in_tmpdir(_Popen) + count = self.cursor.execute( + "SELECT COUNT(*) FROM Handshake").fetchone()[0] + self.assertEqual(count, 0) + + def test_hash_line_inserted_verbose(self): + line = ('WPA*02*ffffffffffffffffffffffffffffffff*' + 'aabbccddeeff*112233445566*657373*' + 'deadbeef*0103*00\n') + + class _Popen: + def __init__(self, *a, **k): + with open('test.22000', 'w', encoding='utf-8') as handle: + handle.write(line) + + def wait(self): + return 0 + + # Both verbosities: the verbose branch prints the parsed hash line. + for verbose in (True, False): + self._run_in_tmpdir(_Popen, verbose=verbose) + row = self.cursor.execute( + "SELECT bssid, mac FROM Handshake").fetchone() + self.assertEqual(row, ('AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66')) + + def test_popen_failure_is_caught(self): + def _boom(*a, **k): + raise OSError("cannot exec") + + # Must not raise; the error is caught and reported. + self._run_in_tmpdir(_boom) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gaps_cert.py b/tests/test_gaps_cert.py new file mode 100644 index 0000000..4c802f5 --- /dev/null +++ b/tests/test_gaps_cert.py @@ -0,0 +1,186 @@ +'''Coverage for the certificate parsing/field-extraction branches not reached +by the sample capture: the tshark-driven parse_certificates dispatch (verbose +logging, malformed certs, address-less lines, the reassembly error path) and +the cert_fields/cert_extensions helpers that need certificate features (EC and +Ed25519 keys, SAN, encipher/decipher-only key usage, OCSP) absent from the RSA +sample cert.''' +import ipaddress +import unittest +from unittest import mock + +from cryptography import x509 +from cryptography.x509.oid import (NameOID, ExtendedKeyUsageOID, + AuthorityInformationAccessOID) +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa, ed25519 + +from utils import cert_parsers, cert_fields, cert_extensions + +from test_base import mem_db, colon_hex, build_self_signed + + +def _rich_cert_der(): + '''A self-signed RSA cert carrying every extension cert_extensions reads: + SAN, full Key Usage (incl. encipher/decipher-only), Basic Constraints, + AKI/SKI, CRL distribution points and Authority Info Access (OCSP).''' + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'rich.test'), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Org'), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, + 'Unit')]) + extensions = [ + (x509.SubjectAlternativeName( + [x509.DNSName('rich.test'), + x509.IPAddress(ipaddress.ip_address('10.0.0.1'))]), False), + (x509.KeyUsage( + digital_signature=True, content_commitment=False, + key_encipherment=True, data_encipherment=False, + key_agreement=True, key_cert_sign=True, crl_sign=True, + encipher_only=True, decipher_only=True), True), + (x509.BasicConstraints(ca=True, path_length=1), True), + (x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), False), + (x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False), + (x509.AuthorityKeyIdentifier.from_issuer_public_key( + key.public_key()), False), + (x509.CRLDistributionPoints([x509.DistributionPoint( + full_name=[x509.UniformResourceIdentifier( + 'http://crl.test/ca.crl')], + relative_name=None, reasons=None, crl_issuer=None)]), False), + (x509.AuthorityInformationAccess([ + x509.AccessDescription( + AuthorityInformationAccessOID.OCSP, + x509.UniformResourceIdentifier('http://ocsp.test')), + x509.AccessDescription( + AuthorityInformationAccessOID.CA_ISSUERS, + x509.UniformResourceIdentifier('http://ca.test/ca.crt'))]), + False), + ] + cert = build_self_signed(key, name, extensions, sign_hash=hashes.SHA256()) + return cert.public_bytes(serialization.Encoding.DER) + + +def _key_usage_cert(**flags): + '''A minimal self-signed cert carrying only a Key Usage extension with the + given flags, for exercising the individual key-usage branches.''' + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'ku.test')]) + usage = dict(digital_signature=True, content_commitment=False, + key_encipherment=False, data_encipherment=False, + key_agreement=False, key_cert_sign=False, crl_sign=False, + encipher_only=False, decipher_only=False) + usage.update(flags) + return build_self_signed(key, name, [(x509.KeyUsage(**usage), True)], + sign_hash=hashes.SHA256()) + + +def _ed25519_cert_der(): + key = ed25519.Ed25519PrivateKey.generate() + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'ed.test')]) + # Ed25519 signs with no separate hash. + cert = build_self_signed(key, name, sign_hash=None) + return cert.public_bytes(serialization.Encoding.DER) + + +class TestCertFieldExtraction(unittest.TestCase): + def test_rich_cert_extensions(self): + fields = cert_fields._extract_cert_fields(_rich_cert_der(), 0) + # SAN join, key usage (incl. encipher/decipher-only) and OCSP URL. + self.assertIn('rich.test', fields['subject_alt_names']) + self.assertIn('10.0.0.1', fields['subject_alt_names']) + self.assertIn('encipherOnly', fields['key_usage']) + self.assertIn('decipherOnly', fields['key_usage']) + self.assertEqual(fields['ocsp_urls'], 'http://ocsp.test') + self.assertEqual(fields['crl_urls'], 'http://crl.test/ca.crl') + self.assertEqual(fields['is_ca'], 'True') + + def test_public_key_details_ec_curve(self): + # An EC public key (class name contains 'EllipticCurve') reports its + # curve name; drive _public_key_details directly since cryptography's + # concrete class name varies by version. + class _Curve: + name = 'secp256r1' + + class EllipticCurvePublicKey: + key_size = 256 + curve = _Curve() + + class _Cert: + def public_key(self): + return EllipticCurvePublicKey() + + algorithm, size, curve, exponent = cert_fields._public_key_details( + _Cert()) + self.assertEqual(algorithm, 'EC') + self.assertEqual(curve, 'secp256r1') + self.assertEqual(exponent, '') + + def test_key_usage_branches(self): + # key_agreement set but encipher/decipher-only clear: the inner ifs are + # both skipped (43->45, 45->47), and key_agreement clear skips the + # whole block (42->47). + agreement = cert_extensions._key_usage( + _key_usage_cert(key_agreement=True)) + self.assertIn('keyAgreement', agreement) + self.assertNotIn('encipherOnly', agreement) + no_agreement = cert_extensions._key_usage( + _key_usage_cert(key_encipherment=True)) + self.assertIn('keyEncipherment', no_agreement) + + def test_ed25519_cert_neither_rsa_nor_ec(self): + fields = cert_fields._extract_cert_fields(_ed25519_cert_der(), 0) + self.assertEqual(fields['public_key_algorithm'], 'Ed25519') + # Neither the EC curve nor the RSA exponent branch is taken. + self.assertEqual(fields['public_key_curve'], '') + self.assertEqual(fields['public_key_exponent'], '') + + def test_name_attribute_error_is_swallowed(self): + class _BadName: + def get_attributes_for_oid(self, _oid): + raise ValueError("bad name") + + self.assertEqual( + cert_fields._name_attribute(_BadName(), NameOID.COMMON_NAME), '') + + +class TestParseCertificates(unittest.TestCase): + def setUp(self): + self.database = mem_db() + self.cursor = self.database.cursor() + + def tearDown(self): + self.database.close() + + def _run(self, output, verbose): + completed = mock.Mock() + completed.stdout = output.encode('utf-8') + with mock.patch.object(cert_parsers.subprocess, 'run', + return_value=completed): + cert_parsers.parse_certificates('cap.cap', self.database, verbose) + + def test_lines_cover_all_branches(self): + good = colon_hex(_rich_cert_der()) + lines = [ + "\t\t\t", # empty cert col + # A chain with a trailing empty element exercises the skip-empty + # branch inside _insert_cert_line's split(',') loop. + good + ",\tAA:BB:CC:00:C0:01\tAA:BB:CC:00:C0:02\t1", # AP cert + good + "\t\t\t", # no wlan addresses + "zz\tAA:BB:CC:00:C0:03\tAA:BB:CC:00:C0:04\t2", # malformed hex + ] + output = "\n".join(lines) + for verbose in (True, False): + self._run(output, verbose) + row = self.cursor.execute( + "SELECT cert_type FROM Certificate WHERE bssid = ?", + ('AA:BB:CC:00:C0:01',)).fetchone() + self.assertEqual(row, ('AP',)) + + def test_subprocess_failure_is_caught(self): + with mock.patch.object(cert_parsers.subprocess, 'run', + side_effect=OSError("tshark missing")): + # Must not raise; the error is caught and reported. + cert_parsers.parse_certificates('cap.cap', self.database, False) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gaps_cli.py b/tests/test_gaps_cli.py new file mode 100644 index 0000000..d1e167d --- /dev/null +++ b/tests/test_gaps_cli.py @@ -0,0 +1,45 @@ +'''Coverage for the remaining wifi_db.main() branch combinations (verbose on, +debug off, a capture path without a trailing slash, obfuscation off) and the +capture_pipeline fallback path when the given name has no trailing dot.''' +import os +import tempfile +import unittest +from unittest import mock + +from utils import capture_pipeline + +from test_base import run_main + + +class TestMainBranches(unittest.TestCase): + def test_verbose_no_debug_no_obfuscate_no_trailing_slash(self): + with tempfile.TemporaryDirectory() as workdir: + db_path = os.path.join(workdir, "out.db") + captures = os.path.join(workdir, "captures") + os.mkdir(captures) + # -v (verbose) without --debug, without -o, and a capture path that + # does not end in '/': the complementary branch of every guard the + # existing --debug/-o/trailing-slash test takes. + run_main(["-v", "-d", db_path, captures]) + self.assertTrue(os.path.exists(db_path)) + + +class TestCapturePipelineFallback(unittest.TestCase): + def test_process_capture_fallback_without_trailing_dot(self): + ctx = capture_pipeline.Context( + ouiMap={}, database=mock.Mock(), verbose=False, fake_lat="", + fake_lon="", hcxpcapngtool=False, tshark=False, force=False) + ctx.database.cursor.return_value = mock.Mock() + with mock.patch("utils.capture_pipeline.ingest_capture") as ingest, \ + mock.patch("utils.capture_pipeline.database_utils." + "checkFileProcessed", return_value=0): + # "noext" has no recognised extension and no trailing '.', so the + # dot-stripping branch is skipped and every fallback suffix tried. + capture_pipeline.process_capture(ctx, "noext") + suffixes = [call.args[2] for call in ingest.call_args_list] + for suffix, _name in capture_pipeline.FALLBACK_FORMATS: + self.assertIn("noext" + suffix, suffixes) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gaps_db.py b/tests/test_gaps_db.py new file mode 100644 index 0000000..ab680ba --- /dev/null +++ b/tests/test_gaps_db.py @@ -0,0 +1,230 @@ +'''Coverage for the database helpers' error handling and verbose branches: +the sqlite3 error paths that only fire on a constraint violation / driver +error (reached here with cursors and connections that raise on demand), the +`if verbose:` logging, and small edge cases (empty SSID, randomized-MAC +parse failure, the post-schema column migration).''' +import os +import sqlite3 +import tempfile +import unittest +from unittest import mock + +from utils import database_utils, db_inserts, db_files, db_maintenance +from utils.db_rows import APRow, ClientRow + +from test_base import MemDBTempFile + + +class RaisingCursor: + '''A cursor stand-in whose execute() always raises the given exception, + driving the insert helpers' sqlite error branches.''' + def __init__(self, exc): + self._exc = exc + + def execute(self, *_args, **_kwargs): + raise self._exc + + def fetchall(self): + return [] + + def fetchone(self): + return None + + +class RaisingDB: + '''A database stand-in handing out a RaisingCursor.''' + def __init__(self, exc): + self._exc = exc + + def cursor(self): + return RaisingCursor(self._exc) + + def commit(self): + pass + + +class ScriptRaisingDB: + '''A database whose executescript raises (createDatabase/createViews).''' + def executescript(self, _script): + raise sqlite3.IntegrityError("schema clash") + + def commit(self): + pass + + +def _integrity(): + return sqlite3.IntegrityError("UNIQUE constraint failed") + + +def _operational(): + return sqlite3.OperationalError("no such table") + + +class DBGapBase(MemDBTempFile): + # Inherits an in-memory database (self.database / self.cursor) plus a real + # on-disk file at self.path that the file-hashing inserts need. + pass + + +# -------------------------------------------------------------------------- +# db_files +# -------------------------------------------------------------------------- +class TestDbFiles(DBGapBase): + def test_insert_file_verbose(self): + self.assertEqual(db_files.insertFile(self.cursor, True, self.path), 0) + + def test_insert_file_integrity_error(self): + self.assertEqual( + db_files.insertFile(RaisingCursor(_integrity()), False, self.path), + 1) + + def test_set_file_processed_verbose_and_error(self): + self.assertEqual( + db_files.setFileProcessed(self.cursor, True, self.path), 0) + self.assertEqual( + db_files.setFileProcessed(RaisingCursor(_integrity()), False, + self.path), 1) + + def test_check_file_processed_missing_verbose(self): + self.assertEqual( + db_files.checkFileProcessed(self.cursor, True, "/no/such/file"), 0) + + def test_check_file_processed_integrity_error(self): + self.assertEqual( + db_files.checkFileProcessed(RaisingCursor(_integrity()), False, + self.path), 2) + + +# -------------------------------------------------------------------------- +# db_inserts +# -------------------------------------------------------------------------- +def _ap_row(bssid="AA:BB:CC:00:00:01", first=0): + return APRow(bssid=bssid, essid="AP", manuf="M", channel="6", + freqmhz="2437", carrier="", encryption="WPA2", + packets_total="1", lat="0.0", lon="0.0", cloaked='False', + mfpc='False', mfpr='False', firstTimeSeen=first) + + +def _client_row(mac="55:44:33:22:11:00", first=0): + return ClientRow(mac=mac, ssid="", manuf="M", client_type="1", + packets_total="1", device="Misc", firstTimeSeen=first) + + +class TestDbInserts(DBGapBase): + def test_log_and_exec_verbose(self): + db_inserts._log(True, "hello") + db_inserts._exec(self.cursor, True, "SELECT 1", ()) + + def test_randomized_mac_parse_failure(self): + self.assertEqual(db_inserts.isRandomizedMAC(None), 'False') + self.assertEqual(db_inserts.isRandomizedMAC(''), 'False') + + def test_insert_ap_generic_error(self): + self.assertEqual( + db_inserts.insertAP(RaisingCursor(_operational()), False, + _ap_row()), 1) + + def test_update_ap_integrity_error(self): + # First INSERT raises IntegrityError (-> _updateAP), whose own UPDATEs + # then raise IntegrityError too. firstTimeSeen != 0 exercises the + # firstTimeSeen UPDATE before the loop. + self.assertEqual( + db_inserts.insertAP(RaisingCursor(_integrity()), True, + _ap_row(first="2024-01-01 00:00:00")), 0) + + def test_insert_clients_inner_update_integrity_error(self): + self.assertEqual( + db_inserts.insertClients(RaisingCursor(_integrity()), True, + _client_row(first="2024-01-01 00:00:00")), + 1) + + def test_insert_clients_generic_error(self): + self.assertEqual( + db_inserts.insertClients(RaisingCursor(_operational()), False, + _client_row()), 1) + + +# -------------------------------------------------------------------------- +# db_maintenance +# -------------------------------------------------------------------------- +class TestDbMaintenance(DBGapBase): + def test_obfuscate_integrity_error(self): + self.assertEqual( + db_maintenance.obfuscateDB(RaisingDB(_integrity()), True), 1) + + def test_clear_whitelist_integrity_error(self): + with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False, + encoding='utf-8') as handle: + handle.write("11:22:33:44:55:66\n") + whitelist = handle.name + try: + # Must not raise: the per-mac IntegrityError is caught and logged. + db_maintenance.clearWhitelist(RaisingDB(_integrity()), True, + whitelist) + finally: + os.remove(whitelist) + + +# -------------------------------------------------------------------------- +# database_utils +# -------------------------------------------------------------------------- +class TestDatabaseUtils(DBGapBase): + def test_connect_database_fatal_exit(self): + with mock.patch.object(database_utils.sqlite3, 'connect', + side_effect=sqlite3.Error("cannot open")): + with self.assertRaises(SystemExit): + database_utils.connectDatabase("x.db", False) + + def test_migrate_columns_adds_and_logs(self): + raw = sqlite3.connect(":memory:") + raw.execute("CREATE TABLE AP (bssid TEXT)") + database_utils._migrateColumns(raw, True) + columns = [row[1] for row in + raw.execute("PRAGMA table_info(AP)").fetchall()] + self.assertIn("wps_config_methods_text", columns) + self.assertIn("rsn_capabilities_text", columns) + raw.close() + + def test_create_database_integrity_error(self): + # Must not raise: executescript's IntegrityError is caught. + database_utils.createDatabase(ScriptRaisingDB(), False) + + def test_create_views_integrity_error(self): + database_utils.createViews(ScriptRaisingDB(), False) + + def test_insert_hidden_ssid_empty(self): + self.assertEqual( + database_utils.insertHiddenSSID(self.cursor, False, + "AA:BB:CC:00:00:09", ""), 0) + + def test_insert_identity_verbose(self): + self.assertEqual( + database_utils.insertIdentity( + self.cursor, True, "AA:BB:CC:00:00:0A", "55:44:33:22:11:0A", + "user@realm.example", "EAP-TLS"), 0) + + def test_set_hashcat_verbose(self): + self.assertEqual( + database_utils.setHashcat( + self.cursor, True, "AA:BB:CC:00:00:0B", "55:44:33:22:11:0B", + self.path, " WPA*02*hash "), 0) + + def test_set_hashcat_integrity_error(self): + # A cursor that fails the final Handshake INSERT; the earlier + # constraint inserts are no-ops. + class _HandshakeFails: + def execute(self, sql, *_a, **_k): + if 'handshake' in sql.lower(): + raise _integrity() + + def fetchall(self): + return [] + + self.assertEqual( + database_utils.setHashcat(_HandshakeFails(), False, + "AA:BB:CC:00:00:0C", "55:44:33:22:11:0C", + self.path, "hash"), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gaps_text.py b/tests/test_gaps_text.py new file mode 100644 index 0000000..2ef94e0 --- /dev/null +++ b/tests/test_gaps_text.py @@ -0,0 +1,209 @@ +'''Coverage for the text/XML parser edge branches: missing-file returns, +outer exception handlers, per-row parse errors, the netxml truncation repair, +GPS coordinates and unknown record types, plus the two decode() bitmask +branches the sample data never hits.''' +import os +import tempfile +import unittest + +from utils import log_parsers, text_parsers, netxml_parser, decode + +from test_base import mem_db + + +class CursorRaisingDB: + '''A database whose cursor() raises, to trip the parsers' outer + try/except (which starts with `cursor = database.cursor()`).''' + def cursor(self): + raise RuntimeError("db unavailable") + + def commit(self): + pass + + +def _tmpfile(content, suffix): + handle = tempfile.NamedTemporaryFile('w', suffix=suffix, delete=False, + encoding='utf-8') + handle.write(content) + handle.close() + return handle.name + + +# -------------------------------------------------------------------------- +# decode +# -------------------------------------------------------------------------- +class TestDecode(unittest.TestCase): + def test_config_methods_pushbutton_subsumed(self): + # 0x0080 (PushButton) + 0x0200 (Virtual Push Button): the base + # PushButton name is suppressed in favour of the subtype. + result = decode.decode_wps_config_methods(0x0280) + self.assertIn('Virtual Push Button', result) + self.assertNotIn('PushButton', result) + + def test_rsn_capabilities_gtksa_replay_counters(self): + # bits 4-5 = 01 -> GTKSA replay counters = 2 (> 1), so it is listed. + result = decode.decode_rsn_capabilities(0x0010) + self.assertIn('GTKSA Replay Counters: 2', result) + + +# -------------------------------------------------------------------------- +# log_parsers +# -------------------------------------------------------------------------- +class TestLogParsers(unittest.TestCase): + def test_kismet_insert_ap_row_error(self): + # row[19] is not a parseable date: the exception is logged (verbose) + # and the row skipped (returns 0). Both verbosities exercise the guard. + row = ['x'] * 40 + row[19] = 'not-a-date' + for verbose in (True, False): + self.assertEqual( + log_parsers._kismet_insert_ap(None, verbose, {}, row), 0) + + def test_parse_kismet_csv_missing_file(self): + log_parsers.parse_kismet_csv({}, "/no/such/file.kismet.csv", None, + False) + + def test_parse_kismet_csv_outer_error(self): + path = _tmpfile("Network;a\n", ".kismet.csv") + try: + log_parsers.parse_kismet_csv({}, path, CursorRaisingDB(), False) + finally: + os.remove(path) + + def test_parse_log_csv_missing_file(self): + log_parsers.parse_log_csv({}, "/no/such/file.log.csv", None, False, + "", "") + + def test_parse_log_csv_unknown_kind(self): + # A row whose kind column is neither Client nor AP is skipped. + content = ("LocalTime,a,b,c,d,e,f,g,h,i,j\n" + "2023-10-20 14:33:06,,,,,,,,,,Other\n") + path = _tmpfile(content, ".log.csv") + database = mem_db() + try: + log_parsers.parse_log_csv({}, path, database, False, "", "") + finally: + database.close() + os.remove(path) + + def test_parse_log_csv_outer_error(self): + path = _tmpfile("LocalTime\n", ".log.csv") + try: + log_parsers.parse_log_csv({}, path, CursorRaisingDB(), False, + "", "") + finally: + os.remove(path) + + +# -------------------------------------------------------------------------- +# text_parsers +# -------------------------------------------------------------------------- +class TestTextParsers(unittest.TestCase): + def test_parse_csv_missing_file(self): + text_parsers.parse_csv({}, "/no/such/file.csv", None, False) + + def test_parse_csv_outer_error(self): + path = _tmpfile("BSSID,x\n", ".csv") + try: + text_parsers.parse_csv({}, path, CursorRaisingDB(), False) + finally: + os.remove(path) + + +# -------------------------------------------------------------------------- +# netxml_parser +# -------------------------------------------------------------------------- +_FULL_NETXML = ''' + + + TestNet + WPA2 + + F0:9F:C2:00:00:01 + TestManuf + 6 + 2437 1803 + 54 + IEEE 802.11bgn + CCK + 100 + 0 + 40.1-3.7 + + 64:32:A8:00:00:01 + 10 + + + + 64:32:A8:00:00:02 + TestManuf + 44 + 60 + + 64:32:A8:00:00:02 + + probed-net + + + + + 00:00:00:00:00:01 + + +''' + +# No closing : exercises the truncation-repair path. +_TRUNCATED_NETXML = ('\n' + ' \n' + ' 00:00:00:00:00:02\n' + ' \n' + ' is repaired; the first complete network + # is still parsed without raising. Both verbosities hit the repair. + for verbose in (True, False): + path = _tmpfile(_TRUNCATED_NETXML, ".kismet.netxml") + database = mem_db() + try: + netxml_parser.parse_netxml({}, path, database, verbose) + finally: + database.close() + os.remove(path) + + def test_parse_netxml_missing_file(self): + # A working database but a non-existent file reaches the "missing" + # branch (the cursor is opened before the file is checked). + database = mem_db() + try: + netxml_parser.parse_netxml({}, "/no/such/file.kismet.netxml", + database, False) + finally: + database.close() + + def test_parse_netxml_outer_error(self): + path = _tmpfile(_FULL_NETXML, ".kismet.netxml") + try: + netxml_parser.parse_netxml({}, path, CursorRaisingDB(), False) + finally: + os.remove(path) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..27f5570 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,144 @@ +'''Unit tests for the pure parser-logic added in 1.6: RSN/WPA classification, +suite-selector naming, X.509 cert attribution by EAP direction, the EAP-MD5 +hashcat line format, the sticky WPS field merge and the constant lookup tables. + +These functions are the deterministic core of the .cap parsers and need neither +pyshark packet objects nor a database, so they are tested directly.''' +import unittest + +from utils.cap_common import _suite_name, _dedupe, _to_int +from utils.security_parsers import _classify_wpa, _akm_ints +from utils.cert_parsers import _cert_attribution +from utils.cap_parsers import _merge_wps_fields +from utils.eap_parsers import _eap_md5_hashcat +from utils.wifi_constants import (RSN_AKM_SUITES, RSN_CIPHERS, + RSN_ENTERPRISE_AKMS, EAP_METHOD_TYPES) + + +class TestWpaClassification(unittest.TestCase): + def test_classify_wpa(self): + self.assertEqual(_classify_wpa({2}), 'WPA2') # PSK + self.assertEqual(_classify_wpa({1}), 'WPA2') # 802.1X + self.assertEqual(_classify_wpa({8}), 'WPA3') # SAE + self.assertEqual(_classify_wpa({9}), 'WPA3') # FT-SAE + self.assertEqual(_classify_wpa({18}), 'OWE') # OWE + # A transition network advertising both SAE and PSK is WPA2/WPA3. + self.assertEqual(_classify_wpa({8, 2}), 'WPA2/WPA3') + self.assertEqual(_classify_wpa({9, 4}), 'WPA2/WPA3') + # SAE takes precedence over OWE when both are present. + self.assertEqual(_classify_wpa({8, 18}), 'WPA3') + + def test_akm_ints(self): + self.assertEqual(_akm_ints(['1', '2', '8']), {1, 2, 8}) + self.assertEqual(_akm_ints([]), set()) + # Unparseable selectors are dropped, not counted. + self.assertEqual(_akm_ints(['x', '2']), {2}) + + +class TestSuiteNaming(unittest.TestCase): + def test_suite_name(self): + self.assertEqual(_suite_name('8', RSN_AKM_SUITES), 'SAE') + self.assertEqual(_suite_name('2', RSN_AKM_SUITES), 'PSK') + self.assertEqual(_suite_name('4', RSN_CIPHERS), 'CCMP-128') + # An unknown selector falls back to its own number. + self.assertEqual(_suite_name('99', RSN_AKM_SUITES), '99') + + def test_dedupe_preserves_order(self): + self.assertEqual(_dedupe(['PSK', 'PSK', 'SAE', 'PSK']), ['PSK', 'SAE']) + + +class TestCertAttribution(unittest.TestCase): + def test_direction(self): + # eap.code 2 (EAP-Response) = client cert: bssid=dst, mac=src. + self.assertEqual( + _cert_attribution(['HEX', 'SA', 'DA', '2']), + ('HEX', 'DA', 'SA', 'Client')) + # eap.code 1 (EAP-Request) = AP/server cert: bssid=src, mac=dst. + self.assertEqual( + _cert_attribution(['HEX', 'SA', 'DA', '1']), + ('HEX', 'SA', 'DA', 'AP')) + # Any other code is Unknown but still src/dst ordered. + self.assertEqual( + _cert_attribution(['HEX', 'SA', 'DA', '9']), + ('HEX', 'SA', 'DA', 'Unknown')) + + def test_missing_columns(self): + # A line with only the certificate column must not raise. + self.assertEqual(_cert_attribution(['HEX']), + ('HEX', '', '', 'Unknown')) + + +class TestEapMd5Hashcat(unittest.TestCase): + def test_format(self): + # response:challenge:eap_id, with the EAP id hex-encoded (42 -> 2a). + self.assertEqual( + _eap_md5_hashcat('42', '0102030405060708090a0b0c0d0e0f10', + 'aabbccddeeff00112233445566778899'), + 'aabbccddeeff00112233445566778899:' + '0102030405060708090a0b0c0d0e0f10:2a') + + def test_non_numeric_id_passthrough(self): + # A non-decimal eap_id is left as-is rather than crashing. + self.assertEqual(_eap_md5_hashcat('zz', 'cccc', 'rrrr'), + 'rrrr:cccc:zz') + + def test_to_int(self): + self.assertEqual(_to_int('42'), 42) + self.assertEqual(_to_int('0x1f', 16), 31) + self.assertIsNone(_to_int('nope')) + + +class TestWpsMerge(unittest.TestCase): + def _fields(self, **kw): + base = { + 'wlan_ssid': '', 'wps_version': '1.0', 'wps_device_name': '', + 'wps_model_name': '', 'wps_model_number': '', + 'wps_config_methods': '', 'wps_config_methods_keypad': '', + } + base.update(kw) + return base + + def test_first_frame_copied(self): + fields = self._fields(wps_device_name='Router') + merged = _merge_wps_fields(None, fields) + self.assertEqual(merged['wps_device_name'], 'Router') + # Must be a copy, not the same dict, so later merges don't alias it. + self.assertIsNot(merged, fields) + + def test_sticky_non_empty(self): + # A Probe Response fills device/model; a later reduced Beacon (empty + # details) must not blank them, but does fill a still-missing field. + acc = _merge_wps_fields(None, self._fields(wps_device_name='Router', + wps_model_name='X1')) + _merge_wps_fields(acc, self._fields(wps_device_name='', + wps_model_number='N9')) + self.assertEqual(acc['wps_device_name'], 'Router') + self.assertEqual(acc['wps_model_name'], 'X1') + self.assertEqual(acc['wps_model_number'], 'N9') + + def test_version_climbs_to_2_and_sticks(self): + acc = _merge_wps_fields(None, self._fields(wps_version='1.0')) + _merge_wps_fields(acc, self._fields(wps_version='2.0')) + self.assertEqual(acc['wps_version'], '2.0') + # A later 1.0 frame must not downgrade a 2.0 detection. + _merge_wps_fields(acc, self._fields(wps_version='1.0')) + self.assertEqual(acc['wps_version'], '2.0') + + +class TestConstantTables(unittest.TestCase): + def test_eap_method_types(self): + self.assertEqual(EAP_METHOD_TYPES['13'], 'EAP-TLS') + self.assertEqual(EAP_METHOD_TYPES['25'], 'EAP-PEAP') + self.assertEqual(EAP_METHOD_TYPES['4'], 'EAP-MD5') + + def test_enterprise_akms(self): + # 802.1X-family selectors are enterprise; PSK/SAE/OWE are not. + self.assertIn(1, RSN_ENTERPRISE_AKMS) + self.assertIn(5, RSN_ENTERPRISE_AKMS) + self.assertNotIn(2, RSN_ENTERPRISE_AKMS) + self.assertNotIn(8, RSN_ENTERPRISE_AKMS) + self.assertNotIn(18, RSN_ENTERPRISE_AKMS) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_probe_fingerprint.py b/tests/test_probe_fingerprint.py new file mode 100644 index 0000000..eb4092a --- /dev/null +++ b/tests/test_probe_fingerprint.py @@ -0,0 +1,96 @@ +'''Tests for the probe-request fingerprint parser in utils/eap_parsers.py, +driven by fake pyshark packets (the EAP identity/MD5 tests for the same +module live in test_eap_parsers.py). + +_probe_fingerprint_for_pkt is pure once the packet fields are supplied, so +stand-in layer/packet objects exercise it without tshark; the database insert +is mocked.''' +import unittest +from types import SimpleNamespace +from unittest import mock + +from utils import eap_parsers + + +class _MgtLayer: + '''Stand-in wlan.mgt layer exposing the IE tag numbers and (optionally) + the SSID element the fingerprint parser reads.''' + def __init__(self, tags, ssid_hex=None): + self._tags = tags + self._ssid_hex = ssid_hex + + def get_field(self, name): + if name == "wlan_tag_number": + return _FakeField(self._tags) + raise KeyError(name) + + @property + def wlan_ssid(self): + if self._ssid_hex is None: + raise AttributeError("wlan_ssid") + return self._ssid_hex + + +class _FakeField: + '''pyshark field stand-in: .all_fields yields one sub-field per value.''' + def __init__(self, values): + self.all_fields = [SimpleNamespace(get_default_value=lambda v=v: v) + for v in values] + + +class _ProbePkt: + '''Probe-request packet: pkt.wlan.sa and pkt['wlan.mgt'], either of which + may be absent to trigger the parser's guard clauses.''' + def __init__(self, sa=None, mgt=None): + if sa is not None: + self.wlan = SimpleNamespace(sa=sa) + self._mgt = mgt + + def __getitem__(self, key): + if key == "wlan.mgt" and self._mgt is not None: + return self._mgt + raise KeyError(key) + + +class TestProbeFingerprint(unittest.TestCase): + def _call(self, pkt, seen): + cursor = mock.Mock() + with mock.patch( + "utils.eap_parsers.database_utils.insertProbeFingerprint", + return_value=0) as insert: + errors = eap_parsers._probe_fingerprint_for_pkt( + cursor, False, pkt, seen, "file.cap") + return errors, insert + + def test_missing_mac(self): + errors, insert = self._call(_ProbePkt(), set()) + self.assertEqual(errors, 0) + insert.assert_not_called() + + def test_missing_mgt(self): + errors, insert = self._call(_ProbePkt(sa="AA"), set()) + self.assertEqual(errors, 0) + insert.assert_not_called() + + def test_no_tags(self): + mgt = _MgtLayer(tags=[]) + errors, insert = self._call(_ProbePkt(sa="AA", mgt=mgt), set()) + self.assertEqual(errors, 0) + insert.assert_not_called() + + def test_fingerprint_inserted_and_deduped(self): + ssid_hex = ":".join("%02x" % b for b in b"Net") + mgt = _MgtLayer(tags=["0", "1", "48"], ssid_hex=ssid_hex) + seen = set() + errors, insert = self._call( + _ProbePkt(sa="aa:bb:cc:dd:ee:ff", mgt=mgt), seen) + self.assertEqual(errors, 0) + insert.assert_called_once() + # Same MAC/SSID/fingerprint again is skipped. + errors, insert = self._call( + _ProbePkt(sa="aa:bb:cc:dd:ee:ff", mgt=mgt), seen) + insert.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_realdata.py b/tests/test_realdata.py new file mode 100644 index 0000000..3f44329 --- /dev/null +++ b/tests/test_realdata.py @@ -0,0 +1,195 @@ +from utils import oui +from utils import capture_pipeline + +import nest_asyncio + +from test_base import DBTestBase + + +class TestFunctionsRealData(DBTestBase): + def setUp(self): + # DBTestBase builds the on-disk test database (self.database / self.c); + # then load the real sample captures on top of it. + super().setUp() + nest_asyncio.apply() + + tshark = True + hcxpcapngtool = True + ouiMap = oui.load_vendors() + captures = [ + "./test_data/test-01.cap", + "./test_data/test-01.csv", + "./test_data/test-01.kismet.csv", + "./test_data/test-01.kismet.netxml", + "./test_data/test-01.log.csv" + ] + fake_lat = '' + fake_lon = '' + force = False + ctx = capture_pipeline.Context( + ouiMap=ouiMap, database=self.database, verbose=self.verbose, + fake_lat=fake_lat, fake_lon=fake_lon, + hcxpcapngtool=hcxpcapngtool, tshark=tshark, force=force) + for capture in captures: + capture_pipeline.process_capture(ctx, capture) + + def testRealAP(self): + + # Check AP + query = "SELECT ssid FROM AP WHERE bssid = ?;" + self.c.execute(query, ('B2:9B:00:EE:FB:EB',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'MiFibra-5-D6G3') + + query = "SELECT firstTimeSeen FROM AP WHERE bssid = ?;" + self.c.execute(query, ('F0:9F:C2:11:0A:24',)) + row = self.c.fetchone() + self.assertEqual(row[0], ' 2023-10-20 14:33:06') + + def testRealClient(self): + # Client + query = "SELECT manuf FROM Client WHERE mac = ? " + self.c.execute(query, ('64:32:A8:AD:AB:53',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'Intel Corporate') + + query = "SELECT firstTimeSeen FROM Client WHERE mac = ?" + self.c.execute(query, ('64:32:A8:AD:AB:53',)) + row = self.c.fetchone() + self.assertEqual(row[0], ' 2023-10-20 14:33:06') + + def testRealConnected(self): + # Connected + query = "SELECT bssid FROM Connected WHERE mac = ?" + self.c.execute(query, ('28:6C:07:6F:F9:43',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'F0:9F:C2:71:22:12') + + query = "SELECT bssid FROM Connected WHERE mac = ?" + self.c.execute(query, ('64:32:A8:BA:6C:41',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'F0:9F:C2:71:22:1A') + + def testRealFiles(self): + # Files + query = "SELECT hashSHA FROM Files WHERE file = ?" + self.c.execute(query, ('./test_data/test-01.cap',)) + row = self.c.fetchone() + self.assertEqual(row[0], + '1c951d7a9387ad7a17a85f0bfbec4ee7' + + 'bddf30244ae39aabd78654a104e4409c') + + query = "SELECT hashSHA FROM Files WHERE file = ?" + self.c.execute(query, ('./test_data/test-01.kismet.netxml',)) + row = self.c.fetchone() + self.assertEqual(row[0], + '7aaf4ba048b0fca4d1c481905f076be0e' + + 'fd7913bef2d87bd1e0ef1537ff1bc0b') + + def testRealHandshake(self): + # Handshake + query = "SELECT hashSHA FROM Handshake WHERE bssid = ?" + self.c.execute(query, ('F0:9F:C2:7A:33:28',)) + row = self.c.fetchone() + self.assertEqual(row[0], + '1c951d7a9387ad7a17a85f0bfbe' + + 'c4ee7bddf30244ae39aabd78654a104e4409c') + query = "SELECT hashcat FROM Handshake WHERE mac = ?" + self.c.execute(query, ('28:6C:07:6F:F9:44',)) + row = self.c.fetchone() + # List of expected values, to avoid errors in some systems, idk why + expected_values = ['WPA*02*45a64e58157df9397ffaca67b16fc898*' + + 'f09fc2712212*286c076ff944*' + + '776966692d6d6f62696c65*' + + 'babf7d3ce7f859d4b2a86b7fa704cea0177c9a42' + + '202ebc68a1ab3c779a97c37a*0103007502010a0' + + '00000000000000000011e04b195770b11f0378fc' + + '9977f3a4342475f0073d746781530f3a71dbb5e4' + + 'b840000000000000000000000000000000000000' + + '0000000000000000000000000000000000000000' + + '0000000000000000000001630140100000fac020' + + '100000fac040100000fac020000*00', + 'WPA*02*45a64e58157df9397ffaca67b16fc898*' + + 'f09fc2712212*286c076ff944*' + + '776966692d6d6f62696c65*' + + 'babf7d3ce7f859d4b2a86b7fa704cea0177c9a42' + + '202ebc68a1ab3c779a97c37a*0103007502010a0' + + '00000000000000000011e04b195770b11f0378fc' + + '9977f3a4342475f0073d746781530f3a71dbb5e4' + + 'b840000000000000000000000000000000000000' + + '0000000000000000000000000000000000000000' + + '0000000000000000000001630140100000fac020' + + '100000fac040100000fac020000*80'] + self.assertIn(row[0], expected_values) + + def testRealIdentity(self): + # Identity + query = "SELECT identity FROM Identity WHERE mac = ?" + self.c.execute(query, ('64:32:A8:AC:53:50',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'CONTOSOREG\\anonymous') + + query = "SELECT identity FROM Identity WHERE mac = ?" + self.c.execute(query, ('64:32:A8:BA:6C:41',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'CONTOSO\\anonymous') + + def testRealProbe(self): + # Probe. Filter by the expected SSID: the merged Probe table also holds + # broadcast probe-request rows (ssid '') from the fingerprint parser, + # so an unfiltered fetchone() is no longer order-deterministic. + query = "SELECT ssid FROM Probe WHERE mac = ? AND ssid = ?" + self.c.execute(query, ('64:32:A8:AC:53:50', 'wifi-regional')) + row = self.c.fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[0], 'wifi-regional') + + query = "SELECT ssid FROM Probe WHERE mac = ? AND ssid LIKE ?" + self.c.execute(query, ('B4:99:BA:6F:F9:45', 'wifi-%',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'wifi-offices') + + def testRealSeenAP(self): + # SeenAP + query = "SELECT signal_rssi FROM seenAP WHERE time = ? AND bssid = ?" + self.c.execute(query, ('2023-10-20 14:34:43', 'F0:9F:C2:AA:19:29',)) + row = self.c.fetchone() + self.assertEqual(row[0], -29) + + query = "SELECT tool FROM seenAP WHERE time = ? AND bssid = ?" + self.c.execute(query, ('2023-10-20 14:35:01', 'F0:9F:C2:71:22:10',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'aircrack-ng') + + def testRealSeenClient(self): + # SeenClient + query = "SELECT tool FROM seenClient WHERE time = ? AND mac = ?" + self.c.execute(query, ('2023-10-20 14:33:06', '4E:E6:C2:58:FC:24',)) + row = self.c.fetchone() + self.assertEqual(row[0], 'aircrack-ng') + + query = "SELECT signal_rssi FROM seenClient WHERE time = ? AND mac = ?" + self.c.execute(query, ('2023-10-20 14:35:02', 'B4:99:BA:6F:F9:45',)) + row = self.c.fetchone() + self.assertEqual(row[0], -49) + + def testRealWPS(self): + # WPS attributes are merged 1:1 onto the AP row. Assert the merged WPS + # columns exist and that any WPS data parsed from the real capture is + # well-formed. The capture is not guaranteed to contain WPS-enabled + # APs (the .cap parser also needs tshark), so the presence of WPS rows + # is not required; only their correctness is checked. + self.c.execute("PRAGMA table_info(AP)") + columns = {row[1] for row in self.c.fetchall()} + wps_columns = { + 'wps_version', 'wps_device_name', 'wps_model_name', + 'wps_model_number', 'wps_config_methods', + 'wps_config_methods_keypad', + } + self.assertTrue(wps_columns.issubset(columns)) + + query = ("SELECT wps_version FROM AP " + "WHERE wps_version IS NOT NULL AND wps_version != ''") + self.c.execute(query) + for (wps_version,) in self.c.fetchall(): + self.assertIn(wps_version, ('1.0', '2.0')) diff --git a/tests/test_runtime_utils.py b/tests/test_runtime_utils.py new file mode 100644 index 0000000..cb50a02 --- /dev/null +++ b/tests/test_runtime_utils.py @@ -0,0 +1,157 @@ +'''Tests for the small runtime helpers: the shared pyshark capture driver +(utils/cap_runner.py), the OUI vendor-list download logic (utils/oui.py) and +the asyncio child-watcher shim (utils/asyncio_shim.py). + +pyshark's FileCapture and the vendor download are mocked, so no tshark +process or network access is involved.''' +import asyncio +import unittest +from unittest import mock + +import requests + +from utils import asyncio_shim +from utils import cap_runner +from utils import oui + + +class TestRunCapParse(unittest.TestCase): + def setUp(self): + self.database = mock.Mock() + + def _run(self, per_pkt, packets, **kwargs): + with mock.patch.object(cap_runner.pyshark, "FileCapture", + return_value=packets): + return cap_runner.run_cap_parse( + self.database, "x.cap", True, "label", + "wlan", per_pkt, **kwargs) + + def test_clean_run_with_finalize(self): + errors = self._run(lambda cursor, pkt: 0, ["pkt1", "pkt2"], + finalize=lambda cursor: 0) + self.assertEqual(errors, 0) + self.database.commit.assert_called_once() + + def test_per_packet_errors_are_counted(self): + # With catch_pkt_errors each failing packet is skipped and counted. + def per_pkt(cursor, pkt): + raise ValueError("bad packet") + self.assertEqual(self._run(per_pkt, ["pkt1", "pkt2"]), 2) + self.database.commit.assert_called_once() + + def test_per_packet_error_fatal_without_catch(self): + # Without catch_pkt_errors the first failure aborts the whole parse. + def per_pkt(cursor, pkt): + raise ValueError("bad packet") + errors = self._run(per_pkt, ["pkt1", "pkt2"], + catch_pkt_errors=False) + self.assertEqual(errors, 1) + self.database.commit.assert_not_called() + + def test_tshark_crash(self): + crash = cap_runner.pyshark.capture.capture.TSharkCrashException( + "cut short") + with mock.patch.object(cap_runner.pyshark, "FileCapture", + side_effect=crash): + errors = cap_runner.run_cap_parse( + self.database, "x.cap", False, "label", + "wlan", lambda cursor, pkt: 0) + self.assertEqual(errors, 1) + + +class TestOui(unittest.TestCase): + def test_redownload_success(self): + # Force the "no cached file" branch; the downloaded bytes land in the + # temp file and copyfile is patched so the repo CSV stays untouched. + response = mock.Mock(content=b"Mac Prefix,Vendor\n00:00:01,Test\n") + with mock.patch("utils.oui.os.path.exists", return_value=False), \ + mock.patch("requests.get", return_value=response), \ + mock.patch("utils.oui.copyfile") as copy: + vendors = oui.load_vendors() + copy.assert_called_once() + self.assertTrue(vendors) + + def test_redownload_network_error(self): + # Download failure is swallowed and the shipped CSV is used instead. + with mock.patch("utils.oui.os.path.exists", return_value=False), \ + mock.patch("requests.get", + side_effect=requests.exceptions.RequestException( + "offline")), \ + mock.patch("utils.oui.copyfile") as copy: + vendors = oui.load_vendors() + copy.assert_not_called() + self.assertTrue(vendors) + + def test_redownload_when_cache_is_stale(self): + # The cached CSV exists but is older than 2h, so a re-download is + # attempted. The network call is stubbed to fail (no real request, + # copyfile untouched), exercising the "stale cache" branch that is + # otherwise only reached when the shipped CSV happens to be old. + with mock.patch("utils.oui.os.path.exists", return_value=True), \ + mock.patch("utils.oui.os.path.getmtime", return_value=0), \ + mock.patch("requests.get", + side_effect=requests.exceptions.RequestException( + "offline")), \ + mock.patch("utils.oui.copyfile") as copy: + vendors = oui.load_vendors() + copy.assert_not_called() + self.assertTrue(vendors) + + def test_get_vendor_prefix_walk(self): + # The lookup shortens the MAC until a prefix matches; verbose prints + # each attempt. + vendors = {"001122": "TestVendor"} + self.assertEqual( + oui.get_vendor(vendors, "00:11:22:33:44:55", True), + "TestVendor") + self.assertEqual(oui.get_vendor({}, "00:11:22:33:44:55", True), + "Unknown") + + +class TestAsyncioShim(unittest.TestCase): + # The child-watcher names install() checks for and shims when absent + # (removed in Python 3.14; still present, deprecated, on 3.12). + _NAMES = ("get_child_watcher", "set_child_watcher", + "AbstractChildWatcher", "SafeChildWatcher", + "ThreadedChildWatcher", "FastChildWatcher", + "PidfdChildWatcher", "MultiLoopChildWatcher") + + def test_null_child_watcher_api(self): + watcher = asyncio_shim._NullChildWatcher() + watcher.attach_loop(None) + watcher.add_child_handler(123, lambda: None) + self.assertTrue(watcher.remove_child_handler(123)) + self.assertTrue(watcher.is_active()) + with watcher as entered: + self.assertIs(entered, watcher) + watcher.close() + + def test_install_fills_missing_names(self): + # Simulate Python 3.14 by removing the attributes, then check + # install() puts working shims in place. Originals are restored. + saved = {name: getattr(asyncio, name) for name in self._NAMES + if hasattr(asyncio, name)} + try: + for name in saved: + delattr(asyncio, name) + asyncio_shim.install() + for name in self._NAMES: + self.assertTrue(hasattr(asyncio, name), name) + self.assertIs(asyncio.get_child_watcher(), + asyncio_shim._NULL_CHILD_WATCHER) + self.assertIsNone(asyncio.set_child_watcher(None)) + finally: + for name, value in saved.items(): + setattr(asyncio, name, value) + + def test_install_idempotent(self): + # With every name present install() must change nothing. + before = {name: getattr(asyncio, name) for name in self._NAMES + if hasattr(asyncio, name)} + asyncio_shim.install() + for name, value in before.items(): + self.assertIs(getattr(asyncio, name), value, name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_safe_insert.py b/tests/test_safe_insert.py new file mode 100644 index 0000000..2696207 --- /dev/null +++ b/tests/test_safe_insert.py @@ -0,0 +1,40 @@ +import sqlite3 +import unittest + +from utils.db_inserts import safe_insert + + +class TestSafeInsert(unittest.TestCase): + '''The shared safe_insert decorator's sqlite error handling.''' + + def test_passthrough_return_value(self): + @safe_insert + def ok(cursor, verbose, value): + return value + self.assertEqual(ok(None, False, 7), 7) + + def test_integrity_error_returns_0(self): + # A duplicate row (IntegrityError) is a no-op success -> 0. + @safe_insert + def dup(cursor, verbose): + raise sqlite3.IntegrityError("UNIQUE constraint failed") + self.assertEqual(dup(None, False), 0) + + def test_other_sqlite_error_returns_1(self): + # Any other sqlite3.Error is a failure -> 1. + @safe_insert + def bad(cursor, verbose): + raise sqlite3.OperationalError("no such table") + self.assertEqual(bad(None, False), 1) + + def test_non_sqlite_exception_propagates(self): + # Non-sqlite bugs must not be swallowed. + @safe_insert + def boom(cursor, verbose): + raise ValueError("real bug") + with self.assertRaises(ValueError): + boom(None, False) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..fd09f6a --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,142 @@ +'''Tests for utils/update.py (the GitHub self-update check). + +Everything external is mocked: subprocess (git/pip), requests (GitHub API) +and input() (the update prompt), so the suite never touches the network or +mutates the checkout.''' +import subprocess +import unittest +from unittest import mock + +import requests + +from utils import update + + +class TestIsGitInstalled(unittest.TestCase): + def test_git_present(self): + with mock.patch("utils.update.subprocess.run") as run: + run.return_value = mock.Mock(returncode=0) + self.assertTrue(update.is_git_installed()) + + def test_git_missing(self): + with mock.patch("utils.update.subprocess.run", + side_effect=FileNotFoundError): + self.assertFalse(update.is_git_installed()) + + def test_git_broken(self): + error = subprocess.CalledProcessError(1, "git") + with mock.patch("utils.update.subprocess.run", side_effect=error): + self.assertFalse(update.is_git_installed()) + + +class TestGetLatestGithubRelease(unittest.TestCase): + URL = "https://api.github.com/repos/r4ulcl/wifi_db" + + def test_ok(self): + response = mock.Mock(status_code=200) + response.json.return_value = {"tag_name": "v1.9"} + with mock.patch("utils.update.requests.get", return_value=response): + self.assertEqual(update.get_latest_github_release(self.URL), + "v1.9") + + def test_http_error_status(self): + response = mock.Mock(status_code=404) + with mock.patch("utils.update.requests.get", return_value=response): + self.assertIsNone(update.get_latest_github_release(self.URL)) + + def test_network_error(self): + with mock.patch("utils.update.requests.get", + side_effect=requests.RequestException("offline")): + self.assertIsNone(update.get_latest_github_release(self.URL)) + + def test_malformed_json(self): + # 200 whose JSON lacks "tag_name": the KeyError is swallowed too. + response = mock.Mock(status_code=200) + response.json.return_value = {} + with mock.patch("utils.update.requests.get", return_value=response): + self.assertIsNone(update.get_latest_github_release(self.URL)) + + +class TestIsGitRepo(unittest.TestCase): + def test_both_answers(self): + for exists in (True, False): + with mock.patch("utils.update.os.path.exists", + return_value=exists): + self.assertEqual(update.is_git_repo(), exists) + + +class TestParseVersions(unittest.TestCase): + def test_numeric_compare(self): + # 1.10.0 must beat 1.6.0: tuples of ints, not a string compare. + parsed = update._parse_versions("v1.6.0", "v1.10.0") + self.assertEqual(parsed, ((1, 10, 0), (1, 6, 0), "1.10.0")) + self.assertGreater(parsed[0], parsed[1]) + + def test_unparseable(self): + self.assertIsNone(update._parse_versions("nonsense", "v1.2")) + self.assertIsNone(update._parse_versions("v1.2", "nonsense")) + + +class TestPromptAndUpdate(unittest.TestCase): + def test_decline(self): + # "n" answers the prompt: no subprocess runs and no exit. + with mock.patch("builtins.input", return_value="n"), \ + mock.patch("utils.update.subprocess.Popen") as popen: + update._prompt_and_update("/repo", "1.9") + popen.assert_not_called() + + def test_accept(self): + # Empty answer defaults to yes: git pull + pip install, then exit. + with mock.patch("builtins.input", return_value=""), \ + mock.patch("utils.update.subprocess.Popen") as popen: + popen.return_value = mock.Mock(wait=mock.Mock(return_value=0)) + with self.assertRaises(SystemExit): + update._prompt_and_update("/repo", "1.9") + self.assertEqual(popen.call_count, 2) + + +class TestCheckForUpdate(unittest.TestCase): + '''Each early-return path of check_for_update, plus the three version + comparison outcomes. The helpers it calls are patched per test.''' + + def _run(self, version, installed=True, repo=True, tag="v1.6.0"): + with mock.patch("utils.update.is_git_installed", + return_value=installed), \ + mock.patch("utils.update.is_git_repo", return_value=repo), \ + mock.patch("utils.update.get_latest_github_release", + return_value=tag), \ + mock.patch("utils.update._prompt_and_update") as prompt: + update.check_for_update(version) + return prompt + + def test_no_git(self): + prompt = self._run("v1.0", installed=False) + prompt.assert_not_called() + + def test_not_a_repo(self): + prompt = self._run("v1.0", repo=False) + prompt.assert_not_called() + + def test_no_release_info(self): + prompt = self._run("v1.0", tag=None) + prompt.assert_not_called() + + def test_unparseable_versions(self): + prompt = self._run("nonsense", tag="alsononsense") + prompt.assert_not_called() + + def test_newer_available(self): + prompt = self._run("v1.0", tag="v1.6.0") + prompt.assert_called_once() + + def test_dev_version(self): + prompt = self._run("v9.9", tag="v1.6.0") + prompt.assert_not_called() + + def test_up_to_date(self): + prompt = self._run("v1.6.0", tag="v1.6.0") + prompt.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..a99b75b --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,343 @@ +from unittest import mock + +from test_base import DBTestBase, sample_cert +from utils import database_utils +from utils import wifi_db_aircrack + + +class TestViews(DBTestBase): + def test_insertCertificate(self): + # Define a parsed certificate (as built by _extract_cert_fields) + cert = sample_cert() + + # Insert new certificate (AP/server certificate) + result = database_utils.insertCertificate(self.c, self.verbose, + self.bssid, self.mac, + 'AP', 'test.cap', cert) + self.assertEqual(result, 0) + + self.c.execute("SELECT subject_cn, issuer_cn, cert_type, " + "subject_alt_names, ext_key_usage, self_signed, " + "validity_days, public_key_exponent, " + "sha256_fingerprint " + "FROM Certificate WHERE bssid = ?", (self.bssid,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], cert['subject_cn']) + self.assertEqual(rows[0][1], cert['issuer_cn']) + self.assertEqual(rows[0][2], 'AP') + self.assertEqual(rows[0][3], cert['subject_alt_names']) + self.assertEqual(rows[0][4], cert['ext_key_usage']) + self.assertEqual(rows[0][5], cert['self_signed']) + self.assertEqual(rows[0][6], cert['validity_days']) + self.assertEqual(rows[0][7], cert['public_key_exponent']) + self.assertEqual(rows[0][8], cert['sha256_fingerprint']) + + # A client certificate (different fingerprint) is stored in the same + # table for the same AP, tagged with its own cert_type + client_cert = dict(cert) + client_cert['sha256_fingerprint'] = '99ff88ee77dd' + client_cert['subject_cn'] = 'user.test.local' + result = database_utils.insertCertificate(self.c, self.verbose, + self.bssid, self.mac, + 'Client', 'test.cap', + client_cert) + self.assertEqual(result, 0) + self.c.execute("SELECT cert_type FROM Certificate WHERE bssid = ? " + "ORDER BY cert_type", (self.bssid,)) + self.assertEqual([r[0] for r in self.c.fetchall()], ['AP', 'Client']) + + # Inserting the same certificate again must not duplicate it + result = database_utils.insertCertificate(self.c, self.verbose, + self.bssid, self.mac, + 'AP', 'test.cap', cert) + self.assertEqual(result, 0) + self.c.execute("SELECT COUNT(*) FROM Certificate WHERE bssid = ?", + (self.bssid,)) + self.assertEqual(self.c.fetchone()[0], 2) + + def test_parse_certificates(self): + # Regression: EAP-TLS certificates are reassembled by tshark across + # EAPOL fragments, which pyshark's display-filter iteration never + # surfaced, so the Certificate table stayed empty. parse_certificates + # now reads tshark -T fields output directly; mock that output (with a + # real cert chain + a client cert) and check the rows are stored. + ap, sta = "F0:9F:C2:71:22:14", "28:6C:07:6F:F9:44" + server = self._make_cert_hex(u"radius.contoso.local") + ca = self._make_cert_hex(u"Contoso Root CA") + client = self._make_cert_hex(u"user@contoso") + + # Columns: tls.handshake.certificate \t wlan.sa \t wlan.da \t eap.code. + # A chain is comma-joined in a single column; eap.code 1 = AP/server, + # 2 = client. + line_ap = "\t".join([server + "," + ca, ap, sta, "1"]) + line_client = "\t".join([client, sta, ap, "2"]) + fake_stdout = (line_ap + "\n" + line_client + "\n").encode("utf-8") + fake = mock.Mock() + fake.stdout = fake_stdout + + with mock.patch("utils.cert_parsers.subprocess.run", + return_value=fake): + wifi_db_aircrack.parse_certificates("scanc44-01.cap", + self.database, self.verbose) + + rows = self.c.execute( + "SELECT bssid, mac, cert_type, subject_cn, cert_index " + "FROM Certificate ORDER BY cert_type, cert_index").fetchall() + # 2 certs in the AP chain + 1 client cert + self.assertEqual(len(rows), 3) + self.assertEqual([r[2] for r in rows], ['AP', 'AP', 'Client']) + # AP chain keeps its order via cert_index + self.assertEqual(rows[0][3], 'radius.contoso.local') + self.assertEqual(rows[0][4], 0) + self.assertEqual(rows[1][3], 'Contoso Root CA') + self.assertEqual(rows[1][4], 1) + # Whoever sent the cert, the BSSID stored is always the AP and the MAC + # the client. + for row in rows: + self.assertEqual(row[0], ap) + self.assertEqual(row[1], sta) + + def test_insertSecurity(self): + # Insert RSN/WPA security details for an AP (WPA3-Enterprise) + result = database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=self.bssid, wpa_version='WPA3', + akm_suites='802.1X-SuiteB-SHA384', pairwise_ciphers='GCMP-256', + group_cipher='GCMP-256', enterprise='True', pmf='Required', + rsn_capabilities='0x00c0', file='test.cap')) + self.assertEqual(result, 0) + + # Security columns now live on the AP row (1:1 merge) + self.c.execute("SELECT wpa_version, akm_suites, pairwise_ciphers, " + "enterprise, pmf FROM AP WHERE bssid = ?", + (self.bssid,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], 'WPA3') + self.assertEqual(rows[0][1], '802.1X-SuiteB-SHA384') + self.assertEqual(rows[0][2], 'GCMP-256') + self.assertEqual(rows[0][3], 'True') + self.assertEqual(rows[0][4], 'Required') + + # A second beacon for the same BSSID overwrites the AP columns + result = database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=self.bssid, wpa_version='WPA2', akm_suites='PSK', + pairwise_ciphers='CCMP-128', group_cipher='CCMP-128', + enterprise='False', pmf='Capable', rsn_capabilities='0x0080', + file='test.cap')) + self.assertEqual(result, 0) + self.c.execute("SELECT COUNT(*), MAX(wpa_version) FROM AP " + "WHERE bssid = ?", (self.bssid,)) + count, wpa_version = self.c.fetchone() + self.assertEqual(count, 1) + self.assertEqual(wpa_version, 'WPA2') + + def test_security_and_certificate_views(self): + # The SecurityAP and CertificateAP views must join Security/Certificate + # to AP on the BSSID and expose the expected columns. + database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=self.bssid, wpa_version='WPA3', akm_suites='SAE', + pairwise_ciphers='CCMP-128', group_cipher='CCMP-128', + enterprise='False', pmf='Required', rsn_capabilities='0x00c0', + file='test.cap')) + self.c.execute("SELECT wpa_version, pmf FROM SecurityAP " + "WHERE bssid = ?", (self.bssid,)) + row = self.c.fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[0], 'WPA3') + self.assertEqual(row[1], 'Required') + + cert = { + 'cert_index': 0, 'version': 'v3', 'serial_number': 'ab', + 'signature_algorithm': 'sha256WithRSAEncryption', + 'issuer': 'CN=CA', 'subject': 'CN=radius', + 'not_before': '', 'not_after': '', 'subject_cn': 'radius', + 'subject_o': '', 'subject_ou': '', 'issuer_cn': 'CA', + 'issuer_o': '', 'issuer_ou': '', 'public_key_algorithm': 'RSA', + 'public_key_size': 2048, 'public_key_curve': '', + 'public_key_exponent': '65537', 'subject_alt_names': '', + 'key_usage': '', 'ext_key_usage': '', 'is_ca': 'False', + 'path_length': None, 'self_signed': 'False', + 'authority_key_id': '', 'subject_key_id': '', 'crl_urls': '', + 'ocsp_urls': '', 'validity_days': 365, + 'sha1_fingerprint': 'aa', 'sha256_fingerprint': 'viewfp', + } + database_utils.insertCertificate(self.c, self.verbose, self.bssid, + self.mac, 'AP', 'test.cap', cert) + self.c.execute("SELECT cert_type, subject_cn FROM CertificateAP " + "WHERE bssid = ?", (self.bssid,)) + row = self.c.fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[0], 'AP') + self.assertEqual(row[1], 'radius') + + def test_insertCapabilities(self): + # Store 802.11r/k/v + MBSSID + CSA capabilities on the AP row. + result = database_utils.insertCapabilities( + self.c, self.verbose, database_utils.CapabilitiesRow( + bssid=self.bssid, ft_80211r='True', mobility_domain_id='0xabcd', + rrm_80211k='True', bss_transition_80211v='True', mbssid='True', + max_bssid_indicator=8, csa='True', csa_new_channel=36)) + self.assertEqual(result, 0) + self.c.execute("SELECT ft_80211r, mobility_domain_id, rrm_80211k, " + "bss_transition_80211v, mbssid, max_bssid_indicator, " + "csa, csa_new_channel FROM AP WHERE bssid = ?", + (self.bssid,)) + row = self.c.fetchone() + self.assertEqual(row, ('True', '0xabcd', 'True', 'True', 'True', 8, + 'True', 36)) + + # A later beacon without the elements must NOT clear sticky flags, and + # must keep the detail fields it does not carry. + result = database_utils.insertCapabilities( + self.c, self.verbose, database_utils.CapabilitiesRow( + bssid=self.bssid, ft_80211r='False', mobility_domain_id='', + rrm_80211k='False', bss_transition_80211v='False', + mbssid='False', max_bssid_indicator=None, csa='False', + csa_new_channel=None)) + self.assertEqual(result, 0) + self.c.execute("SELECT ft_80211r, mobility_domain_id, rrm_80211k, " + "csa_new_channel FROM AP WHERE bssid = ?", + (self.bssid,)) + row = self.c.fetchone() + self.assertEqual(row, ('True', '0xabcd', 'True', 36)) + + def test_cloaked_sticky_on_merge(self): + # A detected cloaked='True' must survive later merges (e.g. a beacon + # enriching the AP via insertAPConstraint, which passes 'False'). + database_utils.insertAP( + self.c, self.verbose, database_utils.APRow( + bssid=self.bssid, essid="", manuf="manuf", channel="6", + freqmhz="2437", carrier="", encryption="WPA2", + packets_total="0", lat="0.0", lon="0.0", cloaked='True', + mfpc='False', mfpr='False', firstTimeSeen=0)) + database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=self.bssid, wpa_version='WPA2', akm_suites='PSK', + pairwise_ciphers='CCMP-128', group_cipher='CCMP-128', + enterprise='False', pmf='Disabled', rsn_capabilities='0x0000', + file='test.cap')) + self.c.execute("SELECT cloaked FROM AP WHERE bssid = ?", (self.bssid,)) + self.assertEqual(self.c.fetchone()[0], 'True') + + def test_insertHiddenSSID(self): + # An AP with no SSID yet gets its name filled and flagged as revealed. + database_utils.insertAPConstraint(self.c, self.verbose, self.bssid) + result = database_utils.insertHiddenSSID( + self.c, self.verbose, self.bssid, "RecoveredNet") + self.assertEqual(result, 0) + self.c.execute("SELECT ssid, ssid_revealed FROM AP WHERE bssid = ?", + (self.bssid,)) + self.assertEqual(self.c.fetchone(), ("RecoveredNet", 'True')) + + # A known SSID must never be overwritten by a reveal. + result = database_utils.insertHiddenSSID( + self.c, self.verbose, self.bssid, "DifferentName") + self.assertEqual(result, 0) + self.c.execute("SELECT ssid FROM AP WHERE bssid = ?", (self.bssid,)) + self.assertEqual(self.c.fetchone()[0], "RecoveredNet") + + def test_capabilities_view(self): + # The CapabilitiesAP view exposes the capability columns from AP and + # only lists APs that advertise at least one of them. + database_utils.insertCapabilities( + self.c, self.verbose, database_utils.CapabilitiesRow( + bssid=self.bssid, ft_80211r='True', mobility_domain_id='0x1234', + rrm_80211k='False', bss_transition_80211v='False', + mbssid='False', max_bssid_indicator=None, csa='False', + csa_new_channel=None)) + self.c.execute("SELECT ssid, ft_80211r, mobility_domain_id " + "FROM CapabilitiesAP WHERE bssid = ?", (self.bssid,)) + row = self.c.fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[1], 'True') + self.assertEqual(row[2], '0x1234') + + # An AP with no capabilities advertised must not appear in the view. + other = "AA:BB:CC:DD:EE:FF" + database_utils.insertAPConstraint(self.c, self.verbose, other) + self.c.execute("SELECT COUNT(*) FROM CapabilitiesAP WHERE bssid = ?", + (other,)) + self.assertEqual(self.c.fetchone()[0], 0) + + def test_rsn_capabilities_text(self): + # 1.6: insertSecurity decodes the raw rsn_capabilities bitmask into the + # sibling rsn_capabilities_text column, exposed via SecurityAP. + database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=self.bssid, wpa_version='WPA2', akm_suites='PSK', + pairwise_ciphers='CCMP-128', group_cipher='CCMP-128', + enterprise='False', pmf='Required', rsn_capabilities='0x00c0', + file='test.cap')) + self.c.execute("SELECT rsn_capabilities, rsn_capabilities_text " + "FROM AP WHERE bssid = ?", (self.bssid,)) + self.assertEqual(self.c.fetchone(), ('0x00c0', 'MFPR, MFPC')) + self.c.execute("SELECT rsn_capabilities_text FROM SecurityAP " + "WHERE bssid = ?", (self.bssid,)) + self.assertEqual(self.c.fetchone()[0], 'MFPR, MFPC') + + # An empty/zero bitmask decodes to '' rather than a bogus flag list. + other = "AA:BB:CC:DD:EE:01" + database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=other, wpa_version='WPA2', akm_suites='PSK', + pairwise_ciphers='CCMP-128', group_cipher='CCMP-128', + enterprise='False', pmf='Disabled', rsn_capabilities='0x0000', + file='test.cap')) + self.c.execute("SELECT rsn_capabilities_text FROM AP WHERE bssid = ?", + (other,)) + self.assertEqual(self.c.fetchone()[0], '') + + def test_summary_view(self): + # 1.6: SummaryAP groups by SSID *and* encryption, counting APs and + # connected clients and concatenating the distinct wpa_version, pmf and + # manuf seen per group. + ap2 = "AA:BB:CC:DD:EE:02" + # Two WPA2 APs sharing one SSID, different manufacturers. + self.insert_test_ap(essid="Corp", encryption="WPA2", + manuf="VendorA") + database_utils.insertAP( + self.c, self.verbose, database_utils.APRow( + bssid=ap2, essid="Corp", manuf="VendorB", channel="11", + freqmhz="2462", carrier="", encryption="WPA2", + packets_total="5", lat="0.0", lon="0.0", cloaked='False', + mfpc='False', mfpr='False', firstTimeSeen=0)) + for bssid in (self.bssid, ap2): + database_utils.insertSecurity( + self.c, self.verbose, database_utils.SecurityRow( + bssid=bssid, wpa_version='WPA2', akm_suites='PSK', + pairwise_ciphers='CCMP-128', group_cipher='CCMP-128', + enterprise='False', pmf='Capable', + rsn_capabilities='0x0080', file='test.cap')) + # One client connected to the first AP. + self.insert_test_client() + database_utils.insertConnected(self.c, self.verbose, self.bssid, + self.mac) + # A same-SSID AP with a *different* encryption forms its own group. + database_utils.insertAP( + self.c, self.verbose, database_utils.APRow( + bssid="AA:BB:CC:DD:EE:03", essid="Corp", manuf="VendorC", + channel="36", freqmhz="5180", carrier="", encryption="WPA3", + packets_total="1", lat="0.0", lon="0.0", cloaked='False', + mfpc='False', mfpr='False', firstTimeSeen=0)) + + self.c.execute( + 'SELECT "APs count", wpa_version, pmf, manuf, "Clients count" ' + 'FROM SummaryAP WHERE ssid = ? AND encryption = ?', + ("Corp", "WPA2")) + aps_count, wpa_version, pmf, manuf, clients_count = self.c.fetchone() + self.assertEqual(aps_count, 2) + self.assertEqual(wpa_version, 'WPA2') + self.assertEqual(pmf, 'Capable') + self.assertIn('VendorA', manuf) + self.assertIn('VendorB', manuf) + self.assertEqual(clients_count, 1) + + # The WPA3 AP is a separate group, so "Corp" spans two rows. + self.c.execute("SELECT COUNT(*) FROM SummaryAP WHERE ssid = ?", + ("Corp",)) + self.assertEqual(self.c.fetchone()[0], 2) + diff --git a/tests/unit_test.py b/tests/unit_test.py new file mode 100644 index 0000000..6290ead --- /dev/null +++ b/tests/unit_test.py @@ -0,0 +1,552 @@ +import hashlib +import os +import sqlite3 +import tempfile +import unittest + +from test_base import DBTestBase, PROJECT_ROOT +from utils import database_utils +from utils import oui +from utils.decode import (decode_wps_config_methods, + decode_rsn_capabilities) + + +class TestDecode(unittest.TestCase): + '''Bitmask -> human-readable decoders for the AP *_text columns.''' + + def test_wps_config_methods(self): + # Captured examples: the raw config-methods hex bitmask and the flag + # list it decodes to. Display-PIN/Push-Button subtypes (0x2000/0x0200) + # replace their parent Display/PushButton bits. + cases = { + '0x0000': '', + '0x0004': 'Label', + '0x0086': 'Ethernet, Label, PushButton', + '0x008c': 'Label, Display, PushButton', + '0x2008': 'Virtual Display PIN', + '0x200c': 'Label, Virtual Display PIN', + '0x210c': 'Label, Keypad, Virtual Display PIN', + '0x218c': 'Label, PushButton, Keypad, Virtual Display PIN', + } + for raw, expected in cases.items(): + self.assertEqual(decode_wps_config_methods(raw), expected, raw) + + def test_wps_config_methods_edge(self): + # Empty/None/garbage decode to '' rather than raising. + self.assertEqual(decode_wps_config_methods(''), '') + self.assertEqual(decode_wps_config_methods(None), '') + self.assertEqual(decode_wps_config_methods('nothex'), '') + # A bare integer (already parsed) is accepted too. + self.assertEqual(decode_wps_config_methods(0x0004), 'Label') + + def test_rsn_capabilities(self): + self.assertEqual(decode_rsn_capabilities('0x0000'), '') + self.assertEqual(decode_rsn_capabilities('0x0080'), 'MFPC') + self.assertEqual(decode_rsn_capabilities('0x00c0'), 'MFPR, MFPC') + self.assertEqual(decode_rsn_capabilities('0x0001'), 'Pre-Auth') + # Replay-counter subfields (bits 2-3 / 4-5) decode to their counts. + self.assertEqual(decode_rsn_capabilities('0x000c'), + 'PTKSA Replay Counters: 16') + self.assertEqual(decode_rsn_capabilities(''), '') + self.assertEqual(decode_rsn_capabilities(None), '') + + +class TestFunctions(DBTestBase): + def test_connectDatabase(self): + self.assertIsNotNone(self.database) + + def test_createDatabase(self): + self.test_database_conn = database_utils.connectDatabase( + self.test_database_name, False + ) + database_utils.createDatabase(self.test_database_conn, self.verbose) + cursor = self.test_database_conn.cursor() + # Verify that the tables were created + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = cursor.fetchall() + expected_tables = [ + ('AP',), + ('Client',), + ('SeenClient',), + ('Connected',), + ('SeenAp',), + ('Probe',), + ('Handshake',), + ('Identity',), + ('Files',), + ('Certificate',), + ('EAPMD5',) + ] + self.assertEqual(tables, expected_tables) + + def test_createViews(self): + self.test_database_conn = database_utils.connectDatabase( + self.test_database_name, False + ) + # Create tables first + database_utils.createDatabase(self.test_database_conn, False) + database_utils.createViews(self.test_database_conn, self.verbose) + cursor = self.test_database_conn.cursor() + # Verify that the views were created + cursor.execute("SELECT name FROM sqlite_master WHERE type='view';") + views = cursor.fetchall() + expected_views = [ + ('ProbeClients',), + ('ConnectedAP',), + ('ProbeClientsConnected',), + ('HandshakeAP',), + ('HandshakeAPUnique',), + ('IdentityAP',), + ('CertificateAP',), + ('SecurityAP',), + ('CapabilitiesAP',), + ('SummaryAP',) + ] + self.assertEqual(views, expected_views) + + def test_insertAP(self): + ap = self.insert_test_ap() + + self.c.execute("SELECT ssid FROM AP WHERE bssid = ?", (self.bssid,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], ap['essid']) + + def test_insertClients(self): + client = self.insert_test_client(ssid="Test_AP") + + self.c.execute("SELECT manuf, randomized FROM Client WHERE mac=?", + (self.mac,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], client['manuf']) + # 55:.. first octet 0x55, locally-administered bit clear -> not random + self.assertEqual(rows[0][1], 'False') + + def test_insertWPS(self): + # Define WPS parameters + wlan_ssid = "Test_SSID" + wps_version = "1.0" + wps_device_name = "Test_Device" + wps_model_name = "Test_Model" + wps_model_number = "12345" + wps_config_methods = "0x008c" + wps_config_methods_keypad = True + + # Insert new WPS + result = database_utils.insertWPS( + self.c, self.verbose, database_utils.WPSRow( + bssid=self.bssid, wlan_ssid=wlan_ssid, wps_version=wps_version, + wps_device_name=wps_device_name, wps_model_name=wps_model_name, + wps_model_number=wps_model_number, + wps_config_methods=wps_config_methods, + wps_config_methods_keypad=wps_config_methods_keypad)) + self.assertEqual(result, 0) + + # WPS columns now live on the AP row (1:1 merge); the raw config-methods + # bitmask is decoded into the sibling wps_config_methods_text column. + self.c.execute("SELECT wlan_ssid, wps_config_methods, " + "wps_config_methods_text FROM AP WHERE bssid = ?", + (self.bssid,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], wlan_ssid) + self.assertEqual(rows[0][1], "0x008c") + self.assertEqual(rows[0][2], "Label, Display, PushButton") + + def test_isRandomizedMAC(self): + # bit 1 of the first octet set -> locally administered (randomized) + self.assertEqual(database_utils.isRandomizedMAC("DA:BB:CC:DD:EE:FF"), + 'True') + # globally administered (OUI) MAC -> not randomized + self.assertEqual(database_utils.isRandomizedMAC("00:11:22:33:44:55"), + 'False') + + def test_insertClients_randomized_flag(self): + # 1.6: insertClients derives and stores the randomized flag. A + # locally-administered MAC (first octet 0xDA, bit 1 set) -> 'True'. + rand_mac = "DA:BB:CC:DD:EE:FF" + result = database_utils.insertClients( + self.c, self.verbose, database_utils.ClientRow( + mac=rand_mac, ssid="", manuf="m", client_type="", + packets_total="0", device="", firstTimeSeen=0)) + self.assertEqual(result, 0) + self.c.execute("SELECT randomized FROM Client WHERE mac=?", + (rand_mac.upper(),)) + self.assertEqual(self.c.fetchone()[0], 'True') + + def test_firstTimeSeen_merge(self): + # 1.6 fix: a real firstTimeSeen must replace the '0' placeholder left by + # a foreign-key constraint insert, the earliest timestamp must win, and + # a later timestamp (or a 0 placeholder) must never overwrite it. + database_utils.insertAPConstraint(self.c, self.verbose, self.bssid) + + def stored_fts(): + self.c.execute("SELECT firstTimeSeen FROM AP WHERE bssid=?", + (self.bssid,)) + return self.c.fetchone()[0] + + # Real timestamp replaces the 0 placeholder. + self.insert_test_ap(firstTimeSeen="2024-06-01 00:00:00") + self.assertEqual(stored_fts(), "2024-06-01 00:00:00") + # A later timestamp does not overwrite the earlier one. + self.insert_test_ap(firstTimeSeen="2025-01-01 00:00:00") + self.assertEqual(stored_fts(), "2024-06-01 00:00:00") + # An earlier timestamp wins. + self.insert_test_ap(firstTimeSeen="2020-01-01 00:00:00") + self.assertEqual(stored_fts(), "2020-01-01 00:00:00") + # A 0 placeholder never clobbers a real timestamp. + self.insert_test_ap(firstTimeSeen=0) + self.assertEqual(stored_fts(), "2020-01-01 00:00:00") + + def test_insertEAPMD5(self): + result = database_utils.insertEAPMD5( + self.c, self.verbose, database_utils.EAPMD5Row( + bssid=self.bssid, mac=self.mac, identity="user", eap_id="42", + challenge="0102030405060708090a0b0c0d0e0f10", + response="aabbccddeeff00112233445566778899", + hashcat="aabbccddeeff00112233445566778899:" + "0102030405060708090a0b0c0d0e0f10:42", file='test.cap')) + self.assertEqual(result, 0) + self.c.execute("SELECT identity, eap_id, hashcat FROM EAPMD5 " + "WHERE bssid = ? AND mac = ?", (self.bssid, self.mac)) + row = self.c.fetchone() + self.assertEqual(row[0], "user") + self.assertEqual(row[1], "42") + self.assertTrue(row[2].endswith(":42")) + + def test_insertProbeFingerprint(self): + # The fingerprint now lives on the Probe row for the probed (mac, ssid) + result = database_utils.insertProbeFingerprint( + self.c, self.verbose, self.mac, "TestProbe", "abc123", + "0,1,50,3,45,221", 'test.cap') + self.assertEqual(result, 0) + self.c.execute("SELECT fingerprint, ie_order FROM Probe " + "WHERE mac = ? AND ssid = ?", (self.mac, "TestProbe")) + row = self.c.fetchone() + self.assertEqual(row[0], "abc123") + self.assertEqual(row[1], "0,1,50,3,45,221") + + # A fingerprint for an SSID already present as a plain probe row + # updates that row in place rather than creating a duplicate. + database_utils.insertProbe(self.c, self.verbose, self.mac, "Probed", 0) + database_utils.insertProbeFingerprint( + self.c, self.verbose, self.mac, "Probed", "def456", + "0,1,221", 'test.cap') + self.c.execute("SELECT COUNT(*), MAX(fingerprint) FROM Probe " + "WHERE mac = ? AND ssid = ?", (self.mac, "Probed")) + count, fingerprint = self.c.fetchone() + self.assertEqual(count, 1) + self.assertEqual(fingerprint, "def456") + + def test_insertConnected(self): + # add needed data + self.insert_test_ap() + self.insert_test_client() + + # Insert new connected device + result = database_utils.insertConnected(self.c, self.verbose, + self.bssid, self.mac) + self.assertEqual(result, 0) + + self.c.execute("SELECT bssid FROM Connected WHERE mac=?", (self.mac,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], self.bssid) + + def test_inserFile(self): + path = os.path.join(PROJECT_ROOT, "README.md") + + result = database_utils.insertFile(self.c, self.verbose, path) + self.assertEqual(result, 0) + + self.c.execute("SELECT file FROM Files WHERE file=?", (path,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], path) + + def test_insertHandshake(self): + path = self.insert_test_handshake() + + self.c.execute("SELECT * FROM handshake WHERE bssid = ?", + (self.bssid,)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][2], path) + + def test_insertIdentity(self): + identity = "DOMAIN\\username" + method = "EAP-PEAP" + result = database_utils.insertIdentity(self.c, self.verbose, + self.bssid, self.mac, identity, + method) + self.assertEqual(result, 0) + self.c.execute("SELECT identity, realm FROM Identity WHERE mac=?", + (self.mac,)) + row = self.c.fetchone() + self.assertEqual(row[0], identity) + self.assertEqual(row[1], "") # no '@' -> no realm + + # An anonymous outer identity carries the realm after '@' + result = database_utils.insertIdentity(self.c, self.verbose, + self.bssid, self.mac, + "anonymous@example.com", + "EAP-TTLS") + self.assertEqual(result, 0) + self.c.execute("SELECT realm FROM Identity WHERE mac=? AND " + "identity=?", (self.mac, "anonymous@example.com")) + self.assertEqual(self.c.fetchone()[0], "example.com") + + def test_insertSeenClient(self): + # add needed data + self.insert_test_client() + + # Insert seenClient + # station = "Test_Station" + time = "2022-02-23 10:00:00" + tool = "aircrack-ng" + power = -50 + lat = "37.7749" + lon = "-122.4194" + alt = "10000" + result = database_utils.insertSeenClient( + self.c, self.verbose, database_utils.SeenClientRow( + mac=self.mac, time=time, tool=tool, signal_rssi=power, + lat=lat, lon=lon, alt=alt)) + self.assertEqual(result, 0) + self.c.execute("SELECT * FROM SeenClient WHERE mac=?", (self.mac,)) + row = self.c.fetchone() + self.assertEqual(row[1], time) + self.assertEqual(row[2], tool) + self.assertEqual(row[3], power) + + def test_insertSeenAP(self): + # add needed data + self.insert_test_ap() + + # Insert SeenAP + time = "2032-02-23 10:00:00" + tool = "aircrack-ng" + signal_rsi = "-70" + lat = "37.7749" + lon = "-122.4194" + alt = "10000" + bsstimestamp = "2032-02-23 10:00:00" + result = database_utils.insertSeenAP( + self.c, self.verbose, database_utils.SeenAPRow( + bssid=self.bssid, time=time, tool=tool, signal_rsi=signal_rsi, + lat=lat, lon=lon, alt=alt, bsstimestamp=bsstimestamp)) + self.assertEqual(result, 0) + self.c.execute("SELECT * FROM SeenAP WHERE bssid = ?", (self.bssid,)) + row = self.c.fetchone() + self.assertEqual(row[1], time) + self.assertEqual(row[2], tool) + + def test_setHashcat(self): + # add needed data + self.insert_test_ap() + self.insert_test_client() + path = self.insert_test_handshake() + + # Insert hashcat HASH + test_hashcat = "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77" + result = database_utils.setHashcat(self.c, self.verbose, self.bssid, + self.mac, path, test_hashcat) + self.assertEqual(result, 0) + self.c.execute("SELECT file, hashcat FROM handshake WHERE bssid = ?", + (self.bssid,)) + rows = self.c.fetchall() + self.assertEqual(rows[0][0], path) + # The hashcat hash must actually be stored, not left empty. + self.assertEqual(rows[0][1], test_hashcat) + + def test_setHashcat_without_prior_handshake(self): + # Regression: hcxpcapngtool --all finds handshakes/PMKIDs that the + # tshark parser skipped, so setHashcat is called for an AP/Client that + # has no pre-existing Handshake/AP/Client row. It must create the + # referenced rows itself, otherwise the INSERT fails with a FOREIGN + # KEY constraint and the hashcat hash is silently dropped (empty). + path = os.path.join(PROJECT_ROOT, "README.md") + test_hashcat = ("WPA*02*727f2f35c4db2779fff8b30f4d349678*" + "f09fc2712212*286c076ff944*776966692d6d6f62696c65") + + result = database_utils.setHashcat(self.c, self.verbose, self.bssid, + self.mac, path, test_hashcat) + self.assertEqual(result, 0) + self.c.execute("SELECT hashcat FROM handshake WHERE bssid = ? " + "AND mac = ?", (self.bssid, self.mac)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], test_hashcat) + + def test_load_vendors(self): + ouiAux = oui.load_vendors() + vendor = oui.get_vendor(ouiAux, '00:00:00:00:00:01', self.verbose) + self.assertEqual(vendor, 'XEROX CORPORATION') + + def test_get_vendor(self): + ouiAux = {'000000': 'company1', + 'FFFFFF': 'company2'} + vendor = oui.get_vendor(ouiAux, '00:00:00:00:00:01', self.verbose) + self.assertEqual(vendor, 'company1') + + def test_obfuscateDB(self): + # add needed data + ap = self.insert_test_ap(manuf="Test_Manufacturer_AP") + client = self.insert_test_client(ssid="null_ssid", + manuf="Test_Manufacturer_Client") + self.insert_test_handshake() + + # obfuscateDB + result = database_utils.obfuscateDB(self.database, self.verbose) + self.assertEqual(result, 0) + + # self.c.execute("SELECT * FROM handshake WHERE bssid = ?", + # (self.bssid,)) + self.c.execute("SELECT * FROM AP WHERE ssid=?", (ap['essid'],)) + rows = self.c.fetchall() + # Same ESSID but different BSSID + self.assertEqual(rows[0][1], ap['essid']) + self.assertEqual(rows[0][3], ap['manuf']) + self.assertEqual(rows[0][4], int(ap['channel'])) + self.assertNotEqual(rows[0][0], self.bssid) + + self.c.execute("SELECT * FROM CLIENT WHERE ssid=?", (client['ssid'],)) + rows = self.c.fetchall() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][1], client['ssid']) + self.assertEqual(rows[0][2], client['manuf']) + self.assertEqual(rows[0][3], client['client_type']) + + def test_insertAPConstraint(self): + # Creates a placeholder AP row (empty attributes) so foreign keys that + # reference an as-yet-unseen BSSID resolve. + bssid = "AA:BB:CC:44:55:66" + result = database_utils.insertAPConstraint(self.c, self.verbose, bssid) + self.assertEqual(result, 0) + self.c.execute("SELECT bssid, ssid FROM AP WHERE bssid=?", (bssid,)) + row = self.c.fetchone() + self.assertIsNotNone(row) + self.assertEqual(row[0], bssid) + self.assertEqual(row[1], "") + + def test_insertClientConstraint(self): + # Creates a placeholder Client row for an as-yet-unseen MAC. + mac = "AA:BB:CC:11:22:33" + result = database_utils.insertClientConstraint(self.c, self.verbose, + mac) + self.assertEqual(result, 0) + self.c.execute("SELECT mac FROM Client WHERE mac=?", (mac,)) + self.assertEqual(self.c.fetchone()[0], mac) + + def test_insertMFP(self): + # insertMFP ensures the AP row and stores the PMF capable/required + # flags on it. + result = database_utils.insertMFP(self.c, self.verbose, self.bssid, + 'True', 'True') + self.assertEqual(result, 0) + self.c.execute("SELECT mfpc, mfpr FROM AP WHERE bssid=?", (self.bssid,)) + self.assertEqual(self.c.fetchone(), ('True', 'True')) + + # A later frame reporting no PMF must not clear the sticky flags. + database_utils.insertMFP(self.c, self.verbose, self.bssid, + 'False', 'False') + self.c.execute("SELECT mfpc, mfpr FROM AP WHERE bssid=?", (self.bssid,)) + self.assertEqual(self.c.fetchone(), ('True', 'True')) + + def test_insertProbe(self): + # A plain SSID-only probe row (fingerprint columns left NULL). The + # Client row is the FK parent, so it must exist first. + self.insert_test_client() + result = database_utils.insertProbe(self.c, self.verbose, self.mac, + "MyNet", 0) + self.assertEqual(result, 0) + self.c.execute("SELECT ssid, fingerprint FROM Probe " + "WHERE mac=? AND ssid=?", (self.mac, "MyNet")) + row = self.c.fetchone() + self.assertEqual(row[0], "MyNet") + self.assertIsNone(row[1]) + + def test_getHash(self): + # Stable SHA-256 hex digest of the given bytes. + data = b"wifi_db" + self.assertEqual(database_utils.getHash(data), + hashlib.sha256(data).hexdigest()) + self.assertEqual(len(database_utils.getHash(b"")), 64) + + def test_insertFile_idempotent_keeps_handshake(self): + # insertFile must be INSERT OR IGNORE, never OR REPLACE: the Files row + # is the ON DELETE CASCADE parent of Handshake, so re-inserting the + # same file must not wipe already-stored handshakes. + self.insert_test_ap() + self.insert_test_client() + path = self.insert_test_handshake() + self.assertEqual(database_utils.insertFile(self.c, self.verbose, path), + 0) + self.c.execute("SELECT COUNT(*) FROM Handshake WHERE bssid=?", + (self.bssid,)) + self.assertEqual(self.c.fetchone()[0], 1) + + def test_file_processed_lifecycle(self): + # insertFile stores processed='False'; setFileProcessed flips it to + # 'True'; checkFileProcessed reports 0 before and 1 after. + path = os.path.join(PROJECT_ROOT, "README.md") + self.assertEqual(database_utils.insertFile(self.c, self.verbose, path), + 0) + self.assertEqual( + database_utils.checkFileProcessed(self.c, self.verbose, path), 0) + self.assertEqual( + database_utils.setFileProcessed(self.c, self.verbose, path), 0) + self.c.execute("SELECT processed FROM Files WHERE file=?", (path,)) + self.assertEqual(self.c.fetchone()[0], "True") + self.assertEqual( + database_utils.checkFileProcessed(self.c, self.verbose, path), 1) + + def test_checkFileProcessed_missing_file(self): + # A non-existent path is reported as not-processed (0) without raising. + self.assertEqual( + database_utils.checkFileProcessed(self.c, self.verbose, + "/no/such/file.cap"), 0) + + def test_clearWhitelist(self): + # Every table row keyed on a whitelisted BSSID/MAC is deleted. + self.insert_test_ap() + self.insert_test_client() + database_utils.insertConnected(self.c, self.verbose, self.bssid, + self.mac) + self.database.commit() + with tempfile.NamedTemporaryFile('w', suffix='.txt', + delete=False) as handle: + handle.write(self.bssid + "\n" + self.mac + "\n") + whitelist_path = handle.name + self.addCleanup(os.remove, whitelist_path) + + database_utils.clearWhitelist(self.database, self.verbose, + whitelist_path) + self.c.execute("SELECT COUNT(*) FROM AP WHERE bssid=?", (self.bssid,)) + self.assertEqual(self.c.fetchone()[0], 0) + self.c.execute("SELECT COUNT(*) FROM Client WHERE mac=?", (self.mac,)) + self.assertEqual(self.c.fetchone()[0], 0) + self.c.execute("SELECT COUNT(*) FROM Connected") + self.assertEqual(self.c.fetchone()[0], 0) + + def test_migrateColumns_adds_text_columns_idempotently(self): + # 1.6 migration: an AP table created before the *_text columns existed + # gains them via ALTER TABLE, and re-running the migration is a no-op. + conn = sqlite3.connect(":memory:") + self.addCleanup(conn.close) + conn.execute("CREATE TABLE AP (bssid TEXT PRIMARY KEY, ssid TEXT)") + cols = [r[1] for r in conn.execute("PRAGMA table_info(AP)").fetchall()] + self.assertNotIn("rsn_capabilities_text", cols) + + database_utils._migrateColumns(conn, self.verbose) + cols = [r[1] for r in conn.execute("PRAGMA table_info(AP)").fetchall()] + self.assertIn("wps_config_methods_text", cols) + self.assertIn("rsn_capabilities_text", cols) + + # Idempotent: a second run must not raise "duplicate column". + database_utils._migrateColumns(conn, self.verbose) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit_test.py b/unit_test.py deleted file mode 100644 index 789a69f..0000000 --- a/unit_test.py +++ /dev/null @@ -1,621 +0,0 @@ -import os -# import sqlite3 -import unittest -# from database_utils import * -from utils import database_utils -from utils import oui -# from utils import update -# from utils import wifi_db_aircrack - -import wifi_db -import nest_asyncio - - -class TestFunctions(unittest.TestCase): - def setUp(self): - self.verbose = False - self.database_name = 'test_database.db' - self.database = database_utils.connectDatabase(self.database_name, - self.verbose) - database_utils.createDatabase(self.database, self.verbose) - database_utils.createViews(self.database, self.verbose) - self.c = self.database.cursor() - self.bssid = "00:11:22:33:44:55" - self.mac = "55:44:33:22:11:00" - self.test_database_name = 'test_database.db' - self.test_database_conn = None - - def tearDown(self): - self.database.close() - if self.test_database_conn: - self.test_database_conn.close() - if os.path.exists(self.test_database_name): - os.remove(self.test_database_name) - - def test_connectDatabase(self): - self.assertIsNotNone(self.database) - - def test_createDatabase(self): - self.test_database_conn = database_utils.connectDatabase( - self.test_database_name, False - ) - database_utils.createDatabase(self.test_database_conn, self.verbose) - cursor = self.test_database_conn.cursor() - # Verify that the tables were created - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - tables = cursor.fetchall() - expected_tables = [ - ('AP',), - ('Client',), - ('SeenClient',), - ('Connected',), - ('WPS',), - ('SeenAp',), - ('Probe',), - ('Handshake',), - ('Identity',), - ('Files',) - ] - self.assertEqual(tables, expected_tables) - - def test_createViews(self): - self.test_database_conn = database_utils.connectDatabase( - self.test_database_name, False - ) - # Create tables first - database_utils.createDatabase(self.test_database_conn, False) - database_utils.createViews(self.test_database_conn, self.verbose) - cursor = self.test_database_conn.cursor() - # Verify that the views were created - cursor.execute("SELECT name FROM sqlite_master WHERE type='view';") - views = cursor.fetchall() - expected_views = [ - ('ProbeClients',), - ('ConnectedAP',), - ('ProbeClientsConnected',), - ('HandshakeAP',), - ('HandshakeAPUnique',), - ('IdentityAP',), - ('SummaryAP',) - ] - self.assertEqual(views, expected_views) - - def test_insertAP(self): - essid = "Test_AP" - manuf = "Test_Manufacturer" - channel = "6" - freqmhz = "2437" - carrier = "test" - encryption = "WPA2" - packets_total = "10" - lat = "37.7749" - lon = "-122.4194" - cloaked = 'False' - mfpc = 'False' - mfpr = 'False' - # Insert new AP - result = database_utils.insertAP(self.c, self.verbose, self.bssid, - essid, manuf, channel, freqmhz, - carrier, encryption, packets_total, - lat, lon, cloaked, mfpc, mfpr, 0) - - self.assertEqual(result, 0) - - self.c.execute("SELECT ssid FROM AP WHERE bssid = ?", (self.bssid,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][0], essid) - - def test_insertClients(self): - ssid = "Test_AP" - manuf = "Test_Manufacturer" - packets_total = "10" - power = "-70" - # Insert new client - result = database_utils.insertClients(self.c, self.verbose, self.mac, - ssid, manuf, packets_total, - power, "Misc", 0) - - self.assertEqual(result, 0) - - self.c.execute("SELECT manuf FROM Client WHERE mac=?", (self.mac,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][0], manuf) - - def test_insertWPS(self): - # Define WPS parameters - wlan_ssid = "Test_SSID" - wps_version = "1.0" - wps_device_name = "Test_Device" - wps_model_name = "Test_Model" - wps_model_number = "12345" - wps_config_methods = "1234" - wps_config_methods_keypad = True - - # Insert new WPS - result = database_utils.insertWPS(self.c, self.verbose, self.bssid, - wlan_ssid, wps_version, - wps_device_name, wps_model_name, - wps_model_number, - wps_config_methods, - wps_config_methods_keypad) - self.assertEqual(result, 0) - - self.c.execute("SELECT wlan_ssid FROM WPS WHERE bssid = ?", - (self.bssid,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][0], wlan_ssid) - - def test_insertConnected(self): - # add needed data - essid = "Test_AP" - manuf = "Test_Manufacturer" - channel = "6" - freqmhz = "2437" - carrier = "test" - encryption = "WPA2" - packets_total = "10" - lat = "37.7749" - lon = "-122.4194" - cloaked = False - mfpc = 'False' - mfpr = 'False' - # Insert new AP - result = database_utils.insertAP(self.c, self.verbose, self.bssid, - essid, manuf, channel, freqmhz, - carrier, encryption, packets_total, - lat, lon, cloaked, mfpc, mfpr, 0) - - self.assertEqual(result, 0) - - ssid = "" - manuf = "Test_Manufacturer" - packets_total = "10" - power = "-70" - # Insert new client - result = database_utils.insertClients(self.c, self.verbose, self.mac, - ssid, manuf, packets_total, - power, "Misc", 0) - - self.assertEqual(result, 0) - - # Insert new connected device - result = database_utils.insertConnected(self.c, self.verbose, - self.bssid, self.mac) - self.assertEqual(result, 0) - - self.c.execute("SELECT bssid FROM Connected WHERE mac=?", (self.mac,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][0], self.bssid) - - def test_inserFile(self): - script_path = os.path.dirname(os.path.abspath(__file__)) - path = script_path+"/README.md" - - result = database_utils.insertFile(self.c, self.verbose, path) - self.assertEqual(result, 0) - - self.c.execute("SELECT file FROM Files WHERE file=?", (path,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][0], path) - - def test_insertHandshake(self): - script_path = os.path.dirname(os.path.abspath(__file__)) - path = script_path+"/README.md" - - result = database_utils.insertHandshake(self.c, self.verbose, - self.bssid, self.mac, path) - self.assertEqual(result, 0) - - self.c.execute("SELECT * FROM handshake WHERE bssid = ?", - (self.bssid,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][2], path) - - def test_insertIdentity(self): - identity = "DOMAIN\\username" - method = "EAP-PEAP" - result = database_utils.insertIdentity(self.c, self.verbose, - self.bssid, self.mac, identity, - method) - self.assertEqual(result, 0) - self.c.execute("SELECT identity FROM Identity WHERE mac=?", - (self.mac,)) - row = self.c.fetchone() - self.assertEqual(row[0], identity) - - def test_insertSeenClient(self): - # add needed data - ssid = "" - manuf = "Test_Manufacturer" - packets_total = "10" - power = "-70" - # Insert new client - result = database_utils.insertClients(self.c, self.verbose, self.mac, - ssid, manuf, packets_total, - power, "Misc", 0) - - # Insert seenClient - # station = "Test_Station" - time = "2022-02-23 10:00:00" - tool = "aircrack-ng" - power = -50 - lat = "37.7749" - lon = "-122.4194" - alt = "10000" - result = database_utils.insertSeenClient(self.c, self.verbose, - self.mac, time, tool, power, - lat, lon, alt) - self.assertEqual(result, 0) - self.c.execute("SELECT * FROM SeenClient WHERE mac=?", (self.mac,)) - row = self.c.fetchone() - self.assertEqual(row[1], time) - self.assertEqual(row[2], tool) - self.assertEqual(row[3], power) - - def test_insertSeenAP(self): - # add needed data - essid = "Test_AP" - manuf = "Test_Manufacturer" - channel = "6" - freqmhz = "2437" - carrier = "test" - encryption = "WPA2" - packets_total = "10" - lat = "37.7749" - lon = "-122.4194" - cloaked = False - mfpc = 'False' - mfpr = 'False' - # Insert new AP - result = database_utils.insertAP(self.c, self.verbose, self.bssid, - essid, manuf, channel, freqmhz, - carrier, encryption, packets_total, - lat, lon, cloaked, mfpc, mfpr, 0) - - self.assertEqual(result, 0) - - # Insert SeenAP - time = "2032-02-23 10:00:00" - tool = "aircrack-ng" - signal_rsi = "-70" - lat = "37.7749" - lon = "-122.4194" - alt = "10000" - bsstimestamp = "2032-02-23 10:00:00" - result = database_utils.insertSeenAP(self.c, self.verbose, self.bssid, - time, tool, signal_rsi, lat, lon, - alt, bsstimestamp) - self.assertEqual(result, 0) - self.c.execute("SELECT * FROM SeenAP WHERE bssid = ?", (self.bssid,)) - row = self.c.fetchone() - self.assertEqual(row[1], time) - self.assertEqual(row[2], tool) - - def test_setHashcat(self): - # add needed data - essid = "Test_AP" - manuf = "Test_Manufacturer" - channel = "6" - freqmhz = "2437" - carrier = "test" - encryption = "WPA2" - packets_total = "10" - lat = "37.7749" - lon = "-122.4194" - cloaked = False - mfpc = 'False' - mfpr = 'False' - # Insert new AP - result = database_utils.insertAP(self.c, self.verbose, self.bssid, - essid, manuf, channel, freqmhz, - carrier, encryption, packets_total, - lat, lon, cloaked, mfpc, mfpr, 0) - - self.assertEqual(result, 0) - - ssid = "" - manuf = "Test_Manufacturer" - packets_total = "10" - power = "-70" - # Insert new client - result = database_utils.insertClients(self.c, self.verbose, self.mac, - ssid, manuf, packets_total, - power, "Misc", 0) - - self.assertEqual(result, 0) - - # insert Handshake - script_path = os.path.dirname(os.path.abspath(__file__)) - path = script_path+"/README.md" - - result = database_utils.insertHandshake(self.c, self.verbose, - self.bssid, self.mac, path) - self.assertEqual(result, 0) - - # Insert hashcat HASH - script_path = os.path.dirname(os.path.abspath(__file__)) - path = script_path+"/README.md" - test_hashcat = "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77" - result = database_utils.setHashcat(self.c, self.verbose, self.bssid, - self.mac, path, test_hashcat) - self.assertEqual(result, 0) - self.c.execute("SELECT * FROM handshake WHERE bssid = ?", - (self.bssid,)) - rows = self.c.fetchall() - self.assertEqual(rows[0][2], path) - - def test_load_vendors(self): - ouiAux = oui.load_vendors() - vendor = oui.get_vendor(ouiAux, '00:00:00:00:00:01', self.verbose) - self.assertEqual(vendor, 'XEROX CORPORATION') - - def test_get_vendor(self): - ouiAux = {'000000': 'company1', - 'FFFFFF': 'company2'} - vendor = oui.get_vendor(ouiAux, '00:00:00:00:00:01', self.verbose) - self.assertEqual(vendor, 'company1') - - def test_obfuscateDB(self): - # add needed data - essid = "Test_AP" - manufAP = "Test_Manufacturer_AP" - channel = "6" - freqmhz = "2437" - carrier = "test" - encryption = "WPA2" - packets_total = "10" - lat = "37.7749" - lon = "-122.4194" - cloaked = False - mfpc = 'False' - mfpr = 'False' - # Insert new AP - result = database_utils.insertAP(self.c, self.verbose, self.bssid, - essid, manufAP, channel, freqmhz, - carrier, encryption, packets_total, - lat, lon, cloaked, mfpc, mfpr, 0) - - self.assertEqual(result, 0) - - ssid = "null_ssid" - manufClient = "Test_Manufacturer_Client" - packets_total = "10" - power = "-70" - # Insert new client - result = database_utils.insertClients(self.c, self.verbose, self.mac, - ssid, manufClient, packets_total, - power, "Misc", 0) - - self.assertEqual(result, 0) - - # insert Handshake - script_path = os.path.dirname(os.path.abspath(__file__)) - path = script_path+"/README.md" - - result = database_utils.insertHandshake(self.c, self.verbose, - self.bssid, self.mac, path) - self.assertEqual(result, 0) - - # obfuscateDB - result = database_utils.obfuscateDB(self.database, self.verbose) - self.assertEqual(result, 0) - - # self.c.execute("SELECT * FROM handshake WHERE bssid = ?", - # (self.bssid,)) - self.c.execute("SELECT * FROM AP WHERE ssid=?", (essid,)) - rows = self.c.fetchall() - # Same ESSID but different BSSID - self.assertEqual(rows[0][1], essid) - self.assertEqual(rows[0][3], manufAP) - self.assertEqual(rows[0][4], int(channel)) - self.assertNotEqual(rows[0][0], self.bssid) - - self.c.execute("SELECT * FROM CLIENT WHERE ssid=?", (ssid,)) - rows = self.c.fetchall() - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0][1], ssid) - self.assertEqual(rows[0][2], manufClient) - self.assertEqual(rows[0][3], packets_total) - - -class TestFunctionsRealData(unittest.TestCase): - def setUp(self): - self.verbose = False - self.database_name = 'test_database.db' - self.database = database_utils.connectDatabase(self.database_name, - self.verbose) - database_utils.createDatabase(self.database, self.verbose) - database_utils.createViews(self.database, self.verbose) - self.c = self.database.cursor() - self.bssid = "00:11:22:33:44:55" - self.mac = "55:44:33:22:11:00" - self.test_database_name = 'test_database.db' - self.test_database_conn = None - - # Load real data - nest_asyncio.apply() - - tshark = True - hcxpcapngtool = True - ouiMap = oui.load_vendors() - captures = [ - "./test_data/test-01.cap", - "./test_data/test-01.csv", - "./test_data/test-01.kismet.csv", - "./test_data/test-01.kismet.netxml", - "./test_data/test-01.log.csv" - ] - fake_lat = '' - fake_lon = '' - force = False - for capture in captures: - wifi_db.process_capture(ouiMap, capture, self.database, - self.verbose, fake_lat, fake_lon, - hcxpcapngtool, tshark, force) - - def tearDown(self): - self.database.close() - if self.test_database_conn: - self.test_database_conn.close() - if os.path.exists(self.test_database_name): - os.remove(self.test_database_name) - - def testRealAP(self): - - # Check AP - query = "SELECT ssid FROM AP WHERE bssid = ?;" - self.c.execute(query, ('B2:9B:00:EE:FB:EB',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'MiFibra-5-D6G3') - - query = "SELECT firstTimeSeen FROM AP WHERE bssid = ?;" - self.c.execute(query, ('F0:9F:C2:11:0A:24',)) - row = self.c.fetchone() - self.assertEqual(row[0], ' 2023-10-20 14:33:06') - - def testRealClient(self): - # Client - query = "SELECT manuf FROM Client WHERE mac = ? " - self.c.execute(query, ('64:32:A8:AD:AB:53',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'Intel Corporate') - - query = "SELECT firstTimeSeen FROM Client WHERE mac = ?" - self.c.execute(query, ('64:32:A8:AD:AB:53',)) - row = self.c.fetchone() - self.assertEqual(row[0], ' 2023-10-20 14:33:06') - - def testRealConnected(self): - # Connected - query = "SELECT bssid FROM Connected WHERE mac = ?" - self.c.execute(query, ('28:6C:07:6F:F9:43',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'F0:9F:C2:71:22:12') - - query = "SELECT bssid FROM Connected WHERE mac = ?" - self.c.execute(query, ('64:32:A8:BA:6C:41',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'F0:9F:C2:71:22:1A') - - def testRealFiles(self): - # Files - query = "SELECT hashSHA FROM Files WHERE file = ?" - self.c.execute(query, ('./test_data/test-01.cap',)) - row = self.c.fetchone() - self.assertEqual(row[0], - '1c951d7a9387ad7a17a85f0bfbec4ee7' + - 'bddf30244ae39aabd78654a104e4409c') - - query = "SELECT hashSHA FROM Files WHERE file = ?" - self.c.execute(query, ('./test_data/test-01.kismet.netxml',)) - row = self.c.fetchone() - self.assertEqual(row[0], - '7aaf4ba048b0fca4d1c481905f076be0e' + - 'fd7913bef2d87bd1e0ef1537ff1bc0b') - - def testRealHandshake(self): - # Handshake - query = "SELECT hashSHA FROM Handshake WHERE bssid = ?" - self.c.execute(query, ('F0:9F:C2:7A:33:28',)) - row = self.c.fetchone() - self.assertEqual(row[0], - '1c951d7a9387ad7a17a85f0bfbe' + - 'c4ee7bddf30244ae39aabd78654a104e4409c') - query = "SELECT hashcat FROM Handshake WHERE mac = ?" - self.c.execute(query, ('28:6C:07:6F:F9:44',)) - row = self.c.fetchone() - # List of expected values, to avoid errors in some systems, idk why - expected_values = ['WPA*02*45a64e58157df9397ffaca67b16fc898*' + - 'f09fc2712212*286c076ff944*' + - '776966692d6d6f62696c65*' + - 'babf7d3ce7f859d4b2a86b7fa704cea0177c9a42' + - '202ebc68a1ab3c779a97c37a*0103007502010a0' + - '00000000000000000011e04b195770b11f0378fc' + - '9977f3a4342475f0073d746781530f3a71dbb5e4' + - 'b840000000000000000000000000000000000000' + - '0000000000000000000000000000000000000000' + - '0000000000000000000001630140100000fac020' + - '100000fac040100000fac020000*00', - 'WPA*02*45a64e58157df9397ffaca67b16fc898*' + - 'f09fc2712212*286c076ff944*' + - '776966692d6d6f62696c65*' + - 'babf7d3ce7f859d4b2a86b7fa704cea0177c9a42' + - '202ebc68a1ab3c779a97c37a*0103007502010a0' + - '00000000000000000011e04b195770b11f0378fc' + - '9977f3a4342475f0073d746781530f3a71dbb5e4' + - 'b840000000000000000000000000000000000000' + - '0000000000000000000000000000000000000000' + - '0000000000000000000001630140100000fac020' + - '100000fac040100000fac020000*80'] - assert row[0] in expected_values - # self.assertEqual(row[0], ) - - def testRealIdentity(self): - # Identity - query = "SELECT identity FROM Identity WHERE mac = ?" - self.c.execute(query, ('64:32:A8:AC:53:50',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'CONTOSOREG\\anonymous') - - query = "SELECT identity FROM Identity WHERE mac = ?" - self.c.execute(query, ('64:32:A8:BA:6C:41',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'CONTOSO\\anonymous') - - def testRealProbe(self): - # Probe - query = "SELECT ssid FROM Probe WHERE mac = ?" - self.c.execute(query, ('64:32:A8:AC:53:50',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'wifi-regional') - - query = "SELECT ssid FROM Probe WHERE mac = ? AND ssid LIKE ?" - self.c.execute(query, ('B4:99:BA:6F:F9:45', 'wifi-%',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'wifi-offices') - - def testRealSeenAP(self): - # SeenAP - query = "SELECT signal_rssi FROM seenAP WHERE time = ? AND bssid = ?" - self.c.execute(query, ('2023-10-20 14:34:43', 'F0:9F:C2:AA:19:29',)) - row = self.c.fetchone() - self.assertEqual(row[0], -29) - - query = "SELECT tool FROM seenAP WHERE time = ? AND bssid = ?" - self.c.execute(query, ('2023-10-20 14:35:01', 'F0:9F:C2:71:22:10',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'aircrack-ng') - - def testRealSeenClient(self): - # SeenClient - query = "SELECT tool FROM seenClient WHERE time = ? AND mac = ?" - self.c.execute(query, ('2023-10-20 14:33:06', '4E:E6:C2:58:FC:24',)) - row = self.c.fetchone() - self.assertEqual(row[0], 'aircrack-ng') - - query = "SELECT signal_rssi FROM seenClient WHERE time = ? AND mac = ?" - self.c.execute(query, ('2023-10-20 14:35:02', 'B4:99:BA:6F:F9:45',)) - row = self.c.fetchone() - self.assertEqual(row[0], -49) - - ''' - def testReal(self): - # WPS TODO - self.c.execute("SELECT FROM WHERE = ''") - row = self.c.fetchone() - self.assertEqual(row[0], 0) - - self.c.execute("SELECT FROM WHERE = ''") - row = self.c.fetchone() - self.assertEqual(row[0], 0) - ''' - - -if __name__ == '__main__': - unittest.main() diff --git a/utils/asyncio_shim.py b/utils/asyncio_shim.py new file mode 100644 index 0000000..1332353 --- /dev/null +++ b/utils/asyncio_shim.py @@ -0,0 +1,55 @@ +''' asyncio child-watcher compatibility shim. + +Python 3.14 removed the asyncio child-watcher API (get_child_watcher / +set_child_watcher / AbstractChildWatcher / *ChildWatcher classes). pyshark 0.6 +and nest_asyncio still reference it; on 3.14 the event loop manages subprocesses +on its own, so we install no-op shims to keep pyshark's FileCapture working. +Call install() before pyshark is imported/used and before +nest_asyncio.apply(). It is idempotent, and checks each name independently in +case a given Python version only removed some of them. ''' +# -*- coding: utf-8 -*- +import asyncio + + +class _NullChildWatcher: + '''Minimal stand-in for the removed asyncio child watcher.''' + def __init__(self, *args, **kwargs): + pass + + def attach_loop(self, loop): + pass + + def add_child_handler(self, *args, **kwargs): + pass + + def remove_child_handler(self, *args, **kwargs): + return True + + def close(self): + pass + + def is_active(self): + return True + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +_NULL_CHILD_WATCHER = _NullChildWatcher() + + +def install(): + '''Install the no-op child-watcher shims onto the asyncio module. Idempotent + (each name is only set when missing), so it is safe to call repeatedly.''' + if not hasattr(asyncio, "get_child_watcher"): + asyncio.get_child_watcher = lambda *a, **k: _NULL_CHILD_WATCHER + if not hasattr(asyncio, "set_child_watcher"): + asyncio.set_child_watcher = lambda *a, **k: None + for watcher_name in ("AbstractChildWatcher", "SafeChildWatcher", + "ThreadedChildWatcher", "FastChildWatcher", + "PidfdChildWatcher", "MultiLoopChildWatcher"): + if not hasattr(asyncio, watcher_name): + setattr(asyncio, watcher_name, _NullChildWatcher) diff --git a/utils/beacon_parsers.py b/utils/beacon_parsers.py new file mode 100644 index 0000000..3812a00 --- /dev/null +++ b/utils/beacon_parsers.py @@ -0,0 +1,113 @@ +''' Parse AP-advertised details from beacons / probe responses in a .cap file: +RSN/WPA security (AKM suites, ciphers, PMF), 802.11r/k/v + MBSSID/CSA +management capabilities, and recovered hidden (cloaked) SSIDs. ''' +# -*- coding: utf-8 -*- +from utils import database_utils +from utils.cap_common import ( + _to_int, _pkt_bssid_mgt, _field_value, _first_field_value, + _field_is_set, _mgt_tag_numbers, _ssid_from_mgt, _seen_or_invalid) +from utils.cap_runner import run_cap_parse +from utils.wifi_constants import ( + TAG_MOBILITY_DOMAIN, TAG_RM_ENABLED_CAP, TAG_MULTIPLE_BSSID, + TAG_CHANNEL_SWITCH, TAG_EXTENDED_CSA) + + +def _flag(condition): + '''Codebase boolean style: 'True'/'False' strings from a truthy value.''' + return 'True' if condition else 'False' + + +# Detect 802.11r/k/v fast-roaming, Multiple BSSID and Channel Switch +# Announcement advertisements from beacons and probe responses, storing the +# flags on the AP row. +def _capability_flags(mgt): + '''Return the 802.11r/k/v + MBSSID/CSA capability flags for one mgt frame.''' + tags = _mgt_tag_numbers(mgt) + return { + 'ft': _flag(TAG_MOBILITY_DOMAIN in tags), + 'rrm': _flag(TAG_RM_ENABLED_CAP in tags), + 'mbssid': _flag(TAG_MULTIPLE_BSSID in tags), + 'csa': _flag(TAG_CHANNEL_SWITCH in tags or TAG_EXTENDED_CSA in tags), + # 802.11v BSS Transition Management is a bit (b19) of the Extended + # Capabilities element, not an element of its own. + 'bss_trans': _flag(_field_is_set( + _field_value(mgt, 'wlan_extcap_b19'))), + 'mdid': _first_field_value( + mgt, ['wlan_mobility_domain_mdid', 'wlan_ft_mdid']), + # tshark exposes the Multiple BSSID element's "Max BSSID Indicator" + # as wlan.multiple_bssid (the wlan_mbssid_* names never existed, so + # this column was always empty even for MBSSID-advertising APs). + 'max_bssid_indicator': _to_int(_first_field_value( + mgt, ['wlan_multiple_bssid', 'wlan_mbssid_max_bssid_indicator', + 'wlan_mbssid_index'])), + 'csa_new_channel': _to_int(_first_field_value( + mgt, ['wlan_csa_new_channel_number', + 'wlan_ext_chansw_announce_new_chan'])), + } + + +def _insert_one_capability(cursor, verbose, bssid, mgt): + '''Store fast-roaming / MBSSID / CSA capabilities for one AP if any are + advertised. Returns the number of insert errors (0/1).''' + f = _capability_flags(mgt) + if not any(f[k] == 'True' + for k in ('ft', 'rrm', 'bss_trans', 'mbssid', 'csa')): + return 0 + if verbose: + print("Capabilities for AP " + str(bssid) + ": 11r=" + f['ft'] + + " 11k=" + f['rrm'] + " 11v=" + f['bss_trans'] + " MBSSID=" + + f['mbssid'] + " CSA=" + f['csa']) + return database_utils.insertCapabilities( + cursor, verbose, database_utils.CapabilitiesRow( + bssid=bssid, ft_80211r=f['ft'], mobility_domain_id=f['mdid'], + rrm_80211k=f['rrm'], bss_transition_80211v=f['bss_trans'], + mbssid=f['mbssid'], max_bssid_indicator=f['max_bssid_indicator'], + csa=f['csa'], csa_new_channel=f['csa_new_channel'])) + + +def parse_capabilities(name, database, verbose): + seen = set() + + def per_pkt(cursor, pkt): + bssid, mgt = _pkt_bssid_mgt(pkt) + if _seen_or_invalid(bssid, mgt, seen): + return 0 + errors = _insert_one_capability(cursor, verbose, bssid, mgt) + seen.add(bssid.upper()) + return errors + + # Beacons (0x08) and probe responses (0x05) carry the capability IEs. + return run_cap_parse( + database, name, verbose, "Capabilities", + "wlan.fc.type_subtype == 0x08 || wlan.fc.type_subtype == 0x05", + per_pkt) + + +# Recover cloaked (hidden) SSIDs from probe responses and (re)association +# requests, which carry the real SSID even when the beacon hides it. +def _hidden_ssid_for_pkt(cursor, verbose, pkt, seen): + '''Recover and store one AP's cloaked SSID. Returns insert errors (0/1); + `seen` tracks the BSSIDs already handled and is mutated in place.''' + mgt = pkt['wlan.mgt'] + ssid = _ssid_from_mgt(mgt) + if not ssid: + return 0 + bssid = pkt.wlan.bssid + if bssid is None or bssid.upper() in seen: + return 0 + seen.add(bssid.upper()) + if verbose: + print("Revealed SSID for AP " + str(bssid) + ": " + ssid) + return database_utils.insertHiddenSSID(cursor, verbose, bssid, ssid) + + +def parse_hidden_ssid(name, database, verbose): + seen = set() + # Probe responses (0x05) and (re)association requests (0x00 / 0x02) carrying + # a non-wildcard SSID element. wlan.bssid is the AP in all of them, so no + # per-subtype address handling is needed. + return run_cap_parse( + database, name, verbose, "Hidden SSID", + "(wlan.fc.type_subtype == 0x05 || wlan.fc.type_subtype == 0x00 || " + "wlan.fc.type_subtype == 0x02) && wlan.ssid", + lambda cursor, pkt: _hidden_ssid_for_pkt(cursor, verbose, pkt, seen)) diff --git a/utils/cap_common.py b/utils/cap_common.py new file mode 100644 index 0000000..7877355 --- /dev/null +++ b/utils/cap_common.py @@ -0,0 +1,156 @@ +''' Shared plumbing for the pyshark-based .cap parsers. + +Owns the asyncio child-watcher shim and the single `import pyshark` (so the +shim always runs first), the tshark-crash unraisablehook, and the generic +pyshark field / suite helpers reused across the .cap parser modules. +''' +# -*- coding: utf-8 -*- +import binascii +import contextlib +import sys + +# Install the asyncio child-watcher shim; must run before pyshark is imported +# below (see utils/asyncio_shim.py). +from utils import asyncio_shim +asyncio_shim.install() + +import pyshark # noqa: E402 (imported after the child-watcher shim above) + + +# pyshark's Capture.__del__ calls close(), which re-raises TSharkCrashException +# when tshark exited non-zero (e.g. a PCAP cut short in the middle of a packet). +# Because that happens during garbage collection, Python prints a noisy +# "Exception ignored in: " traceback even though every +# call site already catches the crash explicitly. Swallow only that specific +# unraisable and defer everything else to the default hook so real bugs still +# surface. +_default_unraisablehook = sys.unraisablehook + + +def _quiet_tshark_unraisablehook(unraisable): + if isinstance(unraisable.exc_value, + pyshark.capture.capture.TSharkCrashException): + return + _default_unraisablehook(unraisable) + + +sys.unraisablehook = _quiet_tshark_unraisablehook + + +def _safe(func, default=""): + '''Call func() and return its value, or `default` on any error. Keeps the + per-field certificate extraction terse and resilient to malformed certs.''' + try: + return func() + except Exception: + return default + + +def _all_field_values(layer, field_name): + '''Return every value of a (possibly repeated) pyshark layer field''' + values = [] + try: + field = layer.get_field(field_name) + except Exception: + field = None + if field is None: + return values + try: + for sub_field in field.all_fields: + value = sub_field.get_default_value() + if value not in (None, ''): + values.append(value) + except Exception: + with contextlib.suppress(Exception): + values.append(str(field)) + return values + + +def _suite_name(value, mapping): + '''Map an RSN suite selector number to its readable name''' + try: + key = str(int(value)) + except Exception: + key = str(value) + return mapping.get(key, key) + + +def _dedupe(values): + '''Deduplicate a list while preserving order''' + return list(dict.fromkeys(values)) + + +def _to_int(value, base=10): + '''Parse an int, returning None instead of raising.''' + try: + return int(value, base) if isinstance(value, str) else int(value) + except (TypeError, ValueError): + return None + + +def _pkt_bssid_mgt(pkt): + '''Return (bssid, wlan.mgt layer) for a packet, or (None, None).''' + try: + return pkt.wlan.sa, pkt['wlan.mgt'] + except Exception: + return None, None + + +def _field_value(layer, field_name): + '''Return a single field's value (or '') from a pyshark layer, never + raising.''' + try: + field = layer.get_field(field_name) + except Exception: + return '' + if field is None: + return '' + try: + return field.get_default_value() or '' + except Exception: + return '' + + +def _first_field_value(layer, field_names): + '''Return the first non-empty value among several candidate field names + (dissector field names vary between tshark versions).''' + for name in field_names: + value = _field_value(layer, name) + if value not in (None, ''): + return value + return '' + + +def _field_is_set(value): + '''True when a tshark boolean/bit field reads as set.''' + return str(value).strip().lower() in ('1', 'true', 'yes') + + +def _mgt_tag_numbers(mgt): + '''Return the set of 802.11 element (tag) numbers present in a management + frame, as ints.''' + values = _all_field_values(mgt, 'wlan_tag_number') + return {i for i in (_to_int(v) for v in values) if i is not None} + + +def _ssid_from_mgt(mgt): + '''Decode the SSID element of a management frame, returning '' for a + hidden/wildcard SSID (empty or NUL padding). tshark may expose wlan.ssid + either already decoded or as colon-separated hex bytes.''' + raw = _field_value(mgt, 'wlan_ssid') + if not raw: + return '' + candidate = raw + if ':' in raw: + try: + candidate = binascii.unhexlify( + raw.replace(':', '')).decode('utf-8', 'replace') + except Exception: + candidate = raw + return candidate.replace('\x00', '').strip() + + +def _seen_or_invalid(bssid, mgt, seen): + '''True when a packet lacks a usable BSSID/mgt or its AP is already seen + (one row per BSSID is enough; the config is stable per AP).''' + return bssid is None or mgt is None or bssid.upper() in seen diff --git a/utils/cap_parsers.py b/utils/cap_parsers.py new file mode 100644 index 0000000..5f1ece0 --- /dev/null +++ b/utils/cap_parsers.py @@ -0,0 +1,186 @@ +''' Parse .cap/.pcap capture files into the SQLite DB: EAPOL handshakes, MFP and +WPS. `parse_cap` dispatches to every .cap parser (including the EAP, certificate, +security, capability and hidden-SSID parsers that live in their own modules). ''' +# -*- coding: utf-8 -*- +import binascii + +from utils import database_utils +from utils.cap_common import _safe +from utils.cap_runner import run_cap_parse +from utils.cert_parsers import parse_certificates +from utils.beacon_parsers import parse_capabilities, parse_hidden_ssid +from utils.security_parsers import parse_security +from utils.eap_parsers import (parse_identities, parse_eap_md5, + parse_probe_fingerprint, exec_hcxpcapngtool) + + +def parse_cap(name, database, verbose, hcxpcapngtool, tshark): + if tshark: + parse_handshakes(name, database, verbose) + parse_WPS(name, database, verbose) + parse_identities(name, database, verbose) + parse_MFP(name, database, verbose) + parse_certificates(name, database, verbose) + parse_security(name, database, verbose) + parse_capabilities(name, database, verbose) + parse_hidden_ssid(name, database, verbose) + parse_eap_md5(name, database, verbose) + parse_probe_fingerprint(name, database, verbose) + if hcxpcapngtool: + exec_hcxpcapngtool(name, database, verbose) + + +# Get handshakes from .cap +def _handshake_for_pkt(cursor, verbose, pkt, prev, file): + '''Process one EAPOL packet for a 4-way-handshake message-2 match. + + `prev` is the (src, dst, key_info) of the previous EAPOL frame. Returns + (errors, new_prev): a message-2 (key info containing '10a') that follows the + matching message-1 ('08a') in the opposite direction is a valid pair; any + other EAPOL-Key frame is remembered as a potential message-1.''' + if verbose: + print(pkt.eapol.field_names) + print(pkt.eapol.type) + if pkt.eapol.type != '3': # only EAPOL-Key frames + return 0, prev + src = pkt.wlan.ta + dst = pkt.wlan.da + flag = pkt.eapol.wlan_rsna_keydes_key_info + if flag.find('10a') == -1: + return 0, (src, dst, flag) # remember as a potential message-1 + prevSrc, prevDst, prevFlag = prev + if prevFlag.find('08a') != -1 and dst == prevSrc and src == prevDst: + if verbose: + print("Valid handshake from client " + prevSrc + " to AP " + + prevDst) + return database_utils.insertHandshake( + cursor, verbose, dst, src, file), prev + return 0, prev + + +def parse_handshakes(name, database, verbose): + state = {'prev': ("", "", "")} + + def per_pkt(cursor, pkt): + delta, state['prev'] = _handshake_for_pkt( + cursor, verbose, pkt, state['prev'], name) + return delta + + return run_cap_parse(database, name, verbose, "Handshake", "eapol", + per_pkt) + + +# Get MFP data from .cap +def _insert_one_mfp(cursor, verbose, pkt): + '''Store MFP (PMF) capable/required flags for one management frame, read + from its RSN Capabilities bitfield. Returns the insert error count (0/1).''' + if not (pkt['wlan.mgt'].wlan_rsn_capabilities and pkt.wlan.ta): + return 0 + capabilities = pkt['wlan.mgt'].wlan_rsn_capabilities + # MFP lives in the RSN Capabilities bitfield: + # bit 7 (0x80) = MFP Capable + # bit 6 (0x40) = MFP Required + # Test the bits instead of matching exact values, so APs with other + # capability bits set are detected too. + cap_int = int(capabilities, 16) + mfpc = 'True' if cap_int & 0x80 else 'False' + mfpr = 'True' if cap_int & 0x40 else 'False' + if not (mfpc == 'True' or mfpr == 'True'): + return 0 + if verbose: + print(f"MFPC: {mfpc}") + print(f"MFPR: {mfpr}") + return database_utils.insertMFP(cursor, verbose, pkt.wlan.ta, mfpc, mfpr) + + +def parse_MFP(name, database, verbose): + # Filter only with mfpr or mfpc enable, on Beacons (0x0008). + return run_cap_parse( + database, name, verbose, "MFP", + "((wlan.rsn.capabilities.mfpr == 1)||" + "(wlan.rsn.capabilities.mfpc == 1))&&" + "(wlan.fc.type_subtype == 0x0008)", + lambda cursor, pkt: _insert_one_mfp(cursor, verbose, pkt)) + + +def _wps_fields_for_pkt(pkt): + '''Decode the WPS attributes of one Beacon / Probe Response into + (bssid, fields), where fields' keys are the WPSRow attribute names. Every + attribute is read defensively with _safe(): an absent field yields '' + rather than raising, since a Beacon's reduced WPS IE legitimately omits + most of them.''' + wmgt = 'wlan.mgt' + bssid = _safe(lambda: pkt.wlan.sa.upper()) + # tshark exposes the SSID as colon-separated hex; decode it the same way as + # the other .cap parsers, defaulting to '' on a non-hex / undecodable value + # instead of raising "Non-hexadecimal digit found". + wlan_ssid = _safe(lambda: binascii.unhexlify( + pkt[wmgt].wlan_ssid.replace(':', '')).decode('ascii')) + # WPS 2.0 advertises itself through the Version2 extension; read it on its + # own so a non-hex SSID can no longer suppress the 2.0 flag. + wps_ext_version2 = _safe(lambda: pkt[wmgt].wps_ext_version2) + fields = { + 'wlan_ssid': wlan_ssid, + 'wps_version': '2.0' if '20' in (wps_ext_version2 or '') else '1.0', + 'wps_device_name': _safe(lambda: pkt[wmgt].wps_device_name), + 'wps_model_name': _safe(lambda: pkt[wmgt].wps_model_name), + 'wps_model_number': _safe(lambda: pkt[wmgt].wps_model_number), + 'wps_config_methods': _safe(lambda: pkt[wmgt].wps_config_methods), + 'wps_config_methods_keypad': _safe( + lambda: pkt[wmgt].wps_config_methods_keypad), + } + return bssid, fields + + +def _merge_wps_fields(acc, fields): + '''Sticky-merge one frame's WPS fields into the per-BSSID accumulator: keep + the first non-empty value for each attribute (so a later Beacon's reduced + IE never blanks a Probe Response's device/model name) and let wps_version + climb to '2.0'. Returns the (new or updated) accumulator dict.''' + if acc is None: + return dict(fields) + for key, value in fields.items(): + if key == 'wps_version': + if value == '2.0': + acc[key] = '2.0' + elif value and not acc.get(key): + acc[key] = value + return acc + + +# Get WPS (Wi-Fi Protected Setup) details from AP Beacons / Probe Responses. +def parse_WPS(name, database, verbose): + # A WPS-enabled AP re-advertises the same details in every Beacon and Probe + # Response, so collapse them to one merged row per BSSID and run insertWPS + # once per AP (in finalize) instead of once per frame. + # + # The rich WPS attributes (device/model name, model number, config methods) + # only appear in AP-originated Beacons (0x08) and, in full form, Probe + # Responses (0x05) that advertise the AP-only Wi-Fi Protected Setup State; + # that attribute is absent from client Probe Requests, so they are excluded + # and no client device lands in the AP table. + wps_by_bssid = {} + + def per_pkt(_cursor, pkt): + bssid, fields = _wps_fields_for_pkt(pkt) + if bssid: + wps_by_bssid[bssid] = _merge_wps_fields( + wps_by_bssid.get(bssid), fields) + return 0 + + def finalize(cursor): + errors = 0 + for bssid, fields in wps_by_bssid.items(): + if verbose: + print('==============================') + print(bssid, fields['wps_version']) + errors += database_utils.insertWPS( + cursor, verbose, + database_utils.WPSRow(bssid=bssid, **fields)) + return errors + + return run_cap_parse( + database, name, verbose, "WPS", + "wps.wifi_protected_setup_state && " + "(wlan.fc.type_subtype == 0x08 || wlan.fc.type_subtype == 0x05)", + per_pkt, catch_pkt_errors=False, finalize=finalize) diff --git a/utils/cap_runner.py b/utils/cap_runner.py new file mode 100644 index 0000000..b86400c --- /dev/null +++ b/utils/cap_runner.py @@ -0,0 +1,50 @@ +''' Shared driver for the pyshark-based .cap parsers. + +Every parser used to repeat the same scaffolding: open a FileCapture with a +display filter, loop over the packets, commit, print a ".cap " not in filedata: + if verbose: + print("ERROR, not end") + filedata = filedata[:filedata.rfind(" WPA3 + return "WPA2/WPA3" if akm_ints & {2, 4} else "WPA3" + if 18 in akm_ints: # OWE + return "OWE" + return "WPA2" + + +# Get RSN/WPA security details (AKM suites and ciphers) from beacons and +# probe responses. +def _akm_ints(akm_values): + '''Parse AKM suite type strings into the set of their integer values.''' + return {i for i in (_to_int(v) for v in akm_values) if i is not None} + + +def _security_row(mgt): + '''Build the RSN/WPA security row for one mgt frame, or None when the frame + carries no AKM suite (so the caller can skip it without marking it seen).''' + akm_values = _all_field_values(mgt, 'wlan_rsn_akms_type') + if not akm_values: + return None + pcs_values = _all_field_values(mgt, 'wlan_rsn_pcs_type') + gcs_values = _all_field_values(mgt, 'wlan_rsn_gcs_type') + akm_ints = _akm_ints(akm_values) + pmf, rsn_capabilities, mfpc, mfpr = _rsn_pmf(mgt) + return { + 'akm_suites': ", ".join(_dedupe( + [_suite_name(a, RSN_AKM_SUITES) for a in akm_values])), + 'pairwise_ciphers': ", ".join(_dedupe( + [_suite_name(p, RSN_CIPHERS) for p in pcs_values])), + 'group_cipher': ", ".join(_dedupe( + [_suite_name(g, RSN_CIPHERS) for g in gcs_values])), + 'wpa_version': _classify_wpa(akm_ints), + 'enterprise': _flag(akm_ints & RSN_ENTERPRISE_AKMS), + 'pmf': pmf, + 'rsn_capabilities': rsn_capabilities, + 'mfpc': mfpc, + 'mfpr': mfpr, + } + + +def _insert_one_security(cursor, verbose, file, bssid, mgt): + '''Store RSN/WPA security (and MFP) for one AP. Returns the insert error + count, or None when the frame has no AKM suite.''' + row = _security_row(mgt) + if row is None: + return None + if verbose: + print("Security for AP " + str(bssid) + ": " + row['wpa_version'] + + " [" + row['akm_suites'] + "] PMF=" + row['pmf']) + errors = database_utils.insertSecurity( + cursor, verbose, database_utils.SecurityRow( + bssid=bssid, wpa_version=row['wpa_version'], + akm_suites=row['akm_suites'], + pairwise_ciphers=row['pairwise_ciphers'], + group_cipher=row['group_cipher'], enterprise=row['enterprise'], + pmf=row['pmf'], rsn_capabilities=row['rsn_capabilities'], + file=file)) + # Beacons are far more common than the association frames parsed by + # parse_MFP, so also update the AP mfpc/mfpr from here. + if row['mfpc'] == 'True' or row['mfpr'] == 'True': + errors += database_utils.insertMFP( + cursor, verbose, bssid, row['mfpc'], row['mfpr']) + return errors + + +def parse_security(name, database, verbose): + seen = set() + + def per_pkt(cursor, pkt): + bssid, mgt = _pkt_bssid_mgt(pkt) + if _seen_or_invalid(bssid, mgt, seen): + return 0 + result = _insert_one_security(cursor, verbose, name, bssid, mgt) + if result is None: # no AKM suite on this frame; try later ones + return 0 + seen.add(bssid.upper()) + return result + + # Beacons (0x08) and probe responses (0x05) that carry an RSN IE. The + # per-packet body has no inner try (a bad frame is fatal), so errors are + # not caught per packet. + return run_cap_parse( + database, name, verbose, "Security", + "(wlan.fc.type_subtype == 0x08 || wlan.fc.type_subtype == 0x05) && " + "wlan.rsn.akms.type", + per_pkt, catch_pkt_errors=False) diff --git a/utils/text_parsers.py b/utils/text_parsers.py new file mode 100644 index 0000000..a47cc12 --- /dev/null +++ b/utils/text_parsers.py @@ -0,0 +1,89 @@ +''' Parse the text-based Aircrack/Kismet/Wigle CSV outputs (.kismet.csv, +airodump .csv and .log.csv) into the SQLite DB. The .kismet.netxml parser +lives in netxml_parser. These parsers do not need pyshark/tshark. ''' +# -*- coding: utf-8 -*- +import csv +import os + +from utils import oui +from utils import database_utils + + +def _is_ap_row(row): + '''True when an airodump .csv row is an AP row (not the BSSID header).''' + return len(row) > 13 and row[0] != "BSSID" + + +def _csv_insert_ap(cursor, verbose, ouiMap, row): + '''Insert one AP row from an airodump-ng .csv. Returns insert errors.''' + bssid = row[0] + firstTimeSeen = row[1] + essid = row[13].replace("'", "''") + manuf = oui.get_vendor(ouiMap, bssid, verbose) + encrypt = row[5] + row[6] + row[7] + return database_utils.insertAP( + cursor, verbose, database_utils.APRow( + bssid=bssid, essid=essid[1:], manuf=manuf, channel=row[3], + freqmhz="", carrier="", encryption=encrypt, packets_total=row[10], + lat=0, lon=0, cloaked='False', mfpc='False', mfpr='False', + firstTimeSeen=firstTimeSeen)) + + +def _csv_insert_station(cursor, verbose, ouiMap, row): + '''Insert one station row (plus its connection and probes) from a .csv.''' + mac = row[0] + firstTimeSeen = row[1] + manuf = oui.get_vendor(ouiMap, mac, verbose) + errors = database_utils.insertClients( + cursor, verbose, database_utils.ClientRow( + mac=mac, ssid='', manuf=manuf, client_type='W', + packets_total=row[4], device='Misc', firstTimeSeen=firstTimeSeen)) + if len(row) > 5 and row[5] != " (not associated) ": + errors += database_utils.insertConnected( + cursor, verbose, row[5].replace(' ', ''), row[0]) + contador = 6 + while contador < len(row) and row[contador] != "": + errors += database_utils.insertProbe( + cursor, verbose, row[0], row[contador], 0) + contador += 1 + return errors + + +def _parse_csv_rows(cursor, verbose, ouiMap, csv_reader): + '''Insert APs then stations from an airodump-ng .csv reader. Returns errors. + + The file lists every AP first, then a "Station MAC" header, then the + stations; `client` flips to True once that header is reached.''' + errors = 0 + client = False + for row in csv_reader: + if not row: + continue + if client is False and _is_ap_row(row): + errors += _csv_insert_ap(cursor, verbose, ouiMap, row) + if row[0] == "Station MAC": + client = True + elif client and len(row) > 5: + errors += _csv_insert_station(cursor, verbose, ouiMap, row) + return errors + + +def parse_csv(ouiMap, name, database, verbose): + '''Function to parse the .csv files''' + errors = 0 + if not os.path.isfile(name): + print(".csv missing") + return + try: + cursor = database.cursor() + with open(name, encoding='utf-8') as csv_file: + csv_reader = csv.reader( + (x.replace('\0', '') for x in csv_file), delimiter=',') + errors += _parse_csv_rows(cursor, verbose, ouiMap, csv_reader) + database.commit() + print(".csv OK, errors", errors) + except Exception as error: + errors += 1 + print("parse_csv " + str(error)) + print("Error in .csv") + print(".csv OK, errors", errors) diff --git a/utils/update.py b/utils/update.py index 2845ffc..d782130 100644 --- a/utils/update.py +++ b/utils/update.py @@ -1,14 +1,16 @@ +''' Check for and apply updates of wifi_db from GitHub ''' import os import sys -import subprocess -import requests import re +import subprocess # nosec B404 - only used with fixed, absolute-path commands +import requests def is_git_installed(): try: + # Fixed command with an absolute path and no shell or user input. subprocess.run(["/usr/bin/git", "--version"], stdout=subprocess.PIPE, - stderr=subprocess.PIPE, check=True) + stderr=subprocess.PIPE, check=True) # nosec B603 return True except FileNotFoundError: return False @@ -18,13 +20,18 @@ def is_git_installed(): def get_latest_github_release(repo_url): try: - response = requests.get(f"{repo_url}/releases/latest") + # (connect, read) timeouts: a short connect timeout makes the check + # bail out quickly when there is no internet access, while still + # allowing a slightly longer read for slow connections. + response = requests.get(f"{repo_url}/releases/latest", + timeout=(2, 5)) if response.status_code == 200: latest_release_tag = response.json()["tag_name"] return latest_release_tag - else: - return None - except Exception as e: + return None + except (requests.RequestException, KeyError, ValueError) as e: + # Network error, or a 200 response whose JSON is malformed or missing + # the "tag_name" field: treat all of them as "no update info". print(e) return None @@ -41,15 +48,52 @@ def is_git_repo(): return git_path_exists -def check_for_update(VERSION): +def _parse_versions(version, latest_release_tag): + '''Return (latest_tuple, current_tuple, latest_number) for the two version + strings, or None when either has no parseable d.d(.d) number. The tuples are + compared as ints so 1.10.0 > 1.6.0 (not a string compare).''' + latest_match = re.search(r'(\d+(\.\d+)+)', latest_release_tag) + current_match = re.search(r'(\d+(\.\d+)+)', version) + if not latest_match or not current_match: + return None + latest_number = latest_match.group(1) + current_number = current_match.group(1) + latest_tuple = tuple(int(part) for part in latest_number.split('.')) + current_tuple = tuple(int(part) for part in current_number.split('.')) + return latest_tuple, current_tuple, latest_number + + +def _prompt_and_update(script_dir, latest_release_tag_number): + '''Ask the user and, on yes, git pull + pip install requirements, then exit.''' + user_choice = input("A new version is available (v" + + latest_release_tag_number + + "). Do you want to update (Y/n)?: " + ).strip().lower() or "y" + if user_choice not in ("", "y", "Y"): + print("You chose not to update. Running the current version.") + return + print("Updating...") + # Fixed command, absolute path, no shell or user input. + update_process = subprocess.Popen(["/usr/bin/git", "pull"], + cwd=script_dir) # nosec B603 + # Wait for the Git pull operation to complete + update_process.wait() + # Install required packages using pip + install_process = subprocess.Popen( # nosec B603 + ["/usr/bin/python3", "-m", "pip", "install", "-r", + "requirements.txt"]) + install_process.wait() # Wait for the installation + print("Update complete. Please run again the script.") + sys.exit() + + +def check_for_update(version): repo_url = "https://api.github.com/repos/r4ulcl/wifi_db" - script_path = os.path.abspath(__file__) - script_dir = os.path.dirname(script_path) + script_dir = os.path.dirname(os.path.abspath(__file__)) if not is_git_installed(): print("Git is not installed on your system. Please install Git.") return - # sys.exit(1) if not is_git_repo(): print("The program is not in a Git folder. \ @@ -57,43 +101,22 @@ def check_for_update(VERSION): return latest_release_tag = get_latest_github_release(repo_url) + if not latest_release_tag: + print("Unable to check for updates.") + return - if latest_release_tag: - # Get only the number part, without v and -dev - latest_release_tag_number = re.search(r'(\d+(\.\d+)+)', - latest_release_tag).group(1) - current_number = re.search(r'(\d+(\.\d+)+)', VERSION).group(1) - # print(latest_release_tag_number) - # print(current_number) - if latest_release_tag_number > current_number: - user_choice = input("A new version is available (v" + - latest_release_tag_number + - "). Do you want to update (Y/n)?: " - ).strip().lower() or "y" - if user_choice in ("", "y", "Y"): - print("Updating...") - update_process = subprocess.Popen(["/usr/bin/git", "pull"], - cwd=script_dir) - # Wait for the Git pull operation to complete - update_process.wait() - # Install dependencies - requirements_file = "requirements.txt" - # Install required packages using pip - install_process = subprocess.Popen(["/usr/bin/python3", "-m", - "pip", "install", "-r", - requirements_file]) - install_process.wait() # Wait for the installation - - print("Update complete. Please run again the script.") - sys.exit() - else: - print("You chose not to update. Running the current version.") - elif latest_release_tag_number < current_number: - print("You are using a future version ;) ("+VERSION+").\n") - else: - print("You are using the latest version ("+VERSION+").\n") + parsed = _parse_versions(version, latest_release_tag) + if parsed is None: + print("Unable to parse version numbers.") + return + latest_version, current_version, latest_release_tag_number = parsed + + if latest_version > current_version: + _prompt_and_update(script_dir, latest_release_tag_number) + elif latest_version < current_version: + print("You are using a future/dev version ;) ("+version+").\n") else: - print("Unable to check for updates.") + print("You are using the latest version ("+version+").\n") if __name__ == "__main__": diff --git a/utils/view.sql b/utils/view.sql index cf215ca..eba7f09 100644 --- a/utils/view.sql +++ b/utils/view.sql @@ -36,10 +36,38 @@ SELECT Identity.bssid, AP.ssid, Identity.mac, Client.manuf, Identity.identity, I FROM Identity JOIN AP ON Identity.bssid = AP.bssid JOIN Client ON Identity.mac = Client.mac ORDER BY Identity.bssid; +DROP VIEW IF EXISTS CertificateAP; +CREATE VIEW IF NOT EXISTS CertificateAP AS +SELECT Certificate.bssid, AP.ssid, Certificate.cert_type, Certificate.subject_cn, Certificate.issuer_cn, Certificate.subject, Certificate.issuer, Certificate.not_before, Certificate.not_after, Certificate.public_key_algorithm, Certificate.public_key_size, Certificate.self_signed, Certificate.sha256_fingerprint +FROM Certificate JOIN AP ON Certificate.bssid = AP.bssid +ORDER BY Certificate.bssid; + +DROP VIEW IF EXISTS SecurityAP; +CREATE VIEW IF NOT EXISTS SecurityAP AS +SELECT AP.bssid, AP.ssid, AP.wpa_version, AP.akm_suites, AP.pairwise_ciphers, AP.group_cipher, AP.enterprise, AP.pmf, AP.rsn_capabilities, AP.rsn_capabilities_text, AP.mfpc, AP.mfpr +FROM AP +WHERE AP.wpa_version IS NOT NULL +ORDER BY AP.bssid; + +DROP VIEW IF EXISTS CapabilitiesAP; +CREATE VIEW IF NOT EXISTS CapabilitiesAP AS +SELECT AP.bssid, AP.ssid, AP.ft_80211r, AP.mobility_domain_id, AP.rrm_80211k, AP.bss_transition_80211v, AP.mbssid, AP.max_bssid_indicator, AP.csa, AP.csa_new_channel +FROM AP +WHERE AP.ft_80211r = 'True' OR AP.rrm_80211k = 'True' OR AP.bss_transition_80211v = 'True' OR AP.mbssid = 'True' OR AP.csa = 'True' +ORDER BY AP.bssid; + DROP VIEW IF EXISTS SummaryAP; CREATE VIEW IF NOT EXISTS SummaryAP AS -SELECT AP.ssid, COUNT(DISTINCT AP.bssid) as "APs count", AP.encryption, AP.manuf, AP.cloaked, count(DISTINCT Connected.mac) as "Clients count" -FROM AP LEFT JOIN Connected ON AP.bssid = Connected.bssid +SELECT + AP.ssid, + COUNT(DISTINCT AP.bssid) AS "APs count", + AP.encryption, + GROUP_CONCAT(DISTINCT AP.wpa_version) AS wpa_version, + GROUP_CONCAT(DISTINCT AP.pmf) AS pmf, + GROUP_CONCAT(DISTINCT AP.manuf) AS manuf, + AP.cloaked, + COUNT(DISTINCT Connected.mac) AS "Clients count" +FROM AP LEFT JOIN Connected ON AP.bssid = Connected.bssid WHERE AP.encryption != "" -group by AP.ssid -ORDER BY "APs count" DESC; \ No newline at end of file +GROUP BY AP.ssid, AP.encryption +ORDER BY "APs count" DESC, AP.ssid; \ No newline at end of file diff --git a/utils/wifi_constants.py b/utils/wifi_constants.py new file mode 100644 index 0000000..9db25f1 --- /dev/null +++ b/utils/wifi_constants.py @@ -0,0 +1,96 @@ +''' Shared constants for the aircrack/kismet/.cap parsers. + +EAP method types, RSN AKM / cipher suite selectors and the 802.11 +management-frame element (tag) numbers used to detect AP capabilities. +''' +# -*- coding: utf-8 -*- + + +# EAP method types as registered by IANA, used to label the authentication +# method seen for each identity. +# https://www.iana.org/assignments/eap-numbers/eap-numbers.xhtml +# Type 1 (Identity) is handled separately to capture the identity string. +EAP_METHOD_TYPES = { + '2': "EAP-Notification", + '3': "EAP-Legacy-Nak", + '4': "EAP-MD5", + '5': "EAP-OTP", + '6': "EAP-GTC", + '9': "EAP-RSA", + '10': "EAP-DSS", + '11': "EAP-KEA", + '12': "EAP-KEA-VALIDATE", + '13': "EAP-TLS", + '15': "EAP-SecurID", + '17': "EAP-LEAP", + '18': "EAP-SIM", + '19': "EAP-SRP-SHA1", + '21': "EAP-TTLS", + '23': "EAP-AKA", + '25': "EAP-PEAP", + '26': "MS-EAP-Authentication", + '29': "EAP-MSCHAPv2", + '43': "EAP-FAST", + '46': "EAP-PAX", + '47': "EAP-PSK", + '48': "EAP-SAKE", + '49': "EAP-IKEv2", + '50': "EAP-AKA'", + '51': "EAP-GPSK", + '52': "EAP-pwd", + '53': "EAP-EKE", + '54': "EAP-PT", + '55': "EAP-TEAP", +} + + +# RSN AKM (Authentication and Key Management) suite selectors, OUI 00-0F-AC. +# https://www.iana.org/assignments/... (IEEE 802.11 RSN suite types) +RSN_AKM_SUITES = { + '1': "802.1X", + '2': "PSK", + '3': "FT-802.1X", + '4': "FT-PSK", + '5': "802.1X-SHA256", + '6': "PSK-SHA256", + '7': "TDLS", + '8': "SAE", + '9': "FT-SAE", + '10': "AP-PeerKey", + '11': "802.1X-SuiteB-SHA256", + '12': "802.1X-SuiteB-SHA384", + '13': "FT-802.1X-SHA384", + '14': "FILS-SHA256", + '15': "FILS-SHA384", + '16': "FT-FILS-SHA256", + '17': "FT-FILS-SHA384", + '18': "OWE", + '19': "FT-PSK-SHA384", + '20': "PSK-SHA384", +} + +# AKM selectors that indicate an enterprise (802.1X / EAP) network. +RSN_ENTERPRISE_AKMS = {1, 3, 5, 11, 12, 13, 14, 15, 16, 17} + +# RSN cipher suite selectors, OUI 00-0F-AC. +RSN_CIPHERS = { + '0': "Use-Group", + '1': "WEP-40", + '2': "TKIP", + '4': "CCMP-128", + '5': "WEP-104", + '6': "BIP-CMAC-128", + '8': "GCMP-128", + '9': "GCMP-256", + '10': "CCMP-256", + '11': "BIP-GMAC-128", + '12': "BIP-GMAC-256", + '13': "BIP-CMAC-256", +} + +# 802.11 management-frame element (tag) numbers used to detect AP capabilities. +TAG_MOBILITY_DOMAIN = 54 # 802.11r Fast BSS Transition (MDE) +TAG_RM_ENABLED_CAP = 70 # 802.11k Radio Resource Measurement (neighbor rep.) +TAG_MULTIPLE_BSSID = 71 # Multiple BSSID set +TAG_CHANNEL_SWITCH = 37 # Channel Switch Announcement (CSA) +TAG_EXTENDED_CSA = 60 # Extended Channel Switch Announcement diff --git a/utils/wifi_db_aircrack.py b/utils/wifi_db_aircrack.py index 5fe2dc4..baeca32 100644 --- a/utils/wifi_db_aircrack.py +++ b/utils/wifi_db_aircrack.py @@ -1,694 +1,41 @@ #!/bin/python3 -''' Parse Aircrack, Kismet and Wigle output to a SQLite DB ''' +''' Parse Aircrack, Kismet and Wigle output to a SQLite DB. + +Compatibility facade. The parsers were split into cohesive submodules to keep +each file small: + +* :mod:`utils.wifi_constants` -- EAP/RSN/tag constant tables. +* :mod:`utils.cap_common` -- asyncio child-watcher shim, the single pyshark + import and the shared pyshark field helpers. +* :mod:`utils.netxml_parser` -- .kismet.netxml parser (no pyshark needed). +* :mod:`utils.text_parsers` -- .kismet.csv / airodump .csv / .log.csv parsers + (no pyshark needed). +* :mod:`utils.cert_parsers` -- X.509 certificate extraction (fields in + :mod:`utils.cert_fields`). +* :mod:`utils.beacon_parsers` -- RSN/WPA security, 11r/k/v capabilities and + hidden-SSID recovery from beacons. +* :mod:`utils.cap_parsers` -- handshakes, MFP, WPS, identities, EAP-MD5, + probe fingerprints, hcxpcapngtool and the + ``parse_cap`` dispatcher. + +The public ``parse_*`` entry points are re-exported here so existing callers +(``wifi_db.py`` and the tests) keep importing them from ``wifi_db_aircrack``. +''' # -*- coding: utf-8 -*- -import csv -# import xml.etree.ElementTree as ET # vuln! -import defusedxml.ElementTree as ET -import os -import re -from utils import oui -import ftfy -from utils import database_utils -import pyshark -import subprocess -# import platform -import binascii -import datetime - - -def parse_netxml(ouiMap, name, database, verbose): - '''Function to parse the .kismet.netxml files''' - - filename = name - exists = os.path.isfile(filename) - errors = 0 - try: - cursor = database.cursor() - if exists: - with open(filename, 'r') as file: - filedata = file.read() - # fix aircrack error, remove spaces &#x 0; - filedata = re.sub(r'&#x[ ]+', '&#x', filedata) - - # fix aircrack error, remove NULL byte � - filedata = filedata.replace('�', '') - filedata = filedata.replace('�', '') - # fix xml not well formed, end before write all the file - if "" not in filedata: - if verbose: - print("ERROR, not end") - filedata = filedata[:filedata.rfind(" 35 and row[0] != "Network": - try: - bssid = row[3] - essid = row[2] - essid = essid.replace("'", "''") - - # firstTimeSeen - firstTimeSeen_string = row[19] - - date_object = datetime.datetime.strptime( - firstTimeSeen_string, "%a %b %d %H:%M:%S %Y" - ) - firstTimeSeen = date_object.strftime( - "%Y-%m-%d %H:%M:%S" - ) - - manuf = oui.get_vendor(ouiMap, bssid, verbose) - - channel = row[5] - freqmhz = 0 - carrier = "" - encryption = row[7] - packets_total = row[16] - lat = row[32] - lon = row[33] - cloaked = 'False' - mfpc = 'False' - mfpr = 'False' - errors += database_utils.insertAP( - cursor, verbose, bssid, essid, manuf, channel, - freqmhz, carrier, encryption, packets_total, - lat, lon, cloaked, mfpc, mfpr, firstTimeSeen) - - # manuf y carrier implementar - except Exception as error: - if verbose: - print("Uncontrolled error UPDATE AP " - "kismet csv: ", error) - - database.commit() - print(".kismet.csv OK, errors", errors) - else: - print(".kismet.csv missing") - except Exception as error: - errors += 1 - print("parse_kismet_csv " + str(error)) - print("Error in kismet.csv") - print(".kismet.csv OK, errors", errors) - - -def parse_csv(ouiMap, name, database, verbose): - '''Function to parse the .csv files''' - exists = os.path.isfile(name) - errors = 0 - try: - cursor = database.cursor() - if exists: - with open(name) as csv_file: - csv_reader = csv.reader((x.replace('\0', '') - for x in csv_file), delimiter=',') - client = False - for row in csv_reader: - if row: - if client is False and len(row) > 13 \ - and row[0] != "BSSID": - # insert AP de aqui tambien - bssid = row[0] - firstTimeSeen = row[1] - essid = row[13] - essid = essid.replace("'", "''") - manuf = oui.get_vendor(ouiMap, bssid, verbose) - channel = row[3] - freq = "" - carrier = "" - encrypt = row[5] + row[6] + row[7] - packets_total = row[10] - cloaked = 'False' - - mfpc = 'False' - mfpr = 'False' - - errors += database_utils.insertAP( - cursor, verbose, bssid, essid[1:], manuf, - channel, freq, carrier, encrypt, - packets_total, 0, 0, cloaked, mfpc, mfpr, - firstTimeSeen) - - if row and row[0] == "Station MAC": - client = True - elif row and client and len(row) > 5: - # print(row[0]) - mac = row[0] - firstTimeSeen = row[1] - manuf = oui.get_vendor(ouiMap, mac, verbose) - packets = row[4] - # print(mac, manuf) - - errors += database_utils.insertClients( - cursor, verbose, mac, '', manuf, 'W', - packets, 'Misc', firstTimeSeen) - - if len(row) > 5 and row[5] != " (not associated) ": - a = database_utils.insertConnected( - cursor, verbose, row[5].replace(' ', ''), - row[0]) - - errors += a - - contador = 6 - while contador < len(row) and row[contador] != "": - errors += database_utils.insertProbe( - cursor, verbose, row[0], row[contador], 0) - contador += 1 - database.commit() - - print(".csv OK, errors", errors) - else: - print(".csv missing") - except Exception as error: - errors += 1 - print("parse_csv " + str(error)) - print("Error in .csv") - print(".csv OK, errors", errors) - - -def parse_log_csv(ouiMap, name, database, verbose, fake_lat, fake_lon): - ''' Parse .log.csv file from Aircrack-ng to the database ''' - exists = os.path.isfile(name) - errors = 0 - try: - cursor = database.cursor() - if exists: - with open(name) as csv_file: - csv_reader = csv.reader(csv_file, delimiter=',') - for row in csv_reader: - time = row[0] - if time != "LocalTime": - if len(row) > 10 and row[10] == "Client": - mac = row[3] - manuf = oui.get_vendor(ouiMap, mac, verbose) - signal_rssi = row[4] - lat = row[6] - lon = row[7] - if fake_lat != "": # just write file in db - lat = fake_lat - if fake_lon != "": - lon = fake_lon - ssid = "" - typeAux = "" - packets_total = "" - device = "" - errors += database_utils.insertClients( - cursor, verbose, mac, ssid, manuf, - typeAux, packets_total, device, time) - - errors += database_utils.insertSeenClient( - cursor, verbose, mac, time, - 'aircrack-ng', signal_rssi, lat, lon, - '0.0') - - if len(row) > 10 and row[10] == "AP": - lat = row[6] - lon = row[7] - if fake_lat != "": - lat = fake_lat - if fake_lon != "": - lon = fake_lon - manuf = oui.get_vendor(ouiMap, row[3], verbose) - cloaked = 'False' - mfpc = 'False' - mfpr = 'False' - errors += database_utils.insertAP( - cursor, verbose, row[3], row[2], - manuf, 0, 0, '', '', 0, lat, lon, - cloaked, mfpc, mfpr, time) - - # if row[6] != "0.000000": - errors += database_utils.insertSeenAP( - cursor, verbose, row[3], time, - 'aircrack-ng', row[4], lat, lon, - '0.0', 0) - - database.commit() - print(".log.csv done, errors", errors) - else: - print(".log.csv missing") - except Exception as error: - errors += 1 - print("parse_log_csv " + str(error)) - print("Error in log") - print(".log.csv done, errors", errors) - - -def parse_cap(name, database, verbose, hcxpcapngtool, tshark): - if tshark: - parse_handshakes(name, database, verbose) - parse_WPS(name, database, verbose) - parse_identities(name, database, verbose) - parse_MFP(name, database, verbose) - if hcxpcapngtool: - exec_hcxpcapngtool(name, database, verbose) - - -# Get handshakes from .cap -def parse_handshakes(name, database, verbose): - try: - cursor = database.cursor() - errors = 0 - file = name - cap = pyshark.FileCapture(file, display_filter="eapol") - # cap.set_debug() - prevSrc = "" - prevDst = "" - prevFlag = "" - - for pkt in cap: - try: - if verbose: - print(pkt.eapol.field_names) - print(pkt.eapol.type) - if pkt.eapol.type == '3': # EAPOL = 3 - src = pkt.wlan.ta - dst = pkt.wlan.da - flag = pkt.eapol.wlan_rsna_keydes_key_info - # print(flag) - # IF is the second and the prev is the first one - # add handshake - if flag.find('10a') != -1: - # print('handhsake 2 of 4') - if (prevFlag.find('08a') - and dst == prevSrc and src == prevDst): - # first - if verbose: - print("Valid handshake from client " + - prevSrc + " to AP " + prevDst) - errors += database_utils.insertHandshake(cursor, - verbose, - dst, - src, file) - else: - prevSrc = src - prevDst = dst - prevFlag = flag - except Exception as error: - errors += 1 - if verbose: - print(error) - database.commit() - print(".cap Handshake done, errors", errors) - except pyshark.capture.capture.TSharkCrashException as error: - errors += 1 - print("Error in parse_handshakes (CAP), probably PCAP cut in the " - "middle of a packet: ", error) - print(".cap Handshake done, errors", errors) - except Exception as error: - errors += 1 - print("Error in parse_handshakes (CAP): ", error) - print(".cap Handshake done, errors", errors) - - -# Get MFP data from .cap -def parse_MFP(name, database, verbose): - try: - cursor = database.cursor() - errors = 0 - file = name - # cap = pyshark.FileCapture(file, - # display_filter='wlan.fc.type_subtype == 0x0008') - # Filter only with mfpr or mfpc enable - cap = pyshark.FileCapture(file, - display_filter='\ - ((wlan.rsn.capabilities.mfpr == 1)||\ - (wlan.rsn.capabilities.mfpc == 1))&&\ - (wlan.fc.type_subtype == 0x0008)') - # cap.set_debug() - - for pkt in cap: - try: - mfpc = 'False' - mfpr = 'False' - if pkt['wlan.mgt'].wlan_rsn_capabilities and pkt.wlan.ta: - capabilities = pkt['wlan.mgt'].wlan_rsn_capabilities - # 0x0000008c MFPC only enable - if capabilities == '0x0000008c': - mfpc = 'True' - # 0x000000cc MFP C and R enable - elif capabilities == '0x000000cc': - mfpc = 'True' - mfpr = 'True' - # mfpc = int(capabilities, 16) & 0x01 - # mfpr = (int(capabilities, 16) & 0x02) >> 1 - src = pkt.wlan.ta - # if mfpc is 1 insert in DB - if mfpc == 'True' or mfpr == 'True': - if verbose: - print(f"MFPC: {mfpc}") - print(f"MFPR: {mfpr}") - errors += database_utils.insertMFP(cursor, - verbose, - src, mfpc, - mfpr, file) - # wlan_options = pkt['wlan.mgt'].field_names - # print(wlan_options) - # print(pkt['wlan.mgt']) - except Exception as error: - errors += 1 - if verbose: - print(error) - database.commit() - print(".cap MFP done, errors", errors) - except pyshark.capture.capture.TSharkCrashException as error: - errors += 1 - print("Error in parse_MFP (CAP), probably PCAP cut in the " - "middle of a packet: ", error) - print(".cap MFP done, errors", errors) - except Exception as error: - errors += 1 - print("Error in parse_MFP (CAP): ", error) - print(".cap MFP done, errors", errors) - - -# Get handshakes from .cap -def parse_WPS(name, database, verbose): - try: - cursor = database.cursor() - errors = 0 - file = name - cap = pyshark.FileCapture( - file, display_filter="wps.wifi_protected_setup_state == 0x02 and\ - wlan.da == ff:ff:ff:ff:ff:ff") - # cap.set_debug() - - for pkt in cap: - # print(dir(pkt['wlan.mgt'].wps_version)) - bssid = '' - wlan_ssid = '' - wps_device_name = '' - wps_model_name = '' - wps_model_number = '' - wps_config_methods = '' - wps_config_methods_keypad = '' - wps_version = '1.0' # Default 1.0 - wmgt = 'wlan.mgt' - try: - wlan_ssid = pkt['wlan.mgt'].wlan_ssid - bssid = pkt.wlan.sa - bssid = bssid.upper() - except Exception: - errors += 1 - try: - w_s_hex = pkt[wmgt].wlan_ssid - wlan_ssid_bytes = binascii.unhexlify(w_s_hex.replace(':', '')) - wlan_ssid_decode = wlan_ssid_bytes.decode('ascii') - if wlan_ssid_decode != "": - wlan_ssid = wlan_ssid_decode - if ('20' in pkt[wmgt].wps_ext_version2): - wps_version = '2.0' - except Exception as e: - if verbose: - print(e) - errors += 1 - try: - wps_device_name = pkt[wmgt].wps_device_name - except Exception: - errors += 1 - try: - wps_model_name = pkt[wmgt].wps_model_name - except Exception: - errors += 1 - try: - wps_model_number = pkt[wmgt].wps_model_number - except Exception: - errors += 1 - try: - wps_config_methods = pkt[wmgt].wps_config_methods - except Exception: - errors += 1 - try: - wps_config_methods_keypad = pkt[wmgt].wps_config_methods_keypad - except Exception: - errors += 1 - - try: - if verbose: - print('==============================') - print(bssid) - print(wps_version) - print(pkt[wmgt].wps_ext_version2) - except Exception: - errors += 1 - - errors += database_utils.insertWPS(cursor, verbose, bssid, - wlan_ssid, wps_version, - wps_device_name, wps_model_name, - wps_model_number, - wps_config_methods, - wps_config_methods_keypad) - - print(".cap WPS done, errors", errors) - except pyshark.capture.capture.TSharkCrashException as error: - errors += 1 - print("Error in parse_WPS (CAP), probably PCAP cut in the " - "middle of a packet: ", error) - print(".cap WPS done, errors", errors) - except Exception: - errors += 1 - print("Critical error in parse_WPS (CAP)") - print(".cap WPS done, errors", errors) - - -# Get Identities from MGT login -def parse_identities(name, database, verbose): - try: - cursor = database.cursor() - errors = 0 - file = name - cap = pyshark.FileCapture(file, display_filter="eap") - # cap.set_debug() - - dst = "" - src = "" - identity = "" - method = "" - - # The information is: Identity, method, method... , - # Identity2, method2, method2... - for pkt in cap: - # print(pkt.eapol.field_names) - try: - if pkt.eap.type == '1': # EAP = 1 - dst = pkt.wlan.da - src = pkt.wlan.sa - if pkt.eap.code == '2': - try: - identity = pkt.eap.identity - except Exception as error: - errors += 1 - if verbose: - print(error) - # EAP-PEAP - elif pkt.eap.type == '25': # Found EAP-PEAP - method = "EAP-PEAP" - # Insert, if its already error and continue - database_utils.insertIdentity(cursor, verbose, - dst, src, identity, method) - - elif pkt.eap.type == '13': # Found EAP-TLS - method = "EAP-TLS" - database_utils.insertIdentity(cursor, verbose, - dst, src, identity, method) - else: - method = "OTHER (NOT EAP-PEAP OR EAP-TLS) - ID: " + \ - pkt.eap.type - database_utils.insertIdentity(cursor, verbose, - dst, src, identity, method) - except Exception as e: - if verbose: - print("ERROR:", e) - errors += 1 - - database.commit() - print(".cap Identity done, errors", errors) - except pyshark.capture.capture.TSharkCrashException as error: - errors += 1 - print("Error in parse_identities (CAP), probably PCAP cut in the " - "middle of a packet: ", error) - print(".cap Identity done, errors", errors) - except Exception as error: - errors += 1 - print("Error in parse_identities (CAP): ", error) - print(".cap Identity done, errors", errors) - - -# Use hcxpcapngtool to get the 22000 hash to hashcat -def exec_hcxpcapngtool(name, database, verbose): - try: - # cmd = "where" if platform.system() == "Windows" else "which" - # subprocess.call([cmd, "hcxpcapngtool"]) - cursor = database.cursor() - errors = 0 - fileName = name - # exec_hcxpcapngtool - execute_process = subprocess.Popen(["/usr/bin/hcxpcapngtool", "--all", - fileName, "-o", "test.22000"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) - execute_process.wait() # Wait for the installation process to complete - # Read output (fileName) each line - file_exists = os.path.exists('test.22000') - if not file_exists: - return - with open('test.22000') as f: - lines = f.readlines() - for line in lines: - # update in database aka insert_hash - split = line.split('*') - ap_lower = split[3].upper() - client_lower = split[4].upper() - # : format - ap = (':'.join(ap_lower[i:i + 2] for i in range(0, 12, 2))) - client = (':'.join(client_lower[i:i + 2] for i in - range(0, 12, 2))) - if verbose: - print(ap) - print(client) - print(line) - # Update handshake - - errors += database_utils.setHashcat(cursor, verbose, - ap, client, fileName, - line) - os.remove("test.22000") - print(".cap hcxpcapngtool done, errors", errors) - - except Exception as error: - errors += 1 - print("Error in exec_hcxpcapngtool (CAP): ", error) - print(".cap hcxpcapngtool done, errors", errors) +from utils.netxml_parser import parse_netxml +from utils.text_parsers import parse_csv +from utils.log_parsers import parse_kismet_csv, parse_log_csv +from utils.cert_parsers import parse_certificates +from utils.beacon_parsers import parse_capabilities, parse_hidden_ssid +from utils.security_parsers import parse_security +from utils.cap_parsers import ( + parse_cap, parse_handshakes, parse_MFP, parse_WPS, parse_identities, + parse_eap_md5, parse_probe_fingerprint, exec_hcxpcapngtool) + +__all__ = [ + "parse_netxml", "parse_kismet_csv", "parse_csv", "parse_log_csv", + "parse_certificates", "parse_security", "parse_capabilities", + "parse_hidden_ssid", "parse_cap", "parse_handshakes", "parse_MFP", + "parse_WPS", "parse_identities", "parse_eap_md5", + "parse_probe_fingerprint", "exec_hcxpcapngtool", +] diff --git a/utils/wifi_db_database.sql b/utils/wifi_db_database.sql index 280d6c8..603ab7f 100644 --- a/utils/wifi_db_database.sql +++ b/utils/wifi_db_database.sql @@ -14,6 +14,31 @@ CREATE TABLE IF NOT EXISTS AP mfpc BOOLEAN, mfpr BOOLEAN, firstTimeSeen timestamp, + wpa_version TEXT, + akm_suites TEXT, + pairwise_ciphers TEXT, + group_cipher TEXT, + enterprise BOOLEAN, + pmf TEXT, + rsn_capabilities TEXT, + rsn_capabilities_text TEXT, + wlan_ssid TEXT, + wps_version TEXT, + wps_device_name TEXT, + wps_model_name TEXT, + wps_model_number TEXT, + wps_config_methods TEXT, + wps_config_methods_text TEXT, + wps_config_methods_keypad TEXT, + ft_80211r BOOLEAN, + mobility_domain_id TEXT, + rrm_80211k BOOLEAN, + bss_transition_80211v BOOLEAN, + mbssid BOOLEAN, + max_bssid_indicator int, + csa BOOLEAN, + csa_new_channel int, + ssid_revealed BOOLEAN, CONSTRAINT Key1 PRIMARY KEY (bssid) ); @@ -25,6 +50,7 @@ CREATE TABLE IF NOT EXISTS Client type TEXT, packetsTotal int, device TEXT, + randomized BOOLEAN, firstTimeSeen timestamp, CONSTRAINT Key1 PRIMARY KEY (mac) ); @@ -53,21 +79,6 @@ CREATE TABLE IF NOT EXISTS Connected CONSTRAINT Relationship3 FOREIGN KEY (mac) REFERENCES Client (mac) ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS WPS -( - bssid TEXT NOT NULL, - wlan_ssid TEXT NOT NULL, - wps_version TEXT NOT NULL, - wps_device_name TEXT NOT NULL, - wps_model_name TEXT NOT NULL, - wps_model_number TEXT NOT NULL, - wps_config_methods TEXT NOT NULL, - wps_config_methods_keypad TEXT NOT NULL, - CONSTRAINT KeyWPS PRIMARY KEY (bssid), - CONSTRAINT RelationshipWPS FOREIGN KEY (bssid) REFERENCES AP (bssid) ON UPDATE CASCADE ON DELETE CASCADE -); - - CREATE TABLE IF NOT EXISTS SeenAp ( bssid TEXT NOT NULL, @@ -88,6 +99,9 @@ CREATE TABLE IF NOT EXISTS Probe mac TEXT NOT NULL, ssid TEXT NOT NULL, time datetime, + fingerprint TEXT, + ie_order TEXT, + file TEXT, CONSTRAINT Key5 PRIMARY KEY (mac,ssid), CONSTRAINT ProbesSent FOREIGN KEY (mac) REFERENCES Client (mac) ON UPDATE CASCADE ON DELETE CASCADE ); @@ -112,6 +126,7 @@ CREATE TABLE IF NOT EXISTS Identity mac TEXT NOT NULL, identity TEXT NOT NULL, method TEXT NOT NULL, + realm TEXT, CONSTRAINT Key7 PRIMARY KEY (bssid,mac,identity) CONSTRAINT FRelationship6 FOREIGN KEY (bssid) REFERENCES AP (bssid) ON UPDATE CASCADE ON DELETE CASCADE, CONSTRAINT FRelationship7 FOREIGN KEY (mac) REFERENCES Client (mac) ON UPDATE CASCADE ON DELETE CASCADE @@ -125,4 +140,64 @@ CREATE TABLE IF NOT EXISTS Files hashSHA TEXT NOT NULL, time datetime, CONSTRAINT Key8 PRIMARY KEY (file,hashSHA) -); \ No newline at end of file +); + + +CREATE TABLE IF NOT EXISTS Certificate +( + bssid TEXT NOT NULL, + mac TEXT, + cert_type TEXT, + file TEXT, + cert_index int, + version TEXT, + serial_number TEXT, + signature_algorithm TEXT, + issuer TEXT, + subject TEXT, + not_before timestamp, + not_after timestamp, + subject_cn TEXT, + subject_o TEXT, + subject_ou TEXT, + issuer_cn TEXT, + issuer_o TEXT, + issuer_ou TEXT, + public_key_algorithm TEXT, + public_key_size int, + public_key_curve TEXT, + public_key_exponent TEXT, + subject_alt_names TEXT, + key_usage TEXT, + ext_key_usage TEXT, + is_ca BOOLEAN, + path_length int, + self_signed BOOLEAN, + authority_key_id TEXT, + subject_key_id TEXT, + crl_urls TEXT, + ocsp_urls TEXT, + validity_days int, + sha1_fingerprint TEXT, + sha256_fingerprint TEXT NOT NULL, + CONSTRAINT KeyCert PRIMARY KEY (bssid,sha256_fingerprint), + CONSTRAINT RelationshipCert FOREIGN KEY (bssid) REFERENCES AP (bssid) ON UPDATE CASCADE ON DELETE CASCADE +); + + +CREATE TABLE IF NOT EXISTS EAPMD5 +( + bssid TEXT NOT NULL, + mac TEXT NOT NULL, + identity TEXT, + eap_id TEXT NOT NULL, + challenge TEXT, + response TEXT, + hashcat TEXT, + file TEXT, + CONSTRAINT KeyEAPMD5 PRIMARY KEY (bssid,mac,eap_id), + CONSTRAINT RelationshipEAPMD5AP FOREIGN KEY (bssid) REFERENCES AP (bssid) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT RelationshipEAPMD5Client FOREIGN KEY (mac) REFERENCES Client (mac) ON UPDATE CASCADE ON DELETE CASCADE +); + + diff --git a/wifi_db.py b/wifi_db.py index f91db0a..40dc827 100755 --- a/wifi_db.py +++ b/wifi_db.py @@ -3,22 +3,24 @@ # -*- coding: utf-8 -*- import argparse -from utils import wifi_db_aircrack -from utils import update -from utils import database_utils -from utils import oui import os -from os import path import platform -import subprocess -import nest_asyncio +import subprocess # nosec B404 - only used with fixed, non-shell commands +import sys import re +import nest_asyncio +from utils import update +from utils import database_utils +from utils import oui +# The capture ingestion pipeline lives in utils/capture_pipeline.py; main() +# builds a Context and hands each capture path to handle_capture. +from utils.capture_pipeline import Context, handle_capture # import nest_asyncio ; nest_asyncio.apply() -> -# Fix RuntimeError: This event loop is already running” +# Fix RuntimeError: This event loop is already running -VERSION = '1.5' +VERSION = '1.6.0' def banner(): @@ -41,14 +43,8 @@ def replace_multiple_slashes(string): return re.sub('/+', '/', string) -def main(): - nest_asyncio.apply() - - # Check for update - update.check_for_update(VERSION) - - '''Function main. Parse argument and exec the functions ''' - # args +def build_arg_parser(): + '''Build and return the argparse parser for the CLI.''' parser = argparse.ArgumentParser() parser.add_argument("-V", "--version", help="write the wifi_db version", action="store_true") @@ -87,16 +83,47 @@ def main(): "extension is provided, all types will be added. " "This option supports the use of " "wildcards (*) to select multiple files or folders.") + return parser + + +def _tool_available(tool): + '''Return True if `tool` is found on PATH via which/where.''' + try: + cmd = "where" if platform.system() == "Windows" else "which" + # Fixed command (which/where) with a fixed argument, no shell. + subprocess.call([cmd, tool]) # nosec B603 + return True + except OSError as E: + print("False", E) + return False + + +def detect_tools(): + '''Detect the optional external tools used to enrich captures.''' + hcxpcapngtool = _tool_available("hcxpcapngtool") + tshark = _tool_available("tshark") + return hcxpcapngtool, tshark + + +def main(): + '''Function main. Parse argument and exec the functions ''' + nest_asyncio.apply() + + # Check for update + update.check_for_update(VERSION) + + # args + parser = build_arg_parser() args = parser.parse_args() if args.version: printVersion() - exit() + sys.exit() if not args.capture: print("wifi_db.py: error: the following arguments" + " are required: capture") - exit() + sys.exit() # vars # version = args.version @@ -105,21 +132,7 @@ def main(): obfuscated = args.obfuscated force = args.force - try: - cmd = "where" if platform.system() == "Windows" else "which" - subprocess.call([cmd, "hcxpcapngtool"]) - hcxpcapngtool = True - except Exception as E: - hcxpcapngtool = False - print("False", E) - - try: - cmd = "where" if platform.system() == "Windows" else "which" - subprocess.call([cmd, "tshark"]) - tshark = True - except Exception as E: - tshark = False - print("False", E) + hcxpcapngtool, tshark = detect_tools() name = args.database captures = args.capture @@ -143,59 +156,17 @@ def main(): ouiMap = oui.load_vendors() + ctx = Context(ouiMap=ouiMap, database=database, verbose=verbose, + fake_lat=fake_lat, fake_lon=fake_lon, + hcxpcapngtool=hcxpcapngtool, tshark=tshark, force=force) + for capture in captures: # Remove the trailing forward slash, if it exists if capture.endswith('/'): capture = capture[:-1] capture = replace_multiple_slashes(capture) - if source == "aircrack-ng": - # If it is a folder... - if path.isdir(capture): - print("Parsing folder:", capture) - files = [] - dirpath = os.getcwd() - if os.path.isabs(capture): - dir_capture = capture - else: - dir_capture = dirpath + "/" + capture - if verbose: - print(dir_capture) - print("current directory is : " + dirpath) - - for file in os.listdir(dir_capture): - if (('.cap' in file) or ('.csv' in file) - or ('.kismet.csv' in file) - or ('kismet.netxml' in file) - or ('.log.csv' in file)): - files.append(file) - # Sorted reverse to cap last by name and extension - files.sort(key=os.path.splitext, reverse=True) - print(files) - - counter = 0 - # for each file with correct format of folder ... - for f in files: - counter += 1 - print("File: " + str(counter) + " of " + str(len(files))) - capture_aux = dir_capture + "/" + f - print("\n" + capture_aux) - process_capture(ouiMap, capture_aux, database, - verbose, fake_lat, fake_lon, - hcxpcapngtool, tshark, force) - - else: # it is a file - print("Parsing file:", capture) - process_capture(ouiMap, capture, database, - verbose, fake_lat, fake_lon, - hcxpcapngtool, tshark, force) - - elif source == "kismet": - print("Parsing Kismet capture") - # TO DO - else: - print("Parsing Wigle capture") - # TO DO + handle_capture(ctx, capture, source) # Cleat whitelist MACs script_path = os.path.dirname(os.path.abspath(__file__)) @@ -212,119 +183,6 @@ def main(): + "' or other SQLITE program to view the data") -def process_capture(ouiMap, capture, database, - verbose, fake_lat, fake_lon, - hcxpcapngtool, tshark, force): - cursor = database.cursor() - - if database_utils.checkFileProcessed(cursor, - verbose, capture) == 1 and not force: - print("File", "already processed\n") - else: - if ".cap" in capture: - database_utils.insertFile(cursor, verbose, capture) - wifi_db_aircrack.parse_cap(capture, database, verbose, - hcxpcapngtool, tshark) - database_utils.setFileProcessed(cursor, verbose, capture) - elif ".kismet.netxml" in capture: - database_utils.insertFile(cursor, verbose, capture) - wifi_db_aircrack.parse_netxml(ouiMap, capture, - database, verbose) - database_utils.setFileProcessed(cursor, verbose, capture) - elif ".kismet.csv" in capture: - database_utils.insertFile(cursor, verbose, capture) - wifi_db_aircrack.parse_kismet_csv(ouiMap, capture, - database, verbose) - database_utils.setFileProcessed(cursor, verbose, capture) - elif ".log.csv" in capture: - database_utils.insertFile(cursor, verbose, capture) - wifi_db_aircrack.parse_log_csv(ouiMap, capture, - database, verbose, fake_lat, - fake_lon) - database_utils.setFileProcessed(cursor, verbose, capture) - elif ".csv" in capture: - database_utils.insertFile(cursor, verbose, capture) - wifi_db_aircrack.parse_csv(ouiMap, capture, - database, verbose) - database_utils.setFileProcessed(cursor, verbose, capture) - else: - print("Not format found!") - # Remove dot at end if not format found - if capture.endswith('.'): - capture = capture[:-1] - - captureFormat = capture + ".kismet.netxml" - print("Parsing file:", captureFormat) - if ( - database_utils.checkFileProcessed( - cursor, verbose, captureFormat - ) == 1 and not force - ): - print("File", "already processed\n") - else: - database_utils.insertFile(cursor, verbose, captureFormat) - wifi_db_aircrack.parse_netxml(ouiMap, captureFormat, - database, verbose) - database_utils.setFileProcessed(cursor, verbose, captureFormat) - - captureFormat = capture + ".kismet.csv" - print("Parsing file:", captureFormat) - if ( - database_utils.checkFileProcessed( - cursor, verbose, captureFormat - ) == 1 and not force - ): - print("File", "already processed\n") - else: - database_utils.insertFile(cursor, verbose, captureFormat) - wifi_db_aircrack.parse_kismet_csv(ouiMap, captureFormat, - database, verbose) - database_utils.setFileProcessed(cursor, verbose, captureFormat) - - captureFormat = capture + ".csv" - print("Parsing file:", captureFormat) - if ( - database_utils.checkFileProcessed( - cursor, verbose, captureFormat - ) == 1 and not force - ): - print("File", "already processed\n") - else: - database_utils.insertFile(cursor, verbose, captureFormat) - wifi_db_aircrack.parse_csv(ouiMap, captureFormat, - database, verbose) - database_utils.setFileProcessed(cursor, verbose, captureFormat) - - captureFormat = capture + ".log.csv" - print("Parsing file:", captureFormat) - if ( - database_utils.checkFileProcessed( - cursor, verbose, captureFormat - ) == 1 and not force - ): - print("File", "already processed\n") - else: - database_utils.insertFile(cursor, verbose, captureFormat) - wifi_db_aircrack.parse_log_csv(ouiMap, captureFormat, - database, verbose, fake_lat, - fake_lon) - database_utils.setFileProcessed(cursor, verbose, captureFormat) - - captureFormat = capture + ".cap" - print("Parsing file:", captureFormat) - if ( - database_utils.checkFileProcessed( - cursor, verbose, captureFormat - ) == 1 and not force - ): - print("File", "already processed\n") - else: - database_utils.insertFile(cursor, verbose, captureFormat) - wifi_db_aircrack.parse_cap(captureFormat, database, verbose, - hcxpcapngtool, tshark) - database_utils.setFileProcessed(cursor, verbose, captureFormat) - - if __name__ == "__main__": banner() main()