Skip to main content

phish-signals (Python)

An independent Python implementation of the same detection engine as ../typescript/ — not a binding or wrapper around it, a second implementation held to the same behavior via ../conformance/.

Status: the zero-dependency primitives are ported; the rest is stubbed. types, domains, punycode, sanitize, signals, and iocs are implemented and pass every conformance vector currently in the suite. The checks, aggregation, and parsing layers exist as documented placeholder modules that export nothing yet — each names the TypeScript functions it owes and any porting hazard specific to it. phish_signals.IMPLEMENTED_MODULES is the machine-readable version of that split.

Managed with uv.

Layout

Standard modern Python packaging: a src layout (forces tests to run against the installed package rather than accidentally importing from the working directory) plus pytest's conventional tests/ directory.

py-phish-signals/
├── pyproject.toml
├── LICENSE
├── .python-version          # 3.14, pins the interpreter uv uses locally
├── src/
│   └── phish_signals/
│       ├── __init__.py      # public API surface — mirrors typescript/src/index.ts's role
│       ├── py.typed         # PEP 561 marker: this package ships inline types
│       ├── types.py         # shared shapes, as TypedDicts — see the note below
│       ├── domains.py       # ported
│       ├── punycode.py      # ported
│       ├── sanitize.py      # ported
│       ├── signals.py       # ported
│       ├── iocs.py          # ported
│       └── *.py             # everything else: documented stubs, export nothing yet
└── tests/
    └── test_conformance.py  # runs ../../conformance/vectors against whatever's implemented

Note that .python-version (3.14, what uv uses locally) and requires-python in pyproject.toml (>=3.10, what the package claims to support) are deliberately different numbers. Developing on the newest interpreter while supporting an older floor is the normal arrangement, but it means local green does not by itself prove the floor still holds — check it with uv run --python 3.10 --isolated --with pytest --with . pytest.

Shared shapes are TypedDicts, not dataclasses

This is load-bearing rather than stylistic, and it is the first thing to know before adding a type. The conformance harness compares func(input) == expect, where expect is JSON parsed off disk, by exact deep equality. A dataclass instance never compares equal to a dict, so every vectored function would fail no matter how correct its logic was; a TypedDict is a plain dict at runtime, so one value satisfies both the type checker and the vector.

The corollary is that a TypedDict body may contain annotations only. Methods written inside one are silently discarded — the class is not really a class, TypedDict(...) returns a bare dict whose __init__ never ran, and hasattr(value, "your_method") is False with no error anywhere to say so. Behavior belongs in module-level functions taking and returning these dicts, which is how the TypeScript side is written too.

The directory is py-phish-signals/ (sits clearly alongside typescript/ in the repo listing), but the PyPI distribution name is the plain phish-signals (confirmed available), so pip install phish-signals matches the repo's own name without a redundant py- prefix. Either way, the import name is phish_signals — underscored, since Python import names can't contain hyphens: import phish_signals, not import phish-signals or import py_phish_signals.

Setup

cd py-phish-signals
uv sync                # creates .venv, installs the package + dev dependencies
uv run pytest

Every vector in the suite currently passes. tests/test_conformance.py skips a vector whose module or function doesn't exist yet rather than failing on it (see that file's docstring), so as the remaining modules land their vectors flip from skip to pass — or to fail, if the port doesn't match, which is the point.

To add a runtime dependency once parsing needs one (extract-msg, pyzbar, ...): uv add extract-msg. To add a dev-only one: uv add --dev <package>.

Porting order

Leaves-first, so each layer is verifiable before anything depends on it:

  1. Zero-dependency primitives: domains, punycode, sanitize, signals (scoring), iocs — done, plus types underneath them
  2. Checks over plain data: urlCheck, authCheck, headerAnomalies, contentCheck, attachmentCheck, receivedChain
  3. Aggregation/output: combineResults, sigmaRule, kqlQuery, mitre, recommendations, jsonExport
  4. Parsing last, where the runtime dependencies get swapped for Python equivalents: mailparser → stdlib email, @kenjiuno/msgreader → extract-msg, jsqr/pngjs/jpeg-js → pyzbar/Pillow or opencv-python

Do not port zipCheck onto Python's zipfile module as-is: the TypeScript version deliberately degrades gracefully on a truncated or forged central directory (see typescript/test/zipcheck.test.ts) rather than raising, which matters for detection — an attacker-crafted ZIP that makes the parser throw must not be indistinguishable from "nothing suspicious found." zipfile is stricter than that; either handle its exceptions to match the TypeScript behavior, or port the hand-rolled central-directory parse directly.

Function naming

Vectors name modules and functions the way the TypeScript source does (camelCase, e.g. urlCheck / registrableDomain) since that's the reference implementation. Write the Python side with ordinary Python naming (url_check.registrable_domain) — the conformance harness converts camelCase to snake_case in both positions to find it. See ../conformance/README.md.

That applies to function and module names. It deliberately does not apply to data keys inside the values these functions return — benignSignals, originIp, uncompressedSize and the like stay camelCase, because those cross the language boundary in JSON exports and in the conformance vectors' expect values, where the exact spelling is part of the contract rather than an internal style choice. Renaming one to snake_case is a behavior change that breaks conformance, not a cleanup.

Adding a module

  1. Implement it under src/phish_signals/<name>.py, matching typescript/src/<name>.ts's public functions and behavior (including its quirks — see ../conformance/README.md's note on that).
  2. If ../conformance/vectors/<name>/ already has vectors (seeded from the TypeScript side for the zero-dependency layer), uv run pytest will pick them up automatically — no wiring needed.
  3. If it doesn't yet, add vectors there once both implementations exist, so coverage grows with the port rather than trailing behind it.
  4. Re-export the new public functions from src/phish_signals/__init__.py, same grouping as typescript/src/index.ts.

Publishing

.github/workflows/publish-pypi.yml (repo root) is tag-driven: bump version in pyproject.toml, commit, push a matching pypi-vX.Y.Z tag. It won't succeed until a trusted publisher is configured on PyPI's side for a project named phish-signals — see that workflow file's comment.

Metadata

Release files for phish-signals 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for phish-signals 0.1.0
File Size Uploaded
phish_signals-0.1.0.tar.gz 39.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for phish-signals 0.1.0
File Interpreter ABI Platform
phish_signals-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 79.8 kB

Release files / phish_signals-0.1.0.tar.gz

Download URL phish_signals-0.1.0.tar.gz
Size 39.7 kB
Tags Source
SHA-256 checksum
How to use checksums
1c09a2be9bccce76a447e8821938608dcd8abecbe0b2f4435e5ecd5952f38a22
BLAKE2b-256 checksum
How to use checksums
e10e7ee783ea85c2fffbb2903b825e9979143fbe0238a1a1393d61618cfcbbdd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.

Transparency log

Release files / phish_signals-0.1.0-py3-none-any.whl

Download URL phish_signals-0.1.0-py3-none-any.whl
Size 40.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
03247147b94a53b7deb8773113269473c24a4297e364f357d00bb6edc7cd6bee
BLAKE2b-256 checksum
How to use checksums
f609094f3442b5c4f58a2be78790201c428aee6be6bd5b572eb8ad415041b66b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.

Transparency log

Release history Release notifications | RSS feed

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page