Skip to content

Commit cf6db33

Browse files
eastmadcclaude
andcommitted
feat(ics-protocol): finding-source cross-stack alignment (Phase 2 / Rule digitalandrew#25 Shape-1)
Rule #52 instance digitalandrew#3 / Phase 2: extends the finding-source cross-stack alignment surface to cover the 5 IcsProtocolFamily Literal values per W2-β §SC5-NEW-ICS-S2-η mitigation — Rule digitalandrew#25 single-slice exception digitalandrew#2 atomic commit because test_finding_source_alignment.py enforces pairwise agreement across DB CHECK + Pydantic Literal + frontend Union + frontend FINDING_SOURCE_CONFIG, and splitting leaves the alignment test RED between commits. 5 NEW source values (one per IcsProtocolFamily Literal entry): - ics_modbus_tcp_detected — exercised by Session 1 modbus_tcp.yaml - ics_modbus_rtu_detected — forward-prepared for serial-protocol YAMLs - ics_dnp3_detected — Phase 5 dnp3.yaml will exercise - ics_s7comm_detected — Phase 5 s7comm.yaml will exercise - ics_unknown_ics_detected — Phase 4 plugin escape-hatch envelope Cross-stack surfaces (all in this commit per Rule #48 5-part shape): - DB CHECK extension: alembic 2cba3d4e5f6a (chains from 1c52a4b5c6d7) - Pydantic Literal: app/schemas/finding.py::IcsProtocolFindingSource - Frontend union: frontend/src/types/index.ts::FindingSource - Frontend config: frontend/src/constants/statusConfig.ts entries (5 new entries; cyan/sky/indigo gradient per Scout D operator-UX) Walker emit (app/services/ics_protocol_walker.py): - New _ALLOWED_ICS_FINDING_SOURCES frozenset derived from IcsProtocolFindingSource via typing.get_args (single source of truth) - INNER runner emits one Finding row per (firmware, protocol_family) tuple at severity=info — closed-allowlist guarded so unmapped families skip emit instead of raising 422 at FindingService.create - Result aggregate adds `findings_emitted_count` field; _empty_result_aggregate updated for shape consistency Rule #46 paired META-CANARIES (test_ics_protocol_walker.py): - test_walker_ics_finding_source_allowlist_matches_pydantic_literal: walker allowlist EXACTLY equals Pydantic Literal (no drift either way) - test_walker_ics_finding_source_naming_convention: every Literal entry follows ics_<family>_detected — protects per-family lookup from silent rename drift Verified: - alembic upgrade 1c52a4b5c6d7 -> 2cba3d4e5f6a applied; psql confirmed 5 new ics_* sources in ck_findings_source CHECK - pytest tests/test_ics_protocol_walker.py: 11 passed (was 9) - pytest tests/test_finding_source_alignment.py on host: 3/3 passed (DB CHECK + frontend Union + frontend Config pairwise alignment) - Rule digitalandrew#24 canary + npx tsc -b --force frontend typecheck: exit 0 - broader container pytest sweep (jsonb_normalizers + walker + reapers + auth-gate + sbom_status_alignment): 808 passed Phase 3 (next): MCP tool category (backend/app/ai/tools/ics_protocol.py) with 4 tools — trigger_ics_protocol_walk + list_ics_protocols + lookup_ics_protocol_across_firmwares (Rule #44 mandatory) + describe_ics_protocol_anomalies. Includes W2-β §SC5-NEW-ICS-S2-3 + §SC5-NEW-ICS-S2-γ + §SC5-NEW-ICS-S2-ε + §SC5-NEW-ICS-S2-ι mitigations (status filter + supply_chain_signal curated-only + project_id scope + schema_version legacy-rows filter) per Wave-1 C + W2-β cross-feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2dc77a1 commit cf6db33

6 files changed

Lines changed: 324 additions & 2 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
r"""extend findings.source CHECK with ICS protocol catalog sources (Rule #52 instance #3 Phase 2)
2+
3+
Revision ID: 2cba3d4e5f6a
4+
Revises: 1c52a4b5c6d7
5+
Create Date: 2026-05-22 10:00:00.000000
6+
7+
Rule #25 Shape-1 cross-stack alignment commit. Mirrors the bare_metal
8+
``fd6e7f8a9b0c`` precedent — extends ``ck_findings_source`` with 5 NEW
9+
finding sources matching the closed ``IcsProtocolFamily`` Literal in
10+
``app/schemas/ics_protocol.py``. Per W2-β §SC5-NEW-ICS-S2-η mitigation,
11+
ALL family values get a corresponding finding source — adding a new
12+
protocol YAML (Phase 5: DNP3, S7Comm; future: opc_ua, ethercat) without
13+
a matching source value would silently reject finding emits at runtime.
14+
15+
Cross-stack contract surfaces (Rule #48 5-part):
16+
- DB CHECK: this migration extends ``ck_findings_source``
17+
- Pydantic Literal: ``IcsProtocolFindingSource`` in app/schemas/finding.py
18+
- Frontend mirror: ``FindingSource`` union in types/index.ts +
19+
``FINDING_SOURCE_CONFIG`` keys in statusConfig.ts
20+
- Alignment test: ``test_finding_source_alignment`` auto-discovers
21+
this migration via ``_latest_source_check_migration_path()`` walking
22+
the alembic chain from HEAD; pulls ``_NEW_SOURCE_VALUES`` tuple as
23+
the authoritative DB-side allowlist
24+
25+
NEW SOURCES:
26+
27+
- ``ics_modbus_tcp_detected`` — emitted when the walker matches at
28+
least one binary against a Modbus/TCP manifest (Session 1's
29+
``_system/modbus_tcp.yaml`` is the production reference; Phase 5
30+
will not extend with sibling Modbus/RTU YAMLs).
31+
- ``ics_modbus_rtu_detected`` — Modbus RTU over serial (RS-485);
32+
forward-prepared for future serial-protocol YAML extensions.
33+
- ``ics_dnp3_detected`` — emitted when the walker matches at least one
34+
binary against a DNP3 manifest (Phase 5 ``_system/dnp3.yaml`` will
35+
exercise this source).
36+
- ``ics_s7comm_detected`` — emitted when the walker matches at least
37+
one binary against a Siemens S7Comm/S7Comm-Plus manifest (Phase 5
38+
``_system/s7comm.yaml`` will exercise this source).
39+
- ``ics_unknown_ics_detected`` — emitted when a plugin (Phase 4) flags
40+
an ICS-like stack the closed-grammar catalog cannot identify;
41+
forward-prepared for the W2-β §SC5-NEW-ICS-7 hot-reload mitigation
42+
envelope (plugin escape hatch).
43+
44+
Live audit at migration-authoring time (2026-05-22):
45+
46+
SELECT source, COUNT(*) FROM findings GROUP BY source ORDER BY 2 DESC;
47+
-- 0 rows for any ics_* source (Phase 2 first consumer).
48+
49+
Zero existing rows ⇒ "extend CHECK" path; no defensive backfill needed.
50+
51+
Mirrors ``fd6e7f8a9b0c_extend_findings_source_bare_metal.py``. Same
52+
drop-and-recreate shape with extended ``_NEW_SOURCE_VALUES`` tuple.
53+
Tuple is exposed under that exact name so
54+
``test_finding_source_alignment._load_db_source_values`` can import +
55+
assert agreement.
56+
57+
Chains from ICS Phase 1.A DDL ``1c52a4b5c6d7``.
58+
"""
59+
from __future__ import annotations
60+
61+
from alembic import op
62+
63+
64+
revision: str = "2cba3d4e5f6a"
65+
down_revision: str | None = "1c52a4b5c6d7"
66+
branch_labels: str | None = None
67+
depends_on: str | None = None
68+
69+
70+
_NEW_SOURCE_VALUES: tuple[str, ...] = (
71+
# ── canonical core ─────────────────────────────────────────────────
72+
"manual", "security_audit", "yara_scan", "attack_surface", "sbom_scan",
73+
"hardware_firmware_graph", "apk-manifest-scan", "apk-bytecode-scan",
74+
"apk-mobsfscan", "cwe_checker", "uefi_scan", "clamav_scan", "vt_scan",
75+
"abusech_scan", "fuzzing", "unpack_audit", "security_review", "ai_discovered",
76+
# ── windows family ────────────────────────────────────────────────
77+
"windows_authenticode", "windows_dbx_revoked",
78+
"windows_registry_persistence", "windows_inf", "windows_driver_imports",
79+
"windows_r2r_stomp", "windows_il_capa",
80+
"windows_sysmon_proc_create", "windows_logon_success", "windows_logon_failure",
81+
"windows_amcache_install", "windows_prefetch_execution",
82+
"windows_srum_network_activity", "windows_srum_application_runtime",
83+
"windows_powershell_script_block", "windows_scheduled_task_persistence",
84+
"windows_lnk_abnormal_target",
85+
"windows_mft_ads_hidden_content", "windows_mft_timestomping",
86+
"windows_byovd_driver",
87+
"windows_bcd_suspicious_path", "windows_bcd_testsigning_enabled",
88+
"windows_wmi_persistence",
89+
"windows_esp_unsigned", "windows_esp_dbx_revoked",
90+
"windows_mbr_bootkit", "windows_vbr_anomaly",
91+
"windows_sdb_inject_dll", "windows_sdb_redirect_exe", "windows_sdb_custom_shim",
92+
"windows_etl_kernel_proc_after_clear", "windows_etl_provider_disabled",
93+
"windows_etl_unusual_provider", "windows_etl_non_microsoft_in_diagtrack",
94+
"windows_efs_orphaned_drf", "windows_efs_unusual_recovery_agent",
95+
"windows_efs_domain_admin_in_ddf", "windows_efs_large_drf",
96+
"windows_appcompat_suspicious_path", "windows_appcompat_temp_execution",
97+
"windows_appcompat_recent_baseline",
98+
"windows_dpapi_orphaned_masterkey", "windows_dpapi_admin_creator_sid",
99+
"windows_dpapi_large_masterkey",
100+
"windows_usnjrnl_file_deletion", "windows_usnjrnl_temp_create_delete_pair",
101+
"windows_usnjrnl_renamed_executable",
102+
# ── linux family ──────────────────────────────────────────────────
103+
"linux_journald_priority_critical", "linux_journald_oom_killer",
104+
"linux_journald_suspicious_unit", "linux_journald_log_clear",
105+
"linux_journald_selinux_denied",
106+
"linux_systemd_suspicious_path", "linux_systemd_obfuscated_exec",
107+
"linux_systemd_socket_unusual_port", "linux_systemd_root_minimal_deps",
108+
"linux_systemd_enabled_outside_standard",
109+
"linux_container_privileged_mode", "linux_container_dangerous_capability",
110+
"linux_container_unsafe_host_mount", "linux_container_unconfined_security",
111+
"linux_container_unknown_registry_image",
112+
"linux_bash_history_clear", "linux_cron_suspicious_command",
113+
"linux_ld_preload_hijack",
114+
# ── Rule #52 instance #1 — bare-metal MCU/DSP family ─────────────
115+
"c28x_unsecure_csm",
116+
"c28x_csm_perma_lock",
117+
"bare_metal_chip_unknown_with_hints",
118+
"bare_metal_encrypted_region_skipped",
119+
# ── Rule #52 instance #3 — ICS protocol catalog family (THIS) ────
120+
# All 5 IcsProtocolFamily Literal values get an ics_*_detected
121+
# source per W2-β §SC5-NEW-ICS-S2-η mitigation — forward-prepares
122+
# future protocol YAMLs (opc_ua, ethercat) without requiring a
123+
# second cross-stack alignment commit each time.
124+
"ics_modbus_tcp_detected",
125+
"ics_modbus_rtu_detected",
126+
"ics_dnp3_detected",
127+
"ics_s7comm_detected",
128+
"ics_unknown_ics_detected",
129+
)
130+
131+
132+
_REMOVED_IN_THIS_REVISION: tuple[str, ...] = (
133+
"ics_modbus_tcp_detected",
134+
"ics_modbus_rtu_detected",
135+
"ics_dnp3_detected",
136+
"ics_s7comm_detected",
137+
"ics_unknown_ics_detected",
138+
)
139+
140+
141+
def _in_list_sql(column: str, values: tuple[str, ...]) -> str:
142+
quoted = ", ".join(f"'{v}'" for v in values)
143+
return f"{column} IN ({quoted})"
144+
145+
146+
def upgrade() -> None:
147+
op.drop_constraint("ck_findings_source", "findings", type_="check")
148+
op.create_check_constraint(
149+
"ck_findings_source",
150+
"findings",
151+
_in_list_sql("source", _NEW_SOURCE_VALUES),
152+
)
153+
154+
155+
def downgrade() -> None:
156+
op.execute(
157+
"UPDATE findings SET source = 'manual' "
158+
"WHERE source IN ("
159+
+ ", ".join(f"'{v}'" for v in _REMOVED_IN_THIS_REVISION)
160+
+ ")"
161+
)
162+
163+
op.drop_constraint("ck_findings_source", "findings", type_="check")
164+
prior = tuple(v for v in _NEW_SOURCE_VALUES if v not in _REMOVED_IN_THIS_REVISION)
165+
op.create_check_constraint(
166+
"ck_findings_source",
167+
"findings",
168+
_in_list_sql("source", prior),
169+
)

backend/app/schemas/finding.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,36 @@
575575
]
576576

