Skip to content

Commit caf3f3f

Browse files
Sunanditha B SSunanditha B S
authored andcommitted
Initial commit
0 parents  commit caf3f3f

44 files changed

Lines changed: 2961 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/tests.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: tests
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v4
10+
- uses: actions/setup-python@v5
11+
with:
12+
python-version: "3.11"
13+
- name: Install dependencies
14+
run: pip install -r requirements.txt
15+
- name: Build native CRC-15 component
16+
run: bash canary/native/build.sh
17+
- name: Set up vcan0 for SocketCAN tests
18+
run: |
19+
sudo modprobe vcan
20+
sudo ip link add dev vcan0 type vcan
21+
sudo ip link set up vcan0
22+
- name: Run tests
23+
run: PYTHONPATH=. python -m pytest tests/ -v

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
*.pyc
3+
.pytest_cache/
4+
*.so
5+
venv/
6+
.venv/
7+
*.egg-info/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Sunanditha B S
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# CANARY
2+
3+
Real-time observability and intrusion detection for CAN bus networks.
4+
5+
CAN, plus "canary in the coal mine." An early-warning system for the network that runs almost every car, tractor, and industrial machine on the planet.
6+
7+
---
8+
9+
## Problem
10+
11+
Modern vehicles run 50-100+ ECUs (engine, brakes, steering, infotainment, and more), all talking over a shared CAN bus. CAN was designed in the 1980s for a closed, trusted network. It has no authentication and no encryption. Any node on the bus can send a frame claiming to be any other node.
12+
13+
That assumption stopped being safe once cars got Bluetooth, cellular modems, and USB ports. An attacker who reaches the bus, whether through an infotainment exploit, an OBD-II dongle, or a compromised telematics unit, can inject frames that look completely legitimate to every other ECU on the network.
14+
15+
## Why CAN Attacks Matter
16+
17+
Miller and Valasek's 2015 Jeep Cherokee remote takeover showed that CAN injection can control steering, braking, and acceleration. The attack surface has only grown since, as vehicles add more connectivity, while the CAN network itself still has essentially no built-in defense against a node that's lying about who it is.
18+
19+
Catching this means watching behavior, not just checking protocol correctness. A malicious frame is often a perfectly well-formed CAN frame. That's the actual engineering problem CANARY is built around.
20+
21+
## Architecture
22+
23+
```
24+
┌──────────────────────────────┐
25+
│ ECU Nodes │
26+
│ (Engine, Brake, Door, or an │
27+
│ attacker injecting frames) │
28+
└───────────────┬────────────────┘
29+
│ CANFrame
30+
31+
┌──────────────────────────────┐
32+
│ BusInterface │
33+
│ SimulatedBus (today) │
34+
│ SocketCANBus (future) │
35+
└───────────────┬────────────────┘
36+
┌────────┼────────┐
37+
▼ ▼ ▼
38+
┌─────────┐ ┌────────┐ ┌───────┐
39+
│ Logger │ │Detector│ │ Stats │
40+
│ (CSV + │ │(rules) │ │Engine │
41+
│ replay) │ │ │ │ │
42+
└─────────┘ └───┬────┘ └───┬───┘
43+
│ Alerts │ Telemetry
44+
▼ ▼
45+
┌──────────────────────────┐
46+
│ Dashboard (TUI) │
47+
└──────────────────────────┘
48+
```
49+
50+
Everything downstream of the bus talks to it only through `BusInterface.subscribe()` and `.offer()`. The logger, detector, and stats engine don't know or care whether frames come from a simulation or a real `vcan0` interface.
51+
52+
## Features
53+
54+
- Multi-ECU simulation with realistic timing, jitter, and priority-based arbitration
55+
- CSV traffic logging plus timing-accurate replay from log files
56+
- Rule-based anomaly detection: ID spoofing, malformed frames, impossible message frequency, flooding/DoS, replay attacks
57+
- Severity-tagged alerts, LOW through CRITICAL
58+
- Live terminal dashboard showing bus utilization, arbitration latency, top talkers, and recent alerts
59+
- A native C component (CRC-15 checksum) bridged via `ctypes`, matching how real CAN controllers compute frame integrity at the bit level
60+
- `SocketCANBus`: real Linux SocketCAN support via a raw `AF_CAN` socket, no external library. Works against a real interface (`vcan0` or physical hardware) with zero changes to the detector, logger, or dashboard
61+
- 45 unit and integration tests (2 SocketCAN tests skip automatically outside a CAN-capable kernel, verified passing in CI against `vcan0`)
62+
- Pre-generated sample datasets: clean traffic and a mixed multi-attack scenario
63+
64+
## Demo
65+
66+
```bash
67+
pip install -r requirements.txt
68+
bash canary/native/build.sh # build the C CRC-15 component
69+
PYTHONPATH=. python -m canary.simulator.generate_samples
70+
```
71+
72+
This regenerates `data/sample_logs/normal_traffic.csv` (0 alerts) and `data/attack_scenarios/mixed_attacks.csv` (spoofing, malformed frame, flooding, and replay all caught), printing each alert as it fires.
73+
74+
To run the live dashboard against a running simulation:
75+
```python
76+
from canary.simulator import Simulator
77+
from canary.dashboard import run_dashboard
78+
79+
sim = Simulator("live_demo.csv")
80+
# in practice: run sim.step() on a timer thread, then:
81+
run_dashboard(sim.stats, sim.detector)
82+
```
83+
84+
## How It Works
85+
86+
1. ECUs send frames on a schedule with realistic jitter (`canary/ecu/`).
87+
2. SimulatedBus resolves priority via a min-heap on arbitration ID, lower ID wins, the same way real CAN arbitration works (`canary/bus/`).
88+
3. Logger, Detector, and StatsEngine each subscribe independently. None of them know the others exist.
89+
4. Detector tracks per-ID baselines (expected source, size, period) and runs six independent rules against every frame, producing severity-tagged alerts.
90+
5. StatsEngine aggregates telemetry, rates, latency, top talkers, purely from what it observes. It never touches bus internals.
91+
6. Dashboard renders both and owns nothing else.
92+
93+
## Design Decisions
94+
95+
Arbitration is modeled as priority scheduling, not bit-level contention. This captures the real effect, latency under load and deterministic priority, without simulating electrical signaling that nothing downstream needs.
96+
97+
C shows up exactly once, for the CRC-15 checksum, because that's genuinely bit-level, hardware-adjacent work. It's not there for its own sake.
98+
99+
A number of protocol features are left out on purpose: bit-stuffing, ACK slots, error frames, remote frames, overload frames, the bus-off state machine, exact bit-level arbitration. None of them change what the detector can observe, so implementing them would just be chasing CAN-spec completeness instead of building detection tooling.
100+
101+
`BusInterface` got extracted after Module 4, not designed upfront, and it required zero changes to the detector, logger, or ECU code. That's what you get from subscriber-pattern decoupling from day one.
102+
103+
Two real bugs only showed up at integration time, in Module 7: ECU frames defaulted to wall-clock timestamps instead of simulated time, and the stats engine mixed wall-clock latency against simulated-time frames. Both got fixed with explicit, documented clock-passing rather than patched over. See `Simulator.__init__` and `StatsEngine.observe()`.
104+
105+
## Future Work
106+
107+
- CAN-FD support (larger payloads, flexible data rate)
108+
- UDS (Unified Diagnostic Services) simulation
109+
- J1939 (heavy-vehicle protocol layer)
110+
- ISO-TP (multi-frame transport protocol)
111+
- ML-based anomaly detection, as a stretch goal
112+
113+
## Running Tests
114+
115+
```bash
116+
PYTHONPATH=. python -m pytest tests/ -v
117+
```
118+
119+
45 tests cover frame validation, bus arbitration, ECU scheduling, logging/replay round-trips, the native CRC-15 (checked against a pure-Python reference implementation), all six detection rules, the stats engine, dashboard rendering, full simulator integration across every attack scenario, and SocketCAN frame encode/decode.
120+
121+
Two of those tests exercise a live `vcan0` interface and skip automatically if the kernel has no CAN support. To run them locally on Linux:
122+
```bash
123+
sudo modprobe vcan
124+
sudo ip link add dev vcan0 type vcan
125+
sudo ip link set up vcan0
126+
PYTHONPATH=. python -m pytest tests/test_socketcan.py -v
127+
```
128+
CI runs these for real against `vcan0` on every push.
129+
130+
## License
131+
132+
MIT. See LICENSE.

