Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@
</div>

```bash
pip install cognis-maritimeint
pip install "git+https://github.com/cognis-digital/maritimeint.git"
maritimeint scan . # → prioritized findings in seconds
```

<!-- cognis:layman:start -->
## What is this?

MARITIMEINT watches ship-tracking data (AIS broadcasts) and automatically spots suspicious behavior — like a vessel going dark in a sensitive area, two ships meeting in the middle of the ocean to secretly transfer cargo, or a ship claiming to be in two places at once. It runs entirely on your own computer with no account or subscription required. Designed for journalists, researchers, and analysts who investigate sanctions evasion, smuggling, or fleet anomalies but don't want to fight complicated infrastructure to do it.
<!-- cognis:layman:end -->

## Contents

- [Why maritimeint?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing)
Expand Down Expand Up @@ -50,10 +56,56 @@ AIS vessel tracking & sanctions-evasion anomaly detection — without standing u
<div align="right"><a href="#top">↑ back to top</a></div>

<a name="quick-start"></a>
<!-- cognis:domains:start -->
## Domains

**Primary domain:** Intelligence & OSINT · **JTF MERIDIAN division:** NULLBYTE · BLACK CELL

**Topics:** `cognis` `osint` `intelligence` `recon`

Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together.
<!-- cognis:domains:end -->

<!-- cognis:install:start -->
## Install

`maritimeint` is source-available (not published to PyPI) — every method below installs
straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
the best tool available on your machine.

**One-liner (Linux / macOS):**
```sh
curl -fsSL https://raw.githubusercontent.com/cognis-digital/maritimeint/HEAD/install.sh | sh
```

**One-liner (Windows PowerShell):**
```powershell
irm https://raw.githubusercontent.com/cognis-digital/maritimeint/HEAD/install.ps1 | iex
```

**Or install manually — any one of:**
```sh
pipx install "git+https://github.com/cognis-digital/maritimeint.git" # isolated (recommended)
uv tool install "git+https://github.com/cognis-digital/maritimeint.git" # uv
pip install "git+https://github.com/cognis-digital/maritimeint.git" # pip
```

**From source:**
```sh
git clone https://github.com/cognis-digital/maritimeint.git
cd maritimeint && pip install .
```

Then run:
```sh
maritimeint --help
```
<!-- cognis:install:end -->

## Quick start