577577

578+
# CLAUDE.md Rule #52 instance #3 — ICS protocol catalog walker emit sources.
579+
# Sibling of BareMetalFindingSource. Walker
580+
# (``backend/app/services/ics_protocol_walker.py``) emits one finding row per
581+
# ``(firmware, protocol_family)`` tuple at severity=info when the closed-
582+
# grammar resolver matches at least one binary against a manifest declaring
583+
# the family. The 5 family values mirror the IcsProtocolFamily Literal in
584+
# ``app/schemas/ics_protocol.py`` per W2-β §SC5-NEW-ICS-S2-η mitigation —
585+
# every protocol_family the catalog can output MUST have a corresponding
586+
# finding source declared here (catalog loader validates at YAML-load time
587+
# in a future Phase 4 commit; missing source rejects the manifest with
588+
# WARN, not silent skip).
589+
#
590+
# - ``ics_modbus_tcp_detected`` — Session 1's ``_system/modbus_tcp.yaml``
591+
# exercises this source.
592+
# - ``ics_modbus_rtu_detected`` — forward-prepared for future Modbus-RTU
593+
# serial YAML extension.
594+
# - ``ics_dnp3_detected`` — Phase 5 ``_system/dnp3.yaml`` exercises this.
595+
# - ``ics_s7comm_detected`` — Phase 5 ``_system/s7comm.yaml`` exercises this.
596+
# - ``ics_unknown_ics_detected`` — Phase 4 plugin escape hatch (W2-β
597+
# §SC5-NEW-ICS-7 hot-reload mitigation envelope); reserved for plugins
598+
# that detect an ICS stack but cannot identify the family.
599+
IcsProtocolFindingSource = Literal[
600+
"ics_modbus_tcp_detected",
601+
"ics_modbus_rtu_detected",
602+
"ics_dnp3_detected",
603+
"ics_s7comm_detected",
604+
"ics_unknown_ics_detected",
605+
]
606+
607+
578608
class Severity(str, Enum):
579609
critical = "critical"
580610
high = "high"

