Skip to content

Commit 960500f

Browse files
eastmadcclaude
andcommitted
feat(ics-protocol): plugin infrastructure + bundled string_scanner + W2-β §SC5-NEW-ICS-S2-α HARDENED freeze (Phase 4)
Rule #52 instance #3 / Phase 4: closes Session 1 W2-β §SC5-NEW-ICS-7 (hot-reload × plugin module-level shadow) — the scariest unmitigated attack identified by Session 1 deep-research. Ships with W2-β §SC5-NEW-ICS-S2-α HARDENED shape (Wave-2 β cross-feature critique explicit recommendation): MappingProxyType wrap + closure-capture gate + freeze sentinel + namespace-disjointness collision check. Resolver plugin infrastructure (app/services/ics_protocol_catalog/ resolver.py): - IcsProtocolMatcherProto Protocol — typed plugin contract; cost_class + protocol_families + detect(blob_head, path, size, context) signature. - Private _PLUGIN_REGISTRY mutable dict; PRIVATE — never re-exported. - Public PLUGIN_REGISTRY = MappingProxyType(_PLUGIN_REGISTRY) — read-only view. Direct ``PLUGIN_REGISTRY[x] = y`` writes raise TypeError. CLOSES §SC5-NEW-ICS-S2-α: hostile bundled plugin ``from ... import PLUGIN_REGISTRY; PLUGIN_REGISTRY["x"] = matcher`` attack now fails at the proxy layer (NOT just the freeze check). - register_matcher(name, matcher) — three gates: freeze sentinel, namespace-disjointness collision, closure-capture rejection. - _closure_capture_check (W2-β §SC5-NEW-ICS-S2-ζ): rejects matchers whose detect() callable captures AsyncSession/Settings/ContextVar/ ToolContext via closure (request-scope leak prevention). - freeze_plugin_registry() flips sentinel; _unfreeze_plugin_registry_for_tests for test isolation only. Bundled string_scanner plugin (NEW app/services/ics_protocol_catalog/plugins/string_scanner.py): - Stateless StringScannerPlugin — closure-clean (no captured state). - Coarse byte-needle signatures for modbus_tcp / dnp3 / s7comm — detects library-symbol patterns the closed-grammar string_in_binary_constraint cannot express compactly. Plugin discovery (NEW app/services/ics_protocol_catalog/plugins/__init__.py): - register_default_plugins(freeze=True) — static imports of bundled plugins; no importlib against operator-controlled name strings (defense against §SC5-NEW-ICS-S2-α dynamic-import attack vector). Lifespan wire (app/main.py): - register_default_plugins(freeze=True) called AFTER LOLDrivers probe + BEFORE yield. Order matters — the freeze gate is the security boundary; calling after yield exposes a startup-to-first-request window. Defensive try/except so plugin registration failure does NOT block startup — walker degrades gracefully to closed-grammar YAML detection only. Rule #46 paired META-CANARIES (test_ics_protocol_plugin.py, 13 tests): - test_plugin_registry_is_mappingproxytype_read_only: type assertion - test_mappingproxytype_gate_actually_blocks_direct_dict_write: paired canary — synthesizes hostile direct write; confirms TypeError - test_register_matcher_pre_freeze_succeeds: paired canary (gate doesn't degenerate to always-reject) - test_register_matcher_post_freeze_raises: §SC5-NEW-ICS-S2-α primary mitigation; RuntimeError on post-freeze write - test_freeze_does_not_block_existing_lookups: defensive — reads still work post-freeze - test_register_matcher_rejects_async_session_closure_capture: §SC5-NEW-ICS-S2-ζ — synthesizes hostile matcher with AsyncSession in __closure__; confirms ValueError - test_register_matcher_accepts_stateless_matcher: paired canary - test_register_matcher_rejects_protocol_family_collision: namespace disjointness (analog of file_format A7) - test_register_default_plugins_registers_string_scanner: smoke - test_register_default_plugins_freezes_when_requested: lifespan contract - test_string_scanner_plugin_detect_returns_hits_on_modbus_signature: plugin functional smoke - test_string_scanner_plugin_returns_none_on_clean_binary: paired - test_main_py_lifespan_imports_and_calls_register_default_plugins: Rule #46 META-CANARY — confirms main.py call shape + BEFORE-yield textual ordering Phase 4 test sweep: 13 passed in 0.48s; broader Phase 1-4 + baseline sweep: 952 passed. DEFERRED per W2-β recommendation (queued in Phase 6 postmortem): - AST pre-import scan of plugin module source (complex; covered partially by closure-capture gate + MappingProxyType wrap) - Rule #21 backfill to file_format_catalog (same MappingProxyType + closure-capture hardening) — file_format precedent uses bare dict shape; backfill is a Rule-of-Two sweep but ICS-S2 scope deferred it; documented in Phase 6 postmortem as follow-up. Phase 5 (next): DNP3 + S7Comm production YAML manifests as Rule #25 single-slice commits (one per protocol per Rule #25). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56bf14e commit 960500f

