Skip to content

Commit 8a6f5b5

Browse files
eastmadcclaude
andcommitted
feat(ics-protocol): DNP3 production YAML manifest (Phase 5.A)
Rule #52 instance digitalandrew#3 / Phase 5.A — Rule digitalandrew#25 per-piece commit: ships the second ICS protocol catalog production manifest (after Session 1's modbus_tcp_v0_system). Mirrors the Modbus YAML shape exactly with DNP3-specific port + banner + function-code values. DNP3 (IEEE 1815-2012; IANA TCP/UDP 20000) detector signals: - string_in_binary needle set: 'dnp3' / 'opendnp3' / 'libdnp3' (case-insensitive; combine=any min_count=1) - port_signature: 20000 (little-endian uint16 constant in head) - function_code_set: 14 DNP3 standard FCs (0x01 READ / 0x02 WRITE / 0x03 SELECT / 0x04 OPERATE / 0x05 DIRECT_OPERATE / 0x06 DIRECT_OPERATE_NR / 0x0D COLD_RESTART / 0x0E WARM_RESTART / 0x14 ENABLE_UNSOLICITED / 0x15 DISABLE_UNSOLICITED / 0x17 DELAY_MEASURE / 0x18 RECORD_CURRENT_TIME / 0x81 RESPONSE / 0x82 UNSOLICITED_RESPONSE) — min_count=3 within 512-byte window per W2-β A8 floor. W2-β §SC5-NEW-ICS-2 mitigation: combine=all_required prevents single-signal false positives. Modbus FCs (0x01-0x06) overlap conceptually with DNP3 0x01-0x06 but the port + banner co-requirement disambiguates — see cross-protocol matrix tests in Phase 5.B. Tests (tests/test_ics_protocol_dnp3_e2e.py — Rule #35b live canary against the in-tree production YAML): - test_production_catalog_loads_dnp3_v0: load smoke + schema shape - test_dnp3_resolves_all_three_signals_fire: happy path - test_dnp3_rejects_banner_only_combine_all_required: §SC5-NEW-ICS-2 - test_dnp3_rejects_port_only: §SC5-NEW-ICS-2 - test_dnp3_rejects_function_codes_only: §SC5-NEW-ICS-2 5 tests pass in 0.59s. Cross-protocol matrix (Modbus + DNP3 + S7Comm disjointness) ships in Phase 5.B alongside s7comm.yaml — those tests require both YAMLs present. Phase 5.B (next): s7comm.yaml + cross-protocol matrix tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 960500f commit 8a6f5b5

2 files changed

