Commit 960500f
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
- services/ics_protocol_catalog
- plugins
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
350 | 350 | | |
351 | 351 | | |
352 | 352 | | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
353 | 373 | | |
354 | 374 | | |
355 | 375 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| 35 | + | |
35 | 36 | | |
| 37 | + | |
36 | 38 | | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
37 | 43 | | |
38 | 44 | | |
39 | 45 | | |
| |||
49 | 55 | | |
50 | 56 | | |
51 | 57 | | |
| 58 | + | |
52 | 59 | | |
| 60 | + | |
53 | 61 | | |
54 | 62 | | |
55 | 63 | | |
| 64 | + | |
56 | 65 | | |
| 66 | + | |
57 | 67 | | |
58 | 68 | | |
| 69 | + | |
| 70 | + | |
59 | 71 | | |
60 | 72 | | |
61 | 73 | | |
Lines changed: 52 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
Lines changed: 92 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
0 commit comments