6 files changed

Lines changed: 664 additions & 0 deletions

File tree

backend/app/main.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,26 @@ async def _reap_one_config(config: WalkerReaperConfig) -> None:
350350
"LOLDrivers bundle probe failed unexpectedly", exc_info=True,
351351
)
352352

353+
# CLAUDE.md Rule #52 instance #3 / Phase 4 — ICS protocol catalog
354+
# plugin registration + freeze (W2-β §SC5-NEW-ICS-S2-α HARDENED).
355+
# AFTER any catalog mtime-cache warmup AND BEFORE the FastAPI yield
356+
# so the freeze sentinel is locked before any incoming request can
357+
# reach a register_matcher() call. Closes the
358+
# Session 1 W2-β §SC5-NEW-ICS-7 hot-reload × plugin attack vector.
359+
try:
360+
from app.services.ics_protocol_catalog.plugins import (
361+
register_default_plugins,
362+
)
363+
364+
register_default_plugins(freeze=True)
365+
except Exception:
366+
import logging
367+
logging.getLogger(__name__).exception(
368+
"ics_protocol_catalog: bundled plugin registration failed at "
369+
"lifespan startup — walker will operate WITHOUT plugin "
370+
"matchers; closed-grammar YAML detection still works",
371+
)
372+
353373
yield
354374

355375
# Shutdown Redis