backend/app/services/ics_protocol_walker.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,18 @@
6262
import traceback
6363
import uuid
6464
from collections import Counter
65+
from typing import get_args
6566

6667
from sqlalchemy.ext.asyncio import AsyncSession
6768

6869
from app.database import async_session_factory
6970
from app.models.firmware import Firmware
71+
from app.schemas.finding import (
72+
FindingCreate,
73+
IcsProtocolFindingSource,
74+
Severity,
75+
)
76+
from app.services.finding_service import FindingService
7077
from app.services.firmware_paths import get_detection_roots
7178
from app.services.ics_protocol_catalog import (
7279
IcsResolverContext,
@@ -77,6 +84,19 @@
7784
_stamp_firmware_ics_protocol_walk_result,
7885
)
7986

87+
88+
# Closed allowlist of ICS protocol-family finding sources (Rule #25 Shape-1
89+
# alignment surface — DB CHECK ↔ Pydantic Literal ↔ frontend mirror). The
90+
# walker emits a Finding per (firmware, protocol_family) tuple where the
91+
# source `ics_{protocol_family}_detected` is a declared member of
92+
# IcsProtocolFindingSource. Per W2-β §SC5-NEW-ICS-S2-η mitigation: if a
93+
# future protocol_family value lands in IcsProtocolFamily Literal without
94+
# a corresponding finding source declared here + in the DB CHECK, the
95+
# walker SKIPS emit (no silent FastAPI 422 on FindingService.create).
96+
_ALLOWED_ICS_FINDING_SOURCES: frozenset[str] = frozenset(
97+
get_args(IcsProtocolFindingSource)
98+
)
99+
80100
logger = logging.getLogger(__name__)
81101

