qids 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qids/__init__.py +38 -0
- qids/__main__.py +3 -0
- qids/analysis/__init__.py +50 -0
- qids/analysis/evasion.py +233 -0
- qids/analysis/metrics.py +263 -0
- qids/analysis/plots.py +358 -0
- qids/analysis/prometheus.py +140 -0
- qids/analysis/sizing.py +180 -0
- qids/analysis/sweeps.py +312 -0
- qids/analysis/tracing.py +201 -0
- qids/attacks/__init__.py +39 -0
- qids/attacks/base.py +116 -0
- qids/attacks/channel.py +205 -0
- qids/attacks/forgery.py +256 -0
- qids/attacks/impersonation.py +183 -0
- qids/attacks/otuh_attacks.py +232 -0
- qids/attacks/replay.py +138 -0
- qids/attacks/repudiation.py +116 -0
- qids/blockchain.py +243 -0
- qids/cli.py +672 -0
- qids/core_accel.py +119 -0
- qids/detect/__init__.py +46 -0
- qids/detect/baseline.py +502 -0
- qids/detect/dispute.py +247 -0
- qids/detect/engine.py +463 -0
- qids/detect/ledgers.py +197 -0
- qids/detect/monitors.py +660 -0
- qids/detect/signals.py +94 -0
- qids/detect/sprt.py +147 -0
- qids/detect/statistics.py +221 -0
- qids/errors.py +101 -0
- qids/hardware/__init__.py +36 -0
- qids/hardware/dwdm.py +256 -0
- qids/hardware/etsi_014.py +252 -0
- qids/hardware/etsi_mock_server.py +163 -0
- qids/hardware/optical_profiles.py +145 -0
- qids/hashed_session.py +246 -0
- qids/protocol/__init__.py +73 -0
- qids/protocol/auth.py +156 -0
- qids/protocol/channel_defaults.py +39 -0
- qids/protocol/document.py +265 -0
- qids/protocol/hardware.py +310 -0
- qids/protocol/hybrid.py +236 -0
- qids/protocol/keys.py +136 -0
- qids/protocol/kgp.py +507 -0
- qids/protocol/lfsr.py +322 -0
- qids/protocol/otuh.py +619 -0
- qids/protocol/params.py +285 -0
- qids/protocol/pkcs11.py +392 -0
- qids/protocol/qpk.py +203 -0
- qids/protocol/relay.py +290 -0
- qids/protocol/sign.py +187 -0
- qids/protocol/verify.py +272 -0
- qids/py.typed +0 -0
- qids/quantum/__init__.py +93 -0
- qids/quantum/channel.py +99 -0
- qids/quantum/gates.py +189 -0
- qids/quantum/measure.py +114 -0
- qids/quantum/state.py +117 -0
- qids/quantum/teleport.py +172 -0
- qids/scenarios.py +449 -0
- qids/server/__init__.py +5 -0
- qids/server/daemon.py +270 -0
- qids/session.py +280 -0
- qids-1.3.0.dist-info/METADATA +485 -0
- qids-1.3.0.dist-info/RECORD +69 -0
- qids-1.3.0.dist-info/WHEEL +5 -0
- qids-1.3.0.dist-info/entry_points.txt +2 -0
- qids-1.3.0.dist-info/top_level.txt +1 -0
qids/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""QIDS -- Quantum-Inspired Detection for Signatures.
|
|
2
|
+
|
|
3
|
+
A threat-detection framework for teleportation-based Quantum Digital
|
|
4
|
+
Signature protocols. Simulates Bell-state entanglement distribution and
|
|
5
|
+
quantum teleportation exactly, applies Pauli corrections and projective
|
|
6
|
+
measurements for verification, and detects forgery, impersonation, replay,
|
|
7
|
+
unauthorised verification and channel manipulation through statistical
|
|
8
|
+
threshold rules alone -- no machine learning anywhere in the decision path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "1.3.0"
|
|
12
|
+
|
|
13
|
+
import logging as _logging
|
|
14
|
+
|
|
15
|
+
# Library convention: attach a NullHandler so importing qids never configures
|
|
16
|
+
# logging for the host application. Operators opt in with
|
|
17
|
+
# ``logging.getLogger("qids").setLevel(logging.INFO)``.
|
|
18
|
+
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
|
19
|
+
|
|
20
|
+
from .errors import ( # noqa: F401
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
InsecureConfiguration,
|
|
24
|
+
KeyExhaustedError,
|
|
25
|
+
KeyMaterialError,
|
|
26
|
+
ProtocolError,
|
|
27
|
+
QidsError,
|
|
28
|
+
UnknownScenario,
|
|
29
|
+
)
|
|
30
|
+
from .protocol.params import ProtocolParams # noqa: F401
|
|
31
|
+
from .session import QdsSession, SessionResult # noqa: F401
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ProtocolParams", "QdsSession", "SessionResult", "__version__",
|
|
35
|
+
"QidsError", "ConfigurationError", "InsecureConfiguration",
|
|
36
|
+
"ProtocolError", "KeyExhaustedError", "KeyMaterialError",
|
|
37
|
+
"AuthenticationError", "UnknownScenario",
|
|
38
|
+
]
|
qids/__main__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Security analysis, metrics and benchmarking."""
|
|
2
|
+
|
|
3
|
+
from .evasion import ( # noqa: F401
|
|
4
|
+
EvasionPoint,
|
|
5
|
+
Frontier,
|
|
6
|
+
acceptance_curve,
|
|
7
|
+
evaluate_strength,
|
|
8
|
+
find_frontier,
|
|
9
|
+
is_monotone,
|
|
10
|
+
threshold_only_frontier,
|
|
11
|
+
)
|
|
12
|
+
from .metrics import EvaluationReport, ScenarioStats, evaluate # noqa: F401
|
|
13
|
+
from .sizing import ( # noqa: F401
|
|
14
|
+
LinkSizingParameters,
|
|
15
|
+
LinkSizingResult,
|
|
16
|
+
calculate_optical_link_budget,
|
|
17
|
+
format_sizing_report,
|
|
18
|
+
)
|
|
19
|
+
from .sweeps import ( # noqa: F401
|
|
20
|
+
detection_power_curve,
|
|
21
|
+
forgery_probability_curve,
|
|
22
|
+
leakage_tolerance,
|
|
23
|
+
repudiation_probability_curve,
|
|
24
|
+
validate_forger_model,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
#: Public surface of this subpackage. Declared explicitly because a
|
|
28
|
+
#: re-export is only 'used' from the outside, and ``# noqa`` does not
|
|
29
|
+
#: reach pyflakes -- ``__all__`` does.
|
|
30
|
+
__all__ = [
|
|
31
|
+
"EvaluationReport",
|
|
32
|
+
"EvasionPoint",
|
|
33
|
+
"Frontier",
|
|
34
|
+
"LinkSizingParameters",
|
|
35
|
+
"LinkSizingResult",
|
|
36
|
+
"ScenarioStats",
|
|
37
|
+
"acceptance_curve",
|
|
38
|
+
"calculate_optical_link_budget",
|
|
39
|
+
"detection_power_curve",
|
|
40
|
+
"evaluate",
|
|
41
|
+
"evaluate_strength",
|
|
42
|
+
"find_frontier",
|
|
43
|
+
"format_sizing_report",
|
|
44
|
+
"forgery_probability_curve",
|
|
45
|
+
"is_monotone",
|
|
46
|
+
"leakage_tolerance",
|
|
47
|
+
"repudiation_probability_curve",
|
|
48
|
+
"threshold_only_frontier",
|
|
49
|
+
"validate_forger_model",
|
|
50
|
+
]
|
qids/analysis/evasion.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""The strongest attack that survives: mapping the evasion frontier.
|
|
2
|
+
|
|
3
|
+
Every scenario in the catalogue runs an attack at a *fixed* strength and asks
|
|
4
|
+
whether it is caught. That answers "does this attack work against the
|
|
5
|
+
detector", which is the easy question. The hard one is the adversary's:
|
|
6
|
+
|
|
7
|
+
given that this detector exists, and knowing its thresholds, what is the
|
|
8
|
+
most I can do and still be accepted?
|
|
9
|
+
|
|
10
|
+
An attacker who reads the source picks their strength deliberately. If the
|
|
11
|
+
only defence were the protocol's own threshold rule, they would tune the
|
|
12
|
+
attack to sit just under s_a and walk through. So the number that actually
|
|
13
|
+
characterises the framework is the *largest attack strength that still gets
|
|
14
|
+
ACCEPT*, and how much that concession costs the attacker.
|
|
15
|
+
|
|
16
|
+
Two frontiers are reported per family:
|
|
17
|
+
|
|
18
|
+
``p50`` the strength at which acceptance falls to 50%
|
|
19
|
+
``p05`` the strength at which acceptance falls to 5% -- the practical ceiling
|
|
20
|
+
|
|
21
|
+
Bisection rather than a grid, because the transition is sharp and a grid
|
|
22
|
+
either misses it or wastes runs far from it. Acceptance is monotone in
|
|
23
|
+
strength for every family here (more disturbance is never easier to hide),
|
|
24
|
+
which is what makes bisection valid; the sweep in ``acceptance_curve`` is
|
|
25
|
+
kept so that assumption stays checkable rather than assumed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from ..detect.engine import Decision
|
|
35
|
+
from ..protocol.verify import Level
|
|
36
|
+
from ..session import QdsSession
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class EvasionPoint:
|
|
41
|
+
"""Outcome of testing one attack strength."""
|
|
42
|
+
|
|
43
|
+
strength: float
|
|
44
|
+
trials: int
|
|
45
|
+
accepted: int
|
|
46
|
+
mean_mismatch: float
|
|
47
|
+
fired: dict = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def acceptance(self) -> float:
|
|
51
|
+
return self.accepted / self.trials if self.trials else 0.0
|
|
52
|
+
|
|
53
|
+
def summary(self) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"strength": self.strength,
|
|
56
|
+
"acceptance": self.acceptance,
|
|
57
|
+
"mean_mismatch": self.mean_mismatch,
|
|
58
|
+
"fired": self.fired,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Frontier:
|
|
64
|
+
"""Where a family of attacks stops getting through."""
|
|
65
|
+
|
|
66
|
+
family: str
|
|
67
|
+
p50: float
|
|
68
|
+
p05: float
|
|
69
|
+
detail: list = field(default_factory=list)
|
|
70
|
+
threshold_s_a: float = 0.0
|
|
71
|
+
mismatch_at_p50: float = 0.0
|
|
72
|
+
mismatch_at_p05: float = 0.0
|
|
73
|
+
first_monitor: str = ""
|
|
74
|
+
|
|
75
|
+
def summary(self) -> dict:
|
|
76
|
+
return {
|
|
77
|
+
"family": self.family,
|
|
78
|
+
"strength_at_50pct_acceptance": self.p50,
|
|
79
|
+
"strength_at_5pct_acceptance": self.p05,
|
|
80
|
+
"mismatch_at_p50": self.mismatch_at_p50,
|
|
81
|
+
"mismatch_at_p05": self.mismatch_at_p05,
|
|
82
|
+
"s_a": self.threshold_s_a,
|
|
83
|
+
"first_monitor_to_fire": self.first_monitor,
|
|
84
|
+
"points": [p.summary() for p in self.detail],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def evaluate_strength(
|
|
89
|
+
params,
|
|
90
|
+
adversary_factory,
|
|
91
|
+
strength: float,
|
|
92
|
+
trials: int = 10,
|
|
93
|
+
seed: int = 0,
|
|
94
|
+
holder: str = "bob",
|
|
95
|
+
) -> EvasionPoint:
|
|
96
|
+
"""Run the full pipeline ``trials`` times at one attack strength."""
|
|
97
|
+
accepted, mismatches = 0, []
|
|
98
|
+
fired: dict = {}
|
|
99
|
+
for t in range(trials):
|
|
100
|
+
rng = np.random.default_rng(seed * 7717 + int(strength * 1e6) * 31 + t)
|
|
101
|
+
sess = QdsSession(params, rng)
|
|
102
|
+
sess.distribute(adversary=adversary_factory(strength))
|
|
103
|
+
sess.symmetrise()
|
|
104
|
+
decoys = sess.check_decoys(0)
|
|
105
|
+
sig = sess.sign(b"EVASION", 0)
|
|
106
|
+
env = sess.transport(sig, holder)
|
|
107
|
+
ev, verdict = sess.deliver(sig, holder, Level.AUTHENTICATION,
|
|
108
|
+
envelope=env, decoy_evidence=decoys.get(holder))
|
|
109
|
+
mismatches.append(ev.mismatch_rate)
|
|
110
|
+
if verdict.decision == Decision.ACCEPT:
|
|
111
|
+
accepted += 1
|
|
112
|
+
for name in verdict.fired_names:
|
|
113
|
+
fired[name] = fired.get(name, 0) + 1
|
|
114
|
+
return EvasionPoint(
|
|
115
|
+
strength=strength, trials=trials, accepted=accepted,
|
|
116
|
+
mean_mismatch=float(np.mean(mismatches)),
|
|
117
|
+
fired={k: v / trials for k, v in sorted(fired.items())},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_frontier(
|
|
122
|
+
params,
|
|
123
|
+
adversary_factory,
|
|
124
|
+
family: str,
|
|
125
|
+
lo: float = 0.0,
|
|
126
|
+
hi: float = 1.0,
|
|
127
|
+
trials: int = 10,
|
|
128
|
+
iterations: int = 7,
|
|
129
|
+
seed: int = 0,
|
|
130
|
+
) -> Frontier:
|
|
131
|
+
"""Bisect for the strengths at which acceptance crosses 50% and 5%."""
|
|
132
|
+
points: list = []
|
|
133
|
+
|
|
134
|
+
def probe(x: float) -> EvasionPoint:
|
|
135
|
+
for p in points:
|
|
136
|
+
if abs(p.strength - x) < 1e-9:
|
|
137
|
+
return p
|
|
138
|
+
p = evaluate_strength(params, adversary_factory, x, trials, seed)
|
|
139
|
+
points.append(p)
|
|
140
|
+
return p
|
|
141
|
+
|
|
142
|
+
def bisect(target: float) -> float:
|
|
143
|
+
a, b = lo, hi
|
|
144
|
+
if probe(b).acceptance >= target: # never suppressed in range
|
|
145
|
+
return b
|
|
146
|
+
if probe(a).acceptance < target: # already suppressed at the floor
|
|
147
|
+
return a
|
|
148
|
+
for _ in range(iterations):
|
|
149
|
+
mid = (a + b) / 2
|
|
150
|
+
if probe(mid).acceptance >= target:
|
|
151
|
+
a = mid
|
|
152
|
+
else:
|
|
153
|
+
b = mid
|
|
154
|
+
return (a + b) / 2
|
|
155
|
+
|
|
156
|
+
p50 = bisect(0.5)
|
|
157
|
+
p05 = bisect(0.05)
|
|
158
|
+
points.sort(key=lambda p: p.strength)
|
|
159
|
+
|
|
160
|
+
def mismatch_near(x: float) -> float:
|
|
161
|
+
return min(points, key=lambda p: abs(p.strength - x)).mean_mismatch
|
|
162
|
+
|
|
163
|
+
first = first_monitor(points)
|
|
164
|
+
|
|
165
|
+
return Frontier(
|
|
166
|
+
family=family, p50=p50, p05=p05, detail=points,
|
|
167
|
+
threshold_s_a=params.thresholds[0],
|
|
168
|
+
mismatch_at_p50=mismatch_near(p50),
|
|
169
|
+
mismatch_at_p05=mismatch_near(p05),
|
|
170
|
+
first_monitor=first,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def first_monitor(points, min_rate: float = 0.25) -> str:
|
|
175
|
+
"""Which monitor is the first to engage as attack strength rises?
|
|
176
|
+
|
|
177
|
+
``min_rate`` matters more than it looks. Taking the lowest strength where
|
|
178
|
+
*any* monitor fires at all picks up single noise firings -- one trial in
|
|
179
|
+
twelve, while the attack is still being accepted 92% of the time -- and
|
|
180
|
+
labels that the "first" monitor, which is misleading. Requiring a monitor
|
|
181
|
+
to fire on a real fraction of trials before it counts gives a label that
|
|
182
|
+
reflects the ladder rather than the tail of the sampling distribution.
|
|
183
|
+
"""
|
|
184
|
+
for p in sorted(points, key=lambda q: q.strength):
|
|
185
|
+
meaningful = {k: v for k, v in p.fired.items() if v >= min_rate}
|
|
186
|
+
if meaningful:
|
|
187
|
+
return max(meaningful, key=meaningful.get)
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def acceptance_curve(
|
|
192
|
+
params,
|
|
193
|
+
adversary_factory,
|
|
194
|
+
strengths,
|
|
195
|
+
trials: int = 8,
|
|
196
|
+
seed: int = 0,
|
|
197
|
+
) -> list:
|
|
198
|
+
"""Straight sweep, used to check that acceptance really is monotone."""
|
|
199
|
+
return [evaluate_strength(params, adversary_factory, s, trials, seed)
|
|
200
|
+
for s in strengths]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def is_monotone(points, tolerance: float = 0.25) -> bool:
|
|
204
|
+
"""Is acceptance non-increasing in strength, within sampling noise?
|
|
205
|
+
|
|
206
|
+
Bisection assumes it. With ``trials`` runs per point the estimate has a
|
|
207
|
+
standard error around 0.5/sqrt(trials), so a small rise is noise rather
|
|
208
|
+
than a violation; anything larger means the frontier is not well defined
|
|
209
|
+
and the number should not be quoted.
|
|
210
|
+
"""
|
|
211
|
+
acc = [p.acceptance for p in sorted(points, key=lambda p: p.strength)]
|
|
212
|
+
return all(b <= a + tolerance for a, b in zip(acc, acc[1:]))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def threshold_only_frontier(params, induced_rate) -> float:
|
|
216
|
+
"""Where the frontier would sit with *only* the protocol's threshold rule.
|
|
217
|
+
|
|
218
|
+
``induced_rate(strength)`` gives the mismatch rate an attack produces.
|
|
219
|
+
Solving ``induced_rate(x) = s_a`` gives the strength a bare threshold
|
|
220
|
+
detector would tolerate -- the number the extra monitors have to beat to
|
|
221
|
+
be worth their cost.
|
|
222
|
+
"""
|
|
223
|
+
s_a, _ = params.thresholds
|
|
224
|
+
lo, hi = 0.0, 1.0
|
|
225
|
+
if induced_rate(hi) <= s_a:
|
|
226
|
+
return hi
|
|
227
|
+
for _ in range(60):
|
|
228
|
+
mid = (lo + hi) / 2
|
|
229
|
+
if induced_rate(mid) <= s_a:
|
|
230
|
+
lo = mid
|
|
231
|
+
else:
|
|
232
|
+
hi = mid
|
|
233
|
+
return lo
|
qids/analysis/metrics.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Scoring the detector: detection power, false alarms, attribution accuracy.
|
|
2
|
+
|
|
3
|
+
Three numbers matter, and they are not interchangeable:
|
|
4
|
+
|
|
5
|
+
``undetected acceptance`` an attack that was ACCEPTed. This is the only
|
|
6
|
+
outcome that actually breaks the scheme, and the
|
|
7
|
+
target for it is zero.
|
|
8
|
+
``detection rate`` an attack that was not ACCEPTed (REJECT or
|
|
9
|
+
QUARANTINE). Quarantine counts, because holding a
|
|
10
|
+
signature for arbitration is a safe outcome.
|
|
11
|
+
``false alarm rate`` a benign session that was not ACCEPTed. This is the
|
|
12
|
+
cost of the detector, and it is what stops anyone
|
|
13
|
+
from trivially scoring 100% by rejecting everything.
|
|
14
|
+
|
|
15
|
+
Attribution accuracy is reported separately. Detecting an attack and naming
|
|
16
|
+
it correctly are different capabilities, and conflating them hides the cases
|
|
17
|
+
where the framework is right for the wrong reason.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import time
|
|
24
|
+
from collections import Counter, defaultdict
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from ..detect.engine import Decision
|
|
30
|
+
from ..scenarios import SCENARIOS, run_scenario
|
|
31
|
+
|
|
32
|
+
#: Threats that are the same physical phenomenon seen at different strengths.
|
|
33
|
+
#: Scored as a family as well as exactly, because "weak attack" and "degraded
|
|
34
|
+
#: link" are genuinely indistinguishable from one session's observables.
|
|
35
|
+
THREAT_FAMILY = {
|
|
36
|
+
"benign": "benign",
|
|
37
|
+
"forgery": "fabrication",
|
|
38
|
+
"impersonation": "fabrication",
|
|
39
|
+
"replay": "replay",
|
|
40
|
+
"unauthorized_verification": "access",
|
|
41
|
+
"channel_manipulation": "channel",
|
|
42
|
+
"link_degradation": "channel",
|
|
43
|
+
"repudiation": "repudiation",
|
|
44
|
+
"evasion": "fabrication",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ScenarioStats:
|
|
50
|
+
"""Aggregate outcome of repeating one scenario."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
truth: str
|
|
54
|
+
n_runs: int = 0
|
|
55
|
+
n_detected: int = 0
|
|
56
|
+
n_accepted: int = 0
|
|
57
|
+
n_exact: int = 0
|
|
58
|
+
n_family: int = 0
|
|
59
|
+
mismatch_rates: list = field(default_factory=list)
|
|
60
|
+
latencies_ms: list = field(default_factory=list)
|
|
61
|
+
sprt_stops: list = field(default_factory=list)
|
|
62
|
+
decisions: Counter = field(default_factory=Counter)
|
|
63
|
+
attributed: Counter = field(default_factory=Counter)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_benign(self) -> bool:
|
|
67
|
+
return self.truth == "benign"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def detection_rate(self) -> float:
|
|
71
|
+
return self.n_detected / self.n_runs if self.n_runs else 0.0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def false_alarm_rate(self) -> float:
|
|
75
|
+
return self.detection_rate if self.is_benign else 0.0
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def undetected_acceptance_rate(self) -> float:
|
|
79
|
+
return 0.0 if self.is_benign else self.n_accepted / max(1, self.n_runs)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def exact_attribution(self) -> float:
|
|
83
|
+
return self.n_exact / self.n_runs if self.n_runs else 0.0
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def family_attribution(self) -> float:
|
|
87
|
+
return self.n_family / self.n_runs if self.n_runs else 0.0
|
|
88
|
+
|
|
89
|
+
def row(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"scenario": self.name,
|
|
92
|
+
"truth": self.truth,
|
|
93
|
+
"runs": self.n_runs,
|
|
94
|
+
"detection_rate": self.detection_rate,
|
|
95
|
+
"undetected_acceptance": self.undetected_acceptance_rate,
|
|
96
|
+
"exact_attribution": self.exact_attribution,
|
|
97
|
+
"family_attribution": self.family_attribution,
|
|
98
|
+
"mean_mismatch": float(np.mean(self.mismatch_rates)) if self.mismatch_rates else 0.0,
|
|
99
|
+
"mean_latency_ms": float(np.mean(self.latencies_ms)) if self.latencies_ms else 0.0,
|
|
100
|
+
"median_sprt_stop": (
|
|
101
|
+
float(np.median([s for s in self.sprt_stops if s])) if any(self.sprt_stops) else None
|
|
102
|
+
),
|
|
103
|
+
"decisions": dict(self.decisions),
|
|
104
|
+
"attributed": dict(self.attributed),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class EvaluationReport:
|
|
110
|
+
per_scenario: dict = field(default_factory=dict)
|
|
111
|
+
confusion: dict = field(default_factory=lambda: defaultdict(Counter))
|
|
112
|
+
params_summary: dict = field(default_factory=dict)
|
|
113
|
+
wall_clock_s: float = 0.0
|
|
114
|
+
|
|
115
|
+
# -- aggregate figures -------------------------------------------------
|
|
116
|
+
def _bucket(self, benign: bool):
|
|
117
|
+
return [s for s in self.per_scenario.values() if s.is_benign == benign]
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def attack_scenarios(self) -> list:
|
|
121
|
+
return self._bucket(False)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def benign_scenarios(self) -> list:
|
|
125
|
+
return self._bucket(True)
|
|
126
|
+
|
|
127
|
+
def _weighted(self, items, attr) -> float:
|
|
128
|
+
runs = sum(s.n_runs for s in items)
|
|
129
|
+
if not runs:
|
|
130
|
+
return 0.0
|
|
131
|
+
return sum(getattr(s, attr) * s.n_runs for s in items) / runs
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def detection_rate(self) -> float:
|
|
135
|
+
return self._weighted(self.attack_scenarios, "detection_rate")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def false_alarm_rate(self) -> float:
|
|
139
|
+
return self._weighted(self.benign_scenarios, "detection_rate")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def undetected_acceptance_rate(self) -> float:
|
|
143
|
+
return self._weighted(self.attack_scenarios, "undetected_acceptance_rate")
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def exact_attribution(self) -> float:
|
|
147
|
+
return self._weighted(self.attack_scenarios, "exact_attribution")
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def family_attribution(self) -> float:
|
|
151
|
+
return self._weighted(self.attack_scenarios, "family_attribution")
|
|
152
|
+
|
|
153
|
+
def summary(self) -> dict:
|
|
154
|
+
return {
|
|
155
|
+
"scenarios": len(self.per_scenario),
|
|
156
|
+
"total_runs": sum(s.n_runs for s in self.per_scenario.values()),
|
|
157
|
+
"detection_rate": self.detection_rate,
|
|
158
|
+
"false_alarm_rate": self.false_alarm_rate,
|
|
159
|
+
"undetected_acceptance_rate": self.undetected_acceptance_rate,
|
|
160
|
+
"exact_attribution": self.exact_attribution,
|
|
161
|
+
"family_attribution": self.family_attribution,
|
|
162
|
+
"wall_clock_s": self.wall_clock_s,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def confusion_matrix(self) -> dict:
|
|
166
|
+
return {t: dict(c) for t, c in self.confusion.items()}
|
|
167
|
+
|
|
168
|
+
def table(self) -> str:
|
|
169
|
+
hdr = (
|
|
170
|
+
f"{'scenario':36s} {'truth':26s} {'runs':>5s} {'detect':>7s} "
|
|
171
|
+
f"{'undet.acc':>10s} {'attrib':>7s} {'family':>7s} {'ms':>7s}"
|
|
172
|
+
)
|
|
173
|
+
lines = [hdr, "-" * len(hdr)]
|
|
174
|
+
for s in self.per_scenario.values():
|
|
175
|
+
r = s.row()
|
|
176
|
+
lines.append(
|
|
177
|
+
f"{r['scenario']:36s} {r['truth']:26s} {r['runs']:5d} "
|
|
178
|
+
f"{r['detection_rate']:7.3f} {r['undetected_acceptance']:10.3f} "
|
|
179
|
+
f"{r['exact_attribution']:7.3f} {r['family_attribution']:7.3f} "
|
|
180
|
+
f"{r['mean_latency_ms']:7.2f}"
|
|
181
|
+
)
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _outcome(result):
|
|
186
|
+
"""Extract (decision, attributed_threat) from a scenario result.
|
|
187
|
+
|
|
188
|
+
Repudiation is arbitrated across verifiers, so for those scenarios the
|
|
189
|
+
dispute outcome -- not a single verifier's local verdict -- is the
|
|
190
|
+
framework's answer.
|
|
191
|
+
"""
|
|
192
|
+
if result.dispute is not None and result.truth == "repudiation":
|
|
193
|
+
return result.dispute.decision, result.dispute.threat.value
|
|
194
|
+
v = result.target_verdict
|
|
195
|
+
if v is None:
|
|
196
|
+
return Decision.REJECT, "benign"
|
|
197
|
+
return v.decision, v.threat.value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _name_offset(name: str) -> int:
|
|
201
|
+
"""Stable per-scenario seed offset.
|
|
202
|
+
|
|
203
|
+
This used ``abs(hash(name))``, which is not reproducible: CPython
|
|
204
|
+
randomises string hashing per process unless ``PYTHONHASHSEED`` is set, so
|
|
205
|
+
two runs of the same evaluation at the same ``seed`` drew different streams
|
|
206
|
+
and reported different numbers. A cryptographic digest is stable across
|
|
207
|
+
processes, platforms and Python versions, which is what "seed 11" has to
|
|
208
|
+
mean for a reported figure to be checkable.
|
|
209
|
+
"""
|
|
210
|
+
digest = hashlib.blake2b(name.encode("utf-8"), digest_size=8).digest()
|
|
211
|
+
return int.from_bytes(digest, "big") % 99_991
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def evaluate(
|
|
215
|
+
params,
|
|
216
|
+
seed: int = 0,
|
|
217
|
+
repeats: int = 5,
|
|
218
|
+
only: tuple = (),
|
|
219
|
+
progress: bool = False,
|
|
220
|
+
) -> EvaluationReport:
|
|
221
|
+
"""Run every scenario ``repeats`` times and score the detector."""
|
|
222
|
+
names = only or tuple(SCENARIOS)
|
|
223
|
+
report = EvaluationReport(params_summary=params.security_summary())
|
|
224
|
+
t0 = time.perf_counter()
|
|
225
|
+
|
|
226
|
+
for name in names:
|
|
227
|
+
sc = SCENARIOS[name]
|
|
228
|
+
stats = ScenarioStats(name=name, truth=sc.truth)
|
|
229
|
+
for r_i in range(repeats):
|
|
230
|
+
rng = np.random.default_rng(seed * 100_003 + _name_offset(name) + r_i)
|
|
231
|
+
result = run_scenario(name, params, rng)
|
|
232
|
+
decision, threat = _outcome(result)
|
|
233
|
+
|
|
234
|
+
stats.n_runs += 1
|
|
235
|
+
stats.decisions[decision] += 1
|
|
236
|
+
stats.attributed[threat] += 1
|
|
237
|
+
if decision != Decision.ACCEPT:
|
|
238
|
+
stats.n_detected += 1
|
|
239
|
+
else:
|
|
240
|
+
stats.n_accepted += 1
|
|
241
|
+
if threat == sc.truth or (sc.truth == "benign" and decision == Decision.ACCEPT):
|
|
242
|
+
stats.n_exact += 1
|
|
243
|
+
if THREAT_FAMILY.get(threat) == THREAT_FAMILY.get(sc.truth) or (
|
|
244
|
+
sc.truth == "benign" and decision == Decision.ACCEPT
|
|
245
|
+
):
|
|
246
|
+
stats.n_family += 1
|
|
247
|
+
|
|
248
|
+
v = result.target_verdict
|
|
249
|
+
if v is not None:
|
|
250
|
+
stats.mismatch_rates.append(v.mismatch_rate)
|
|
251
|
+
stats.latencies_ms.append(v.elapsed_s * 1000.0)
|
|
252
|
+
sprt = next((s for s in v.signals if s.name == "sprt"), None)
|
|
253
|
+
stats.sprt_stops.append(
|
|
254
|
+
(sprt.detail or {}).get("stopped_at") if sprt else None
|
|
255
|
+
)
|
|
256
|
+
report.confusion[sc.truth][threat] += 1
|
|
257
|
+
report.per_scenario[name] = stats
|
|
258
|
+
if progress:
|
|
259
|
+
print(f" {name:36s} detect={stats.detection_rate:.2f} "
|
|
260
|
+
f"attrib={stats.exact_attribution:.2f}", flush=True)
|
|
261
|
+
|
|
262
|
+
report.wall_clock_s = time.perf_counter() - t0
|
|
263
|
+
return report
|