```bash
pip install cognis-maritimeint
pip install "git+https://github.com/cognis-digital/maritimeint.git"
maritimeint --version
maritimeint scan . # scan current project
maritimeint scan . --format json # machine-readable
Expand Down
29 changes: 29 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Comprehensive installer for cognis-digital/maritimeint (Windows PowerShell).
# Tries: pipx -> uv -> pip (git+https) -> from source.
# maritimeint is source-available and not on PyPI; all paths install from GitHub.
$ErrorActionPreference = "Stop"
$Repo = "maritimeint"
$Url = "git+https://github.com/cognis-digital/maritimeint.git"
$Git = "https://github.com/cognis-digital/maritimeint.git"
function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta }
function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) }

if (-not (Have python) -and -not (Have py)) {
Say "Python 3.9+ is required but was not found. Install Python first."; exit 1
}
if (Have pipx) {
Say "Installing with pipx (isolated, recommended)..."
pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: maritimeint"; exit 0 }
}
if (Have uv) {
Say "Installing with uv..."
uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: maritimeint"; exit 0 }
}
if (Have pip) {
Say "Installing with pip (user site)..."
pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: maritimeint"; exit 0 }
}
Say "No packaging tool worked; falling back to a source clone."
$Tmp = Join-Path $env:TEMP "$Repo-src"
git clone --depth 1 $Git $Tmp
Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ."
44 changes: 34 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
#!/usr/bin/env sh
# Universal installer for maritimeint. Prefers uv > pipx > pip; installs from the repo.
set -e
SRC="git+https://github.com/cognis-digital/maritimeint.git"
echo "Installing maritimeint ..."
if command -v uv >/dev/null 2>&1; then uv tool install "$SRC"
elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC"
elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC"
else echo "Need uv, pipx, or python3+pip"; exit 1; fi
echo "Done. Run: maritimeint --help"
#!/usr/bin/env sh
# Comprehensive installer for cognis-digital/maritimeint (Linux / macOS).
# Tries the best available method: pipx -> uv -> pip (git+https) -> from source.
# maritimeint is source-available and not on PyPI; all paths install from GitHub.
set -eu

REPO="maritimeint"
URL="git+https://github.com/cognis-digital/maritimeint.git"
GITURL="https://github.com/cognis-digital/maritimeint.git"

say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }

if ! have python3 && ! have python; then
say "Python 3.9+ is required but was not found. Install Python first."; exit 1
fi

if have pipx; then
say "Installing with pipx (isolated, recommended)..."
pipx install "$URL" && { say "Done. Run: maritimeint"; exit 0; }
fi
if have uv; then
say "Installing with uv..."
uv tool install "$URL" && { say "Done. Run: maritimeint"; exit 0; }
fi
if have pip3 || have pip; then
PIP="$(command -v pip3 || command -v pip)"
say "Installing with pip (user site)..."
"$PIP" install --user "$URL" && { say "Done. Run: maritimeint"; exit 0; }
fi

say "No packaging tool worked; falling back to a source clone."
TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO"
say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ."
2 changes: 1 addition & 1 deletion integrations/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Usage: <tool> scan . --format json | python integrations/webhook.py --url URL
"""
from __future__ import annotations
import argparse, json, sys, urllib.request
import argparse, sys, urllib.request

def main() -> int:
ap = argparse.ArgumentParser()
Expand Down
1 change: 1 addition & 0 deletions layman.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MARITIMEINT watches ship-tracking data (AIS broadcasts) and automatically spots suspicious behavior — like a vessel going dark in a sensitive area, two ships meeting in the middle of the ocean to secretly transfer cargo, or a ship claiming to be in two places at once. It runs entirely on your own computer with no account or subscription required. Designed for journalists, researchers, and analysts who investigate sanctions evasion, smuggling, or fleet anomalies but don't want to fight complicated infrastructure to do it.
29 changes: 25 additions & 4 deletions maritimeint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ def build_parser() -> argparse.ArgumentParser:
_add_input(j)
j.add_argument("--max-speed-kn", type=float, default=40.0)

l = sub.add_parser("loiter", help="detect loitering / STS staging")
_add_input(l)
l.add_argument("--radius-nm", type=float, default=2.0)
l.add_argument("--min-hours", type=float, default=4.0)
lt = sub.add_parser("loiter", help="detect loitering / STS staging")
_add_input(lt)
lt.add_argument("--radius-nm", type=float, default=2.0)
lt.add_argument("--min-hours", type=float, default=4.0)

s = sub.add_parser("spoof", help="detect spoofing / identity conflicts")
_add_input(s)
Expand All @@ -85,9 +85,30 @@ def build_parser() -> argparse.ArgumentParser:
return parser


def _positive_float(name: str, value: float) -> None:
"""Raise SystemExit(2) with a clear message if value is not positive."""
if value <= 0:
print(f"error: --{name} must be a positive number, got {value}",
file=sys.stderr)
raise SystemExit(2)


def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)

# Validate numeric arguments before loading data.
if args.command == "gaps":
_positive_float("gap-hours", args.gap_hours)
elif args.command == "jumps":
_positive_float("max-speed-kn", args.max_speed_kn)
elif args.command == "loiter":
_positive_float("radius-nm", args.radius_nm)
_positive_float("min-hours", args.min_hours)
elif args.command == "rendezvous":
_positive_float("proximity-nm", args.proximity_nm)
_positive_float("min-minutes", args.min_minutes)

try:
msgs = load_messages(args.input)
if args.command == "analyze":
Expand Down
52 changes: 44 additions & 8 deletions maritimeint/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@

EARTH_RADIUS_NM = 3440.065 # nautical miles

TOOL_NAME = "maritimeint"
TOOL_VERSION = "0.3.9"


def _parse_ts(value: str) -> datetime:
"""Parse an ISO-8601 timestamp into an aware UTC datetime."""
s = value.strip()
if not s:
raise ValueError("timestamp field is empty")
if s.endswith("Z"):
s = s[:-1] + "+00:00"
dt = datetime.fromisoformat(s)
try:
dt = datetime.fromisoformat(s)
except ValueError:
raise ValueError(f"unparseable timestamp: {value!r}")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
Expand All @@ -46,16 +54,34 @@ class AISMessage:

@classmethod
def from_dict(cls, d: dict[str, Any]) -> "AISMessage":
if not isinstance(d, dict):
raise ValueError(f"AIS record must be a dict, got {type(d).__name__}")
if "mmsi" not in d:
raise ValueError("AIS record missing 'mmsi'")
mmsi_ref = d.get("mmsi")
for key in ("timestamp", "lat", "lon"):
if key not in d:
raise ValueError(f"AIS record for {d.get('mmsi')} missing '{key}'")
raise ValueError(f"AIS record for {mmsi_ref!r} missing '{key}'")
try:
lat = float(d["lat"])
lon = float(d["lon"])
except (TypeError, ValueError) as exc:
raise ValueError(
f"AIS record for {mmsi_ref!r}: invalid lat/lon — {exc}"
) from exc
if not (-90.0 <= lat <= 90.0):
raise ValueError(
f"AIS record for {mmsi_ref!r}: lat {lat} out of range [-90, 90]"
)
if not (-180.0 <= lon <= 180.0):
raise ValueError(
f"AIS record for {mmsi_ref!r}: lon {lon} out of range [-180, 180]"
)
return cls(
mmsi=str(d["mmsi"]),
timestamp=_parse_ts(str(d["timestamp"])),
lat=float(d["lat"]),
lon=float(d["lon"]),
lat=lat,
lon=lon,
name=str(d.get("name", "")),
sog=None if d.get("sog") is None else float(d["sog"]),
cog=None if d.get("cog") is None else float(d["cog"]),
Expand All @@ -69,15 +95,24 @@ def as_record(self) -> dict[str, Any]:

def parse_messages(data: Iterable[dict[str, Any]]) -> list[AISMessage]:
"""Parse raw AIS records into validated, time-sorted AISMessage objects."""
msgs = [AISMessage.from_dict(d) for d in data]
records = list(data)
msgs: list[AISMessage] = []
for i, d in enumerate(records):
try:
msgs.append(AISMessage.from_dict(d))
except (ValueError, TypeError) as exc:
raise ValueError(f"record[{i}]: {exc}") from exc
msgs.sort(key=lambda m: (m.mmsi, m.timestamp))
return msgs


def load_messages(path: str) -> list[AISMessage]:
"""Load AIS records from a JSON file (list, or {\"messages\": [...]})."""
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
try:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
except UnicodeDecodeError as exc:
raise ValueError(f"file is not valid UTF-8: {exc}") from exc
if isinstance(raw, dict):
raw = raw.get("messages", raw.get("records", []))
if not isinstance(raw, list):
Expand Down Expand Up @@ -225,7 +260,8 @@ def detect_spoofing(msgs: list[AISMessage]) -> list[dict[str, Any]]:
pins.setdefault((round(m.lat, 4), round(m.lon, 4)), []).append(m)
for (plat, plon), group in pins.items():
if len(group) >= 3:
span_h = (group[-1].timestamp - group[0].timestamp).total_seconds() / 3600.0
elapsed = group[-1].timestamp - group[0].timestamp
span_h = elapsed.total_seconds() / 3600.0
if span_h >= 1.0:
findings.append({
"type": "static_pin",
Expand Down
Loading
Loading