82102

@@ -176,6 +196,7 @@ def _empty_result_aggregate(
176196
"protocol_family_counts": {},
177197
"manifest_ids_seen": [],
178198
"manifest_sources_seen": [],
199+
"findings_emitted_count": 0,
179200
"errors": errors,
180201
}
181202

@@ -294,6 +315,47 @@ async def _do_ics_protocol_walk(
294315
"matches": match_dicts,
295316
})
296317

318+
# Emit findings — one per (firmware, protocol_family). Severity is
319+
# always INFO (an ICS protocol detected on a PLC is informational
320+
# context, not a vulnerability). Gated by closed allowlist to
321+
# respect the Rule #25 Shape-1 cross-stack alignment contract per
322+
# W2-β §SC5-NEW-ICS-S2-η — protocol families without a declared
323+
# finding source skip emit silently.
324+
findings_emitted = 0
325+
finding_emit_errors: list[str] = []
326+
if family_counts:
327+
findings_service = FindingService(db)
328+
for protocol_family, count in family_counts.items():
329+
source = f"ics_{protocol_family}_detected"
330+
if source not in _ALLOWED_ICS_FINDING_SOURCES:
331+
finding_emit_errors.append(
332+
f"protocol_family={protocol_family!r} has no declared "
333+
f"finding source — manifest output declares a family "
334+
f"the IcsProtocolFindingSource Literal does not list"
335+
)
336+
continue
337+
try:
338+
await findings_service.create(
339+
project_id=firmware.project_id,
340+
data=FindingCreate(
341+
title=f"ICS protocol detected: {protocol_family}",
342+
severity=Severity.info,
343+
firmware_id=firmware_id,
344+
source=source,
345+
description=(
346+
f"Walker detected {count} binary match(es) for "
347+
f"ICS protocol family {protocol_family}. See "
348+
f"ics_protocol_walk_result JSONB for per-binary "
349+
f"match detail + manifest_ids_seen."
350+
),
351+
),
352+
)
353+
findings_emitted += 1
354+
except Exception as exc: # noqa: BLE001 — emit-boundary
355+
finding_emit_errors.append(
356+
f"finding emit failed for {protocol_family}: {exc}"
357+
)
358+
297359
# W2-β §SC5-NEW-ICS-S2-β: read snapshot at EXIT; flag drift.
298360
exit_snapshot = catalog.get_snapshot()
299361
snapshot_id_at_exit = exit_snapshot.snapshot_id
@@ -316,7 +378,8 @@ async def _do_ics_protocol_walk(
316378
"protocol_family_counts": dict(family_counts),
317379
"manifest_ids_seen": manifest_ids,
318380
"manifest_sources_seen": manifest_sources,
319-
"errors": errors,
381+
"findings_emitted_count": findings_emitted,
382+
"errors": errors + finding_emit_errors,
320383
}
321384