backend/app/services/ics_protocol_catalog/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,14 @@
3232
from app.services.ics_protocol_catalog.resolver import (
3333
_SIGNAL_COST_CLASS,
3434
_SOURCE_PRECEDENCE,
35+
PLUGIN_REGISTRY,
3536
SIGNAL_EVALUATORS,
37+
IcsProtocolMatcherProto,
3638
IcsResolverContext,
39+
_unfreeze_plugin_registry_for_tests,
40+
freeze_plugin_registry,
41+
is_plugin_registry_frozen,
42+
register_matcher,
3743
resolve_all,
3844
)
3945
from app.services.ics_protocol_catalog.snapshot import (
@@ -49,13 +55,19 @@ def get_default_snapshot() -> IcsProtocolCatalogSnapshot:
4955
__all__ = [
5056
"IcsProtocolCatalog",
5157
"IcsProtocolCatalogSnapshot",
58+
"IcsProtocolMatcherProto",
5259
"IcsResolverContext",
60+
"PLUGIN_REGISTRY",
5361
"SIGNAL_EVALUATORS",
5462
"_SIGNAL_COST_CLASS",
5563
"_SOURCE_PRECEDENCE",
64+
"_unfreeze_plugin_registry_for_tests",
5665
"derive_vendor_authority",
66+
"freeze_plugin_registry",
5767
"get_default_catalog",
5868
"get_default_snapshot",
69+
"is_plugin_registry_frozen",
70+
"register_matcher",
5971
"reset_default_catalog",
6072
"resolve_all",
6173
]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Bundled plugin discovery + registration for the ICS protocol catalog.
2+
3+
CLAUDE.md Rule #52 instance #3 / Phase 4. Wired into ``main.py:lifespan``
4+
AFTER catalog mtime-cache warmup AND BEFORE the FastAPI ``yield`` so
5+
the freeze sentinel is set before any incoming request can reach a
6+
``register_matcher`` call.
7+
8+
Discovery shape (W2-β §SC5-NEW-ICS-S2-α partial mitigation): bundled
9+
plugins are static imports below — NOT dynamic via ``importlib`` against
10+
operator-controlled name strings. The catalog loader's YAML
11+
``plugin.name`` reference (future cross-feature gate I21) MUST resolve
12+
against the frozen registry; unknown names reject the manifest at
13+
YAML-load time.
14+
15+
Hot-reload of YAML at runtime is allowed (operators tune detection
16+
without restart); plugin registration at runtime is NOT — closes the
17+
§SC5-NEW-ICS-7 hot-reload × plugin attack vector that Session 1 W2-β
18+
identified as the scariest unmitigated case.
19+
"""
20+
from __future__ import annotations
21+
22+
import logging
23+
24+
from app.services.ics_protocol_catalog.plugins.string_scanner import (
25+
StringScannerPlugin,
26+
)
27+
from app.services.ics_protocol_catalog.resolver import (
28+
freeze_plugin_registry,
29+
register_matcher,
30+
)
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
def register_default_plugins(*, freeze: bool = True) -> None:
36+
"""Register every bundled plugin, then optionally freeze the registry.
37+
38+
Wired from ``main.py:lifespan`` with ``freeze=True``. Tests can call
39+
with ``freeze=False`` to register-and-test without locking the
40+
sentinel for sibling tests.
41+
"""
42+
register_matcher("string_scanner", StringScannerPlugin())
43+
if freeze:
44+
freeze_plugin_registry()
45+
logger.info(
46+
"ics_protocol_catalog: plugin registry frozen post-startup "
47+
"(W2-β §SC5-NEW-ICS-S2-α HARDENED — MappingProxyType view + "
48+
"freeze sentinel; bundled plugins: string_scanner)"
49+
)
50+
51+
52+
__all__ = ["register_default_plugins"]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Bundled string-scanner plugin (CLAUDE.md Rule #52 instance #3, Phase 4).
2+
3+
Detects ICS protocol stacks by scanning the binary's head bytes for
4+
library-symbol byte signatures that closed-grammar
5+
``string_in_binary_constraint`` evaluators cannot express compactly.
6+
The plugin is STATELESS — accepts head + path + size + context, returns
7+
a (protocol_family, evidence) tuple per hit.
8+
9+
**Stateless contract — Rule #45 + W2-β §SC5-NEW-ICS-S2-ζ.** The plugin
10+
does NOT capture session state, settings, or context-var values in its
11+
closure. The ``register_matcher`` closure-capture gate would reject
12+
this plugin at registration if it did; the gate's META-CANARY proves
13+
the rejection fires.
14+
15+
**Bundled byte signatures.** Each entry maps a protocol_family to a
16+
list of byte-sequence needles. Hits in ANY needle imply detection
17+
(disjunctive). For the v0 release we ship coarse signatures for the
18+
3 SHIPPED protocols (modbus_tcp, dnp3, s7comm) — future plugin updates
19+
can extend.
20+
"""
21+
from __future__ import annotations
22+
23+
from typing import TYPE_CHECKING
24+
25+
if TYPE_CHECKING:
26+
from app.services.ics_protocol_catalog.resolver import IcsResolverContext
27+
28+
29+
# Per-protocol byte-needle library. Operators can extend this by
30+
# authoring YAML manifests with ``string_in_binary`` signal kinds
31+
# (closed-grammar path); this plugin covers cases where the byte
32+
# sequence is too varied for a single YAML constraint.
33+
_BYTE_NEEDLES: dict[str, list[bytes]] = {
34+
"modbus_tcp": [
35+
b"libmodbus",
36+
b"modbus_tcp_listen",
37+
b"modbus.so",
38+
b"mbap_header",
39+
],
40+
"dnp3": [
41+
b"dnp3-bin",
42+
b"dnp3_outstation",
43+
b"opendnp3",
44+
b"libdnp3",
45+
],
46+
"s7comm": [
47+
b"libs7comm",
48+
b"s7_open_connection",
49+
b"snap7",
50+
b"libsnap7",
51+
],
52+
}
53+
54+
55+
class StringScannerPlugin:
56+
"""Stateless string-scanning matcher. Closure carries no session
57+
state — confirmed by the register_matcher closure-capture gate.
58+
"""
59+
60+
# IcsProtocolMatcherProto contract — accessed by namespace-collision
61+
# check + cost-class ranking.
62+
cost_class: int = 3 # expensive — substring scan over head window
63+
protocol_families: frozenset[str] = frozenset(_BYTE_NEEDLES.keys())
64+
65+
def detect(
66+
self,
67+
blob_head: bytes,
68+
path: str,
69+
size: int,
70+
context: "IcsResolverContext | None",
71+
) -> list[dict] | None:
72+
"""Return a list of ``{protocol_family, evidence}`` dicts or None.
73+
74+
Stateless: reads only its arguments + the module-level
75+
``_BYTE_NEEDLES`` constant.
76+
"""
77+
hits: list[dict] = []
78+
for protocol_family, needles in _BYTE_NEEDLES.items():
79+
for needle in needles:
80+
if needle in blob_head:
81+
hits.append({
82+
"protocol_family": protocol_family,
83+
"evidence": (
84+
f"byte-needle {needle.decode('latin-1')!r} "
85+
f"in head"
86+
),
87+
})
88+
break # one needle hit per family is enough
89+
return hits or None
90+
91+
92+
__all__ = ["StringScannerPlugin"]

0 commit comments

Comments
 (0)