canary/__init__.py

Whitespace-only changes.

canary/bus/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .frame import CANFrame
2+
from .bus import BusInterface, SimulatedBus
3+
from .socketcan_bus import SocketCANBus
4+
5+
# Backward-compat alias: existing tests/modules reference CANBus directly.
6+
# New code should prefer SimulatedBus / BusInterface.
7+
CANBus = SimulatedBus
8+
9+
__all__ = ["CANFrame", "BusInterface", "SimulatedBus", "SocketCANBus", "CANBus"]

canary/bus/bus.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
CAN bus core.
3+
4+
Arbitration is modeled as strict priority ordering (lower ID wins).
5+
Tracks bus utilization against a configured bit rate.
6+
"""
7+
8+
import heapq
9+
import itertools
10+
from typing import Callable, List
11+
12+
from .frame import CANFrame
13+
14+
# Rough overhead per classic CAN frame in bits (SOF + arbitration + control +
15+
# CRC + ACK + EOF + stuffing headroom), used for utilization estimates.
16+
FRAME_OVERHEAD_BITS = 47
17+
18+
19+
class BusInterface:
20+
"""Contract every bus implementation must satisfy. Callers should
21+
depend on this, not on SimulatedBus directly."""
22+
23+
def subscribe(self, callback) -> None:
24+
raise NotImplementedError
25+
26+
def offer(self, frame) -> None:
27+
raise NotImplementedError
28+
29+
30+
class SimulatedBus(BusInterface):
31+
def __init__(self, bitrate_bps: int = 500_000):
32+
self.bitrate_bps = bitrate_bps
33+
self._pending: List[tuple] = [] # heap of (arbitration_id, seq, frame)
34+
self._seq_counter = itertools.count()
35+
self._subscribers: List[Callable[[CANFrame], None]] = []
36+
self._bits_transmitted = 0
37+
self._window_start = None
38+
self.frames_transmitted = 0
39+
self.frames_dropped = 0
40+
41+
def subscribe(self, callback: Callable[[CANFrame], None]) -> None:
42+
"""Register a listener (logger, detector, dashboard) for every
43+
frame that wins arbitration and is transmitted."""
44+
self._subscribers.append(callback)
45+
46+
def offer(self, frame: CANFrame) -> None:
47+
"""An ECU offers a frame to the bus. It queues for arbitration
48+
rather than transmitting immediately, so simultaneous offers
49+
resolve by priority (lowest arbitration_id first)."""
50+
heapq.heappush(self._pending, (frame.arbitration_id, next(self._seq_counter), frame))
51+
52+
def tick(self) -> None:
53+
"""Resolve one round of arbitration: the highest-priority pending
54+
frame transmits, all others remain queued for the next tick."""
55+
if not self._pending:
56+
return
57+
_, _, frame = heapq.heappop(self._pending)
58+
self._transmit(frame)
59+
60+
def _transmit(self, frame: CANFrame) -> None:
61+
frame_bits = FRAME_OVERHEAD_BITS + frame.dlc * 8
62+
self._bits_transmitted += frame_bits
63+
self.frames_transmitted += 1
64+
for callback in self._subscribers:
65+
callback(frame)
66+
67+
def utilization(self, elapsed_seconds: float) -> float:
68+
"""Bus utilization as a fraction of capacity over the elapsed window.
69+
Caller tracks elapsed_seconds; the bus only tracks bits transmitted."""
70+
if elapsed_seconds <= 0:
71+
return 0.0
72+
capacity_bits = self.bitrate_bps * elapsed_seconds
73+
return min(self._bits_transmitted / capacity_bits, 1.0)
74+
75+
def reset_utilization_counter(self) -> None:
76+
self._bits_transmitted = 0
77+
78+
@property
79+
def pending_count(self) -> int:
80+
return len(self._pending)

canary/bus/frame.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
CAN frame data model.
3+
4+
Represents a single CAN 2.0 frame as it would appear on the bus.
5+
Extended (29-bit) IDs are supported via `extended=True`; standard
6+
frames use 11-bit IDs.
7+
"""
8+
9+
from dataclasses import dataclass, field
10+
import time
11+
12+
STANDARD_ID_MAX = 0x7FF # 11-bit
13+
EXTENDED_ID_MAX = 0x1FFFFFFF # 29-bit
14+
MAX_DLC = 8
15+
16+
17+
@dataclass(frozen=True)
18+
class CANFrame:
19+
arbitration_id: int
20+
data: bytes
21+
extended: bool = False
22+
timestamp: float = field(default_factory=time.monotonic)
23+
source_ecu: str = "unknown"
24+
25+
def __post_init__(self):
26+
max_id = EXTENDED_ID_MAX if self.extended else STANDARD_ID_MAX
27+
if not (0 <= self.arbitration_id <= max_id):
28+
raise ValueError(
29+
f"arbitration_id {self.arbitration_id:#x} out of range "
30+
f"for {'extended' if self.extended else 'standard'} frame"
31+
)
32+
if len(self.data) > MAX_DLC:
33+
raise ValueError(f"payload length {len(self.data)} exceeds max DLC of {MAX_DLC}")
34+
35+
@property
36+
def dlc(self) -> int:
37+
return len(self.data)
38+
39+
def __repr__(self) -> str:
40+
hex_data = self.data.hex(" ")
41+
id_fmt = f"{self.arbitration_id:08X}" if self.extended else f"{self.arbitration_id:03X}"
42+
return f"<CANFrame id=0x{id_fmt} dlc={self.dlc} data=[{hex_data}] src={self.source_ecu}>"

0 commit comments

Comments
 (0)