322385

backend/tests/test_ics_protocol_walker.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,58 @@ def test_ics_protocol_walk_status_literal_matches_db_check_values():
465465
f"the DB CHECK ck_firmware_ics_protocol_walk_status value set "
466466
f"{db_check_values!r}. Rule #33 .c contract."
467467
)
468+
469+
470+
# ───────────────────────────────────────────────────────────────────────
471+
# Phase 2: walker finding-emit Rule #25 Shape-1 alignment META-CANARIES.
472+
# ───────────────────────────────────────────────────────────────────────
473+
474+
475+
def test_walker_ics_finding_source_allowlist_matches_pydantic_literal():
476+
"""W2-β §SC5-NEW-ICS-S2-η META-CANARY — the walker's
477+
``_ALLOWED_ICS_FINDING_SOURCES`` frozenset MUST EXACTLY equal the
478+
Pydantic ``IcsProtocolFindingSource`` Literal value set.
479+
480+
Drift means either: (a) the walker can emit a source the DB CHECK
481+
rejects (Pydantic 422 at FindingService.create), or (b) the
482+
Literal admits a value the walker would never emit (dead schema).
483+
"""
484+
from typing import get_args
485+
486+
from app.schemas.finding import IcsProtocolFindingSource
487+
from app.services.ics_protocol_walker import _ALLOWED_ICS_FINDING_SOURCES
488+
489+
literal_values = frozenset(get_args(IcsProtocolFindingSource))
490+
assert _ALLOWED_ICS_FINDING_SOURCES == literal_values, (
491+
f"_ALLOWED_ICS_FINDING_SOURCES {_ALLOWED_ICS_FINDING_SOURCES!r} "
492+
f"MUST equal IcsProtocolFindingSource {literal_values!r}. "
493+
f"Drift means the walker either emits a source the DB rejects "
494+
f"or admits dead schema. Rule #25 Shape-1 cross-stack alignment."
495+
)
496+
497+
498+
def test_walker_ics_finding_source_naming_convention():
499+
"""Every entry in the IcsProtocolFindingSource Literal MUST follow
500+
the ``ics_<protocol_family>_detected`` naming convention so the
501+
walker's per-family lookup
502+
(``f"ics_{family}_detected"``) hits all declared values.
503+
504+
Without this canary, a future commit could rename a source value
505+
(e.g. ``ics_modbus_tcp_detected`` -> ``modbus_detected``) and the
506+
walker would silently stop emitting for that family.
507+
"""
508+
from typing import get_args
509+
510+
from app.schemas.finding import IcsProtocolFindingSource
511+
512+
for source in get_args(IcsProtocolFindingSource):
513+
assert source.startswith("ics_") and source.endswith("_detected"), (
514+
f"source {source!r} does NOT match the "
515+
f"`ics_<family>_detected` convention; the walker's per-family "
516+
f"emit lookup `f'ics_{{family}}_detected'` will not hit it."
517+
)
518+
family = source[len("ics_") : -len("_detected")]
519+
assert family, (
520+
f"source {source!r} has empty protocol family between "
521+
f"`ics_` and `_detected`"
522+
)