Lines changed: 214 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# yaml-language-server: $schema=../../.schema.json
2+
# DNP3 (Distributed Network Protocol 3) firmware-embedded protocol-stack
3+
# detector. IEEE 1815-2012 standard; IANA-registered TCP/UDP port 20000.
4+
# Predominantly used in electric grid SCADA, water/wastewater telemetry,
5+
# and other utility SCADA networks.
6+
#
7+
# Three signals combine (all_required) for a high-confidence detection:
8+
# 1. ASCII string `dnp3` / `opendnp3` / `libdnp3` appearing in the
9+
# binary's head window (library banner / SONAME / copyright text).
10+
# 2. DNP3 IANA port 20000 (0x4E20) appearing as a little-endian uint16
11+
# constant somewhere in the head.
12+
# 3. DNP3 application-layer function-code lookup table — at least 3 of
13+
# the standard DNP3 FCs (per IEEE 1815-2012 §A.2):
14+
# 0x01 READ 0x02 WRITE 0x03 SELECT
15+
# 0x04 OPERATE 0x05 DIRECT_OPERATE 0x06 DIRECT_OPERATE_NR
16+
# 0x0D COLD_RESTART 0x0E WARM_RESTART 0x14 ENABLE_UNSOLICITED
17+
# 0x15 DISABLE_UNSOLICITED 0x17 DELAY_MEASURE 0x18 RECORD_CURRENT_TIME
18+
# 0x81 RESPONSE 0x82 UNSOLICITED_RESPONSE
19+
# Floor of 3 codes within a 512-byte window per W2-β A8 high-collision
20+
# mitigation (DNP3 FCs overlap conceptually with Modbus 0x01-0x06; the
21+
# banner + port co-requirement under all_required disambiguates).
22+
#
23+
# Per W2-β §SC5-NEW-ICS-2 mitigation: combine=all_required prevents
24+
# single-signal false positives. A binary that merely contains the string
25+
# `dnp3` (e.g. an HTTP server's static asset path) without ALSO showing
26+
# port 20000 AND the function-code table DOES NOT fire this manifest.
27+
#
28+
# References:
29+
# - IEEE 1815-2012 "Standard for Electric Power Systems Communications —
30+
# Distributed Network Protocol (DNP3)"
31+
# - IANA Service Name & Transport Protocol Port Number Registry
32+
# (port 20000 / tcp + udp = dnp / "DNP")
33+
# - OpenDNP3 project (https://github.com/dnp3/opendnp3)
34+
# - ICS-CERT ADVISORY-19-274-01 (DNP3 stack identification guidance)
35+
schema_version: 1
36+
manifest_id: dnp3_v0_system
37+
manifest_source: _system
38+
precedence: 5
39+
detection:
40+
combine: all_required
41+
certainty: stack_present
42+
signals:
43+
- kind: string_in_binary
44+
description: "'dnp3' / 'opendnp3' / 'libdnp3' ASCII banner in head window"
45+
string_in_binary_constraint:
46+
needles_hex:
47+
- "646e7033" # 'dnp3'
48+
- "6f70656e646e7033" # 'opendnp3'
49+
- "6c6962646e7033" # 'libdnp3'
50+
case_sensitive: false
51+
combine: any
52+
min_count: 1
53+
- kind: port_signature
54+
description: "IANA-assigned DNP3 port 20000"
55+
ports: [20000]
56+
transport_required: tcp
57+
- kind: function_code_set
58+
description: "DNP3 standard application-layer function-code lookup table"
59+
function_code_constraint:
60+
function_codes_hex:
61+
- "01" # READ
62+
- "02" # WRITE
63+
- "03" # SELECT
64+
- "04" # OPERATE
65+
- "05" # DIRECT_OPERATE
66+
- "06" # DIRECT_OPERATE_NR
67+
- "0d" # COLD_RESTART
68+
- "0e" # WARM_RESTART
69+
- "14" # ENABLE_UNSOLICITED
70+
- "15" # DISABLE_UNSOLICITED
71+
- "17" # DELAY_MEASURE
72+
- "18" # RECORD_CURRENT_TIME
73+
- "81" # RESPONSE
74+
- "82" # UNSOLICITED_RESPONSE
75+
# Floor of 3 codes within a 512-byte window — DNP3 application-
76+
# layer FCs are sparser than Modbus's; 3 is the W2-β A8 floor
77+
# for discriminating from coincidental bytes.
78+
min_count: 3
79+
window_bytes: 512
80+
output:
81+
protocol_family: dnp3
82+
layer: application
83+
transport: tcp
84+
vendor: generic
85+
vendor_product: null
86+
protocol_version: null
87+
confidence: high
88+
notes: |
89+
Generic DNP3 detector covering opendnp3, libdnp3, vendor-specific DNP3
90+
outstation/master implementations. Per W2-β §SC5-NEW-ICS-2 mitigation,
91+
combine=all_required forces co-occurrence of (banner + port + function-
92+
code table) — single-signal hits do NOT fire this manifest.
93+
94+
Vendor-specific entries (e.g. SEL, ABB, GE Multilin, Schweitzer relay
95+
firmware) ship in v1 as additional manifests at
96+
data/ics_protocols/<vendor>/dnp3_*.yaml with vendor-specific banner
97+
needles + product attribution; this v0 entry carries vendor='generic'
98+
so the matcher returns a baseline detection for unidentified DNP3 stacks.
99+
100+
Cross-manifest disjointness vs modbus_tcp_v0_system: DNP3's port 20000
101+
+ FC table (0x01-0x18 + 0x81/0x82) is distinct from Modbus/TCP's port
102+
502 + FC table — but the FC SET overlap on 0x01-0x06 means single-
103+
signal FC-only hits would be ambiguous. all_required + the port +
104+
banner co-requirement resolves the ambiguity in practice.
105+
references:
106+
- "https://standards.ieee.org/ieee/1815/4805/"
107+
- "https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml"
108+
- "https://github.com/dnp3/opendnp3"
109+
- "https://www.cisa.gov/news-events/ics-advisories/icsa-19-274-01"
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""End-to-end integration test for the Phase 5 DNP3 v0 YAML.
2+
3+
Session 2 Phase 5.A — proves the schema + catalog + resolver + YAML
4+
loader chain works against the real ``data/ics_protocols/_system/dnp3.yaml``
5+
shipped in this commit. Rule #35b live canary form against the IN-TREE
6+
production YAML.
7+
8+
Cross-protocol matrix tests live in
9+
``test_ics_protocol_dnp3_s7comm_e2e.py`` (Commit 5.B; ships with
10+
s7comm.yaml so both YAMLs are present for the matrix checks).
11+
"""
12+
from __future__ import annotations
13+
14+
import struct
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from app.services.ics_protocol_catalog import (
20+
IcsProtocolCatalog,
21+
resolve_all,
22+
)
23+
24+
25+
@pytest.fixture
26+
def production_catalog() -> IcsProtocolCatalog:
27+
data_root = (
28+
Path(__file__).resolve().parents[1]
29+
/ "app" / "services" / "ics_protocol_catalog"
30+
/ "data" / "ics_protocols"
31+
)
32+
overlay_root = (
33+
Path(__file__).resolve().parents[1]
34+
/ "app" / "services" / "ics_protocol_catalog"
35+
/ "data" / "ics_protocols.local"
36+
)
37+
return IcsProtocolCatalog(
38+
data_root=data_root, overlay_root=overlay_root,
39+
)
40+
41+
42+
def test_production_catalog_loads_dnp3_v0(production_catalog):
43+
"""The shipped _system/dnp3.yaml loads cleanly."""
44+
snap = production_catalog.get_snapshot()
45+
m = snap.get("dnp3_v0_system")
46+
assert m is not None
47+
assert m.manifest_source == "_system"
48+
assert m.output.protocol_family == "dnp3"
49+
assert m.output.layer == "application"
50+
assert m.output.transport == "tcp"
51+
assert m.detection.combine == "all_required"
52+
kinds = [s.kind for s in m.detection.signals]
53+
assert set(kinds) == {
54+
"string_in_binary", "port_signature", "function_code_set",
55+
}
56+
assert production_catalog.last_warning is None
57+
58+
59+
def test_dnp3_resolves_all_three_signals_fire(production_catalog):
60+
"""Blob with DNP3 banner ('opendnp3') + port 20000 LE constant +
61+
FCs 0x01/0x02/0x03/0x04 in 512-byte window → matches."""
62+
snap = production_catalog.get_snapshot()
63+
blob = bytearray(4096)
64+
blob[100:108] = b"opendnp3"
65+
blob[200:202] = struct.pack("<H", 20000)
66+
blob[800:804] = bytes([0x01, 0x02, 0x03, 0x04])
67+
matches = resolve_all(bytes(blob), "/tmp/dnp3-test.bin", 4096, snap)
68+
dnp3 = [m for m in matches if m.protocol_family == "dnp3"]
69+
assert len(dnp3) == 1
70+
m = dnp3[0]
71+
assert m.confidence == "high"
72+
assert set(m.matched_signals) == {
73+
"string_in_binary", "port_signature", "function_code_set",
74+
}
75+
76+
77+
def test_dnp3_rejects_banner_only_combine_all_required(production_catalog):
78+
"""W2-β §SC5-NEW-ICS-2 — banner-only does NOT fire (combine forces
79+
co-occurrence)."""
80+
snap = production_catalog.get_snapshot()
81+
blob = bytearray(4096)
82+
blob[100:108] = b"opendnp3"
83+
matches = resolve_all(bytes(blob), "/tmp/t.bin", 4096, snap)
84+
dnp3 = [m for m in matches if m.protocol_family == "dnp3"]
85+
assert len(dnp3) == 0
86+
87+
88+
def test_dnp3_rejects_port_only(production_catalog):
89+
"""Port-only — rejected by combine."""
90+
snap = production_catalog.get_snapshot()
91+
blob = bytearray(4096)
92+
blob[200:202] = struct.pack("<H", 20000)
93+
matches = resolve_all(bytes(blob), "/tmp/t.bin", 4096, snap)
94+
dnp3 = [m for m in matches if m.protocol_family == "dnp3"]
95+
assert len(dnp3) == 0
96+
97+
98+
def test_dnp3_rejects_function_codes_only(production_catalog):
99+
"""Function codes alone — rejected by combine."""
100+
snap = production_catalog.get_snapshot()
101+
blob = bytearray(4096)
102+
blob[800:808] = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0d, 0x82])
103+
matches = resolve_all(bytes(blob), "/tmp/t.bin", 4096, snap)
104+
dnp3 = [m for m in matches if m.protocol_family == "dnp3"]
105+
assert len(dnp3) == 0

0 commit comments

Comments
 (0)