frontend/src/constants/statusConfig.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ export const FINDING_SOURCE_CONFIG: Record<FindingSource, FindingSourceConfigEnt
170170
c28x_csm_perma_lock: { icon: KeyRound, label: 'C28x CSM Perma-Lock', className: 'border-amber-600/50 text-amber-700 dark:text-amber-300' },
171171
bare_metal_chip_unknown_with_hints: { icon: Info, label: 'Bare-Metal Chip Unknown', className: 'border-blue-500/50 text-blue-600 dark:text-blue-400' },
172172
bare_metal_encrypted_region_skipped: { icon: Info, label: 'Encrypted Region Skipped', className: 'border-slate-500/50 text-slate-600 dark:text-slate-400' },
173+
ics_modbus_tcp_detected: { icon: Network, label: 'ICS: Modbus/TCP', className: 'border-cyan-500/50 text-cyan-600 dark:text-cyan-400' },
174+
ics_modbus_rtu_detected: { icon: Network, label: 'ICS: Modbus RTU', className: 'border-cyan-500/50 text-cyan-700 dark:text-cyan-300' },
175+
ics_dnp3_detected: { icon: Network, label: 'ICS: DNP3', className: 'border-sky-500/50 text-sky-600 dark:text-sky-400' },
176+
ics_s7comm_detected: { icon: Network, label: 'ICS: S7Comm', className: 'border-indigo-500/50 text-indigo-600 dark:text-indigo-400' },
177+
ics_unknown_ics_detected: { icon: Info, label: 'ICS: Unknown Stack', className: 'border-slate-500/50 text-slate-600 dark:text-slate-400' },
173178
}
174179

175180
// ── Finding confidence ──

0 commit comments

Comments
 (0)