@svrnsec/pulse 0.7.0 → 0.8.0
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.
- package/LICENSE +21 -21
- package/README.md +883 -782
- package/SECURITY.md +86 -86
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6378 -6419
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6379 -6420
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +895 -846
- package/package.json +185 -184
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -393
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -804
- package/src/analysis/heuristic.js +428 -428
- package/src/analysis/jitter.js +446 -446
- package/src/analysis/llm.js +473 -472
- package/src/analysis/populationEntropy.js +404 -403
- package/src/analysis/provider.js +248 -248
- package/src/analysis/refraction.js +392 -391
- package/src/analysis/trustScore.js +356 -356
- package/src/cli/args.js +36 -36
- package/src/cli/commands/scan.js +192 -192
- package/src/cli/runner.js +157 -157
- package/src/collector/adaptive.js +200 -200
- package/src/collector/bio.js +297 -287
- package/src/collector/canvas.js +247 -239
- package/src/collector/dram.js +203 -203
- package/src/collector/enf.js +311 -311
- package/src/collector/entropy.js +195 -195
- package/src/collector/gpu.js +248 -245
- package/src/collector/idleAttestation.js +480 -480
- package/src/collector/sabTimer.js +189 -191
- package/src/fingerprint.js +475 -475
- package/src/index.js +342 -342
- package/src/integrations/react-native.js +462 -459
- package/src/integrations/react.js +184 -185
- package/src/middleware/express.js +155 -155
- package/src/middleware/next.js +174 -175
- package/src/proof/challenge.js +249 -249
- package/src/proof/engagementToken.js +426 -394
- package/src/proof/fingerprint.js +268 -268
- package/src/proof/validator.js +82 -142
- package/src/registry/serializer.js +349 -349
- package/src/terminal.js +263 -263
- package/src/update-notifier.js +259 -264
- package/dist/pulse.cjs.js.map +0 -1
|
@@ -1,428 +1,428 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @svrnsec/pulse — Cross-Metric Heuristic Engine
|
|
3
|
-
*
|
|
4
|
-
* Instead of checking individual thresholds in isolation, this module looks
|
|
5
|
-
* at the *relationships* between metrics. A sophisticated adversary can spoof
|
|
6
|
-
* any single number. Spoofing six metrics so they remain mutually consistent
|
|
7
|
-
* with physical laws is exponentially harder.
|
|
8
|
-
*
|
|
9
|
-
* Three core insights drive this engine:
|
|
10
|
-
*
|
|
11
|
-
* 1. Entropy-Jitter Coherence
|
|
12
|
-
* Real silicon gets noisier as it heats up. Under sustained load, the
|
|
13
|
-
* Quantization Entropy of the timing distribution grows because thermal
|
|
14
|
-
* fluctuations add variance. A VM's hypervisor clock doesn't care about
|
|
15
|
-
* guest temperature — its entropy is flat across all load phases.
|
|
16
|
-
*
|
|
17
|
-
* 2. Hurst-Autocorrelation Coherence
|
|
18
|
-
* Genuine Brownian noise has Hurst ≈ 0.5 and near-zero autocorrelation
|
|
19
|
-
* at all lags. These two values are physically linked. If they diverge —
|
|
20
|
-
* high autocorrelation but Hurst near 0.5, or vice versa — the timings
|
|
21
|
-
* were generated, not measured.
|
|
22
|
-
*
|
|
23
|
-
* 3. CV-Entropy Coherence
|
|
24
|
-
* High variance (CV) must come from somewhere. On real hardware, high CV
|
|
25
|
-
* means the timing distribution is spread out, which also means high
|
|
26
|
-
* entropy. A VM that inflates CV without inflating entropy (e.g. by
|
|
27
|
-
* adding synthetic outliers at fixed offsets) produces a coherence gap.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { detectQuantizationEntropy } from './jitter.js';
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// runHeuristicEngine
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {object} p
|
|
38
|
-
* @param {import('./jitter.js').JitterAnalysis} p.jitter
|
|
39
|
-
* @param {object|null} p.phases - from entropy collector
|
|
40
|
-
* @param {object} p.autocorrelations
|
|
41
|
-
* @returns {HeuristicReport}
|
|
42
|
-
*/
|
|
43
|
-
export function runHeuristicEngine({ jitter, phases, autocorrelations }) {
|
|
44
|
-
const findings = [];
|
|
45
|
-
const bonuses = [];
|
|
46
|
-
let penalty = 0; // accumulated penalty [0, 1]
|
|
47
|
-
let bonus = 0; // accumulated bonus [0, 1]
|
|
48
|
-
let hardOverride = null; // 'vm' | null — bypasses score entirely when set
|
|
49
|
-
|
|
50
|
-
const stats = jitter.stats;
|
|
51
|
-
if (!stats) return _empty();
|
|
52
|
-
|
|
53
|
-
// ── 1. Entropy-Jitter Ratio (phases required) ────────────────────────────
|
|
54
|
-
let entropyJitterRatio = null;
|
|
55
|
-
let entropyJitterScore = 0.5; // neutral if no phased data
|
|
56
|
-
|
|
57
|
-
if (phases) {
|
|
58
|
-
entropyJitterRatio = phases.entropyJitterRatio;
|
|
59
|
-
|
|
60
|
-
const coldQE = phases.cold?.qe ?? null;
|
|
61
|
-
const hotQE = phases.hot?.qe ?? null;
|
|
62
|
-
|
|
63
|
-
// ── HARD KILL: Phase trajectory mathematical contradiction ──────────────
|
|
64
|
-
//
|
|
65
|
-
// EJR is defined as: entropyJitterRatio = hot_QE / cold_QE
|
|
66
|
-
//
|
|
67
|
-
// Before trusting any EJR value, verify it is internally consistent with
|
|
68
|
-
// the QE measurements it purports to summarise. Two forgery vectors exist:
|
|
69
|
-
//
|
|
70
|
-
// Attack A — EJR field overwritten independently:
|
|
71
|
-
// Attacker sets entropyJitterRatio = 1.15 to claim thermal growth,
|
|
72
|
-
// but leaves cold_QE = 3.50, hot_QE = 3.00 unchanged.
|
|
73
|
-
// Computed EJR = 3.00 / 3.50 = 0.857.
|
|
74
|
-
// Discrepancy 1.15 − 0.857 = 0.293 >> 0.005 tolerance → HARD KILL.
|
|
75
|
-
//
|
|
76
|
-
// Attack B — QE values also faked but left inconsistent:
|
|
77
|
-
// Attacker overwrites both QE fields carelessly: cold_QE = 3.5,
|
|
78
|
-
// hot_QE = 3.0, but EJR = 1.15 is still written.
|
|
79
|
-
// cold_QE ≥ hot_QE with EJR ≥ 1.08 is a mathematical impossibility —
|
|
80
|
-
// if hot ≤ cold then hot/cold ≤ 1.0, which can never be ≥ 1.08.
|
|
81
|
-
//
|
|
82
|
-
// Tolerance of 0.005 accounts for floating-point rounding in the entropy
|
|
83
|
-
// collector (detectQuantizationEntropy uses discrete histogram bins).
|
|
84
|
-
//
|
|
85
|
-
// When HARD KILL fires:
|
|
86
|
-
// • hardOverride = 'vm' — fingerprint.js short-circuits isSynthetic
|
|
87
|
-
// • entropyJitterScore = 0.0 — no EJR contribution to stage-2 bonus
|
|
88
|
-
// • penalty += 1.0 — overwhelms the physical floor cap
|
|
89
|
-
// • No further EJR evaluation runs (the data cannot be trusted)
|
|
90
|
-
// • The physical floor protection is explicitly bypassed (see aggregate)
|
|
91
|
-
|
|
92
|
-
if (coldQE !== null && hotQE !== null) {
|
|
93
|
-
const computedEJR = coldQE > 0 ? hotQE / coldQE : null;
|
|
94
|
-
const fieldTampered = computedEJR !== null &&
|
|
95
|
-
Math.abs(entropyJitterRatio - computedEJR) > 0.005;
|
|
96
|
-
const qeContradicts = entropyJitterRatio >= 1.08 && coldQE >= hotQE;
|
|
97
|
-
|
|
98
|
-
if (fieldTampered || qeContradicts) {
|
|
99
|
-
hardOverride = 'vm';
|
|
100
|
-
entropyJitterScore = 0.0;
|
|
101
|
-
findings.push({
|
|
102
|
-
id: 'EJR_PHASE_HARD_KILL',
|
|
103
|
-
label: fieldTampered
|
|
104
|
-
? 'HARD KILL: stored EJR is inconsistent with cold/hot QE values — phase data tampered'
|
|
105
|
-
: 'HARD KILL: EJR ≥ 1.08 claims entropy growth but cold_QE ≥ hot_QE — physically impossible',
|
|
106
|
-
detail: `ejr_stored=${entropyJitterRatio.toFixed(4)} ` +
|
|
107
|
-
`ejr_computed=${computedEJR?.toFixed(4) ?? 'n/a'} ` +
|
|
108
|
-
`cold_QE=${coldQE.toFixed(4)} hot_QE=${hotQE.toFixed(4)} ` +
|
|
109
|
-
`delta=${computedEJR != null ? Math.abs(entropyJitterRatio - computedEJR).toFixed(4) : 'n/a'}`,
|
|
110
|
-
severity: 'critical',
|
|
111
|
-
penalty: 1.0,
|
|
112
|
-
});
|
|
113
|
-
penalty += 1.0;
|
|
114
|
-
|
|
115
|
-
} else {
|
|
116
|
-
// QE values confirmed consistent — proceed with normal EJR evaluation.
|
|
117
|
-
_evaluateEJR(entropyJitterRatio, coldQE, hotQE, findings, bonuses,
|
|
118
|
-
(p) => { penalty += p; }, (b) => { bonus += b; },
|
|
119
|
-
(s) => { entropyJitterScore = s; });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
} else {
|
|
123
|
-
// QE values unavailable — evaluate EJR ratio in isolation.
|
|
124
|
-
_evaluateEJR(entropyJitterRatio, null, null, findings, bonuses,
|
|
125
|
-
(p) => { penalty += p; }, (b) => { bonus += b; },
|
|
126
|
-
(s) => { entropyJitterScore = s; });
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Phase mean drift: real CPU heats up → iterations get slower.
|
|
130
|
-
// Only apply if hard kill wasn't already triggered.
|
|
131
|
-
if (!hardOverride) {
|
|
132
|
-
const coldToHotDrift = (phases.hot?.mean ?? 0) - (phases.cold?.mean ?? 0);
|
|
133
|
-
if (coldToHotDrift > 0.05) {
|
|
134
|
-
bonuses.push({
|
|
135
|
-
id: 'THERMAL_DRIFT_CONFIRMED',
|
|
136
|
-
label: 'CPU mean timing increased from cold to hot phase (thermal drift)',
|
|
137
|
-
detail: `cold=${phases.cold.mean.toFixed(3)}ms hot=${phases.hot.mean.toFixed(3)}ms Δ=${coldToHotDrift.toFixed(3)}ms`,
|
|
138
|
-
value: 0.08,
|
|
139
|
-
});
|
|
140
|
-
bonus += 0.08;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── 2. Hurst-Autocorrelation Coherence ───────────────────────────────────
|
|
146
|
-
const h = jitter.hurstExponent ?? 0.5;
|
|
147
|
-
const ac1 = Math.abs(autocorrelations?.lag1 ?? 0);
|
|
148
|
-
const ac5 = Math.abs(autocorrelations?.lag5 ?? 0);
|
|
149
|
-
const ac50 = Math.abs(autocorrelations?.lag50 ?? 0);
|
|
150
|
-
|
|
151
|
-
// Physical law: Brownian noise (H≈0.5) must have low autocorrelation.
|
|
152
|
-
// Divergence between these two means the data wasn't generated by physics.
|
|
153
|
-
const hurstExpectedAC = Math.abs(2 * h - 1); // theoretical max |autocorr| for given H
|
|
154
|
-
const actualAC = (ac1 + ac5) / 2;
|
|
155
|
-
const acHurstDivergence = Math.abs(actualAC - hurstExpectedAC);
|
|
156
|
-
|
|
157
|
-
if (acHurstDivergence > 0.35) {
|
|
158
|
-
findings.push({
|
|
159
|
-
id: 'HURST_AUTOCORR_INCOHERENT',
|
|
160
|
-
label: 'Hurst exponent and autocorrelation are physically inconsistent',
|
|
161
|
-
detail: `H=${h.toFixed(3)} expected_AC≈${hurstExpectedAC.toFixed(3)} actual_AC=${actualAC.toFixed(3)} divergence=${acHurstDivergence.toFixed(3)}`,
|
|
162
|
-
severity: 'high',
|
|
163
|
-
penalty: 0.12,
|
|
164
|
-
});
|
|
165
|
-
penalty += 0.12;
|
|
166
|
-
} else if (h > 0.45 && h < 0.55 && ac1 < 0.15) {
|
|
167
|
-
// Ideal Brownian + low autocorr — physically coherent
|
|
168
|
-
bonuses.push({
|
|
169
|
-
id: 'BROWNIAN_COHERENCE_CONFIRMED',
|
|
170
|
-
label: 'Hurst ≈ 0.5 and autocorrelation near zero — genuine Brownian noise',
|
|
171
|
-
detail: `H=${h.toFixed(3)} lag1_AC=${ac1.toFixed(3)}`,
|
|
172
|
-
value: 0.10,
|
|
173
|
-
});
|
|
174
|
-
bonus += 0.10;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ── 3. CV-Entropy Coherence ───────────────────────────────────────────────
|
|
178
|
-
// High CV should correlate with high QE. If CV is high but QE is low,
|
|
179
|
-
// the variance was added artificially (fixed-offset outliers, synthetic spikes).
|
|
180
|
-
const cv = stats.cv;
|
|
181
|
-
const qe = jitter.quantizationEntropy;
|
|
182
|
-
|
|
183
|
-
// Expected QE given CV, assuming roughly normal distribution
|
|
184
|
-
// Normal dist with σ/μ = CV: entropy ≈ log2(σ * sqrt(2πe)) + log2(n/binWidth)
|
|
185
|
-
// We use a simplified linear proxy calibrated against real benchmarks.
|
|
186
|
-
const expectedQE = Math.max(0, 1.5 + cv * 16); // empirical: CV=0.15 → QE≈3.9
|
|
187
|
-
const qeDivergence = expectedQE - qe; // positive = QE lower than expected
|
|
188
|
-
|
|
189
|
-
if (qeDivergence > 1.8 && cv > 0.05) {
|
|
190
|
-
// High variance but low entropy: synthetic outliers at fixed offsets
|
|
191
|
-
findings.push({
|
|
192
|
-
id: 'CV_ENTROPY_INCOHERENT',
|
|
193
|
-
label: 'High CV but low entropy — variance appears synthetic (fixed-offset outliers)',
|
|
194
|
-
detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} bits expected_QE≈${expectedQE.toFixed(3)} gap=${qeDivergence.toFixed(3)}`,
|
|
195
|
-
severity: 'high',
|
|
196
|
-
penalty: 0.10,
|
|
197
|
-
});
|
|
198
|
-
penalty += 0.10;
|
|
199
|
-
} else if (qeDivergence < 0.5 && cv > 0.08) {
|
|
200
|
-
// CV and QE are coherent — timings come from a real distribution
|
|
201
|
-
bonuses.push({
|
|
202
|
-
id: 'CV_ENTROPY_COHERENT',
|
|
203
|
-
label: 'Variance and entropy are physically coherent',
|
|
204
|
-
detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} expected≈${expectedQE.toFixed(3)}`,
|
|
205
|
-
value: 0.06,
|
|
206
|
-
});
|
|
207
|
-
bonus += 0.06;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ── 4. Steal-time periodicity (the "Picket Fence" detector) ─────────────
|
|
211
|
-
// VM steal-time bursts create a periodic signal in the autocorrelation.
|
|
212
|
-
// If lag-50 autocorrelation is significantly higher than lag-5,
|
|
213
|
-
// the scheduler quantum is approximately 50× the mean iteration time.
|
|
214
|
-
const picketFence = _detectPicketFence(autocorrelations);
|
|
215
|
-
if (picketFence.detected) {
|
|
216
|
-
findings.push({
|
|
217
|
-
id: 'PICKET_FENCE_DETECTED',
|
|
218
|
-
label: `"Picket Fence" steal-time rhythm detected at lag ${picketFence.dominantLag}`,
|
|
219
|
-
detail: picketFence.detail,
|
|
220
|
-
severity: 'high',
|
|
221
|
-
penalty: 0.08,
|
|
222
|
-
});
|
|
223
|
-
penalty += 0.08;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ── 5. Skewness-Kurtosis coherence ───────────────────────────────────────
|
|
227
|
-
// Real hardware timing is right-skewed (occasional slow outliers from OS preemption).
|
|
228
|
-
// VMs that add synthetic outliers at fixed offsets produce wrong skew/kurtosis.
|
|
229
|
-
const skew = stats.skewness ?? 0;
|
|
230
|
-
const kurt = stats.kurtosis ?? 0;
|
|
231
|
-
|
|
232
|
-
if (skew > 0.3 && kurt > 0) {
|
|
233
|
-
// Right-skewed, leptokurtic — consistent with OS preemption on real hardware
|
|
234
|
-
bonuses.push({
|
|
235
|
-
id: 'NATURAL_SKEW_CONFIRMED',
|
|
236
|
-
label: 'Right-skewed distribution with positive kurtosis — OS preemption pattern',
|
|
237
|
-
detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
|
|
238
|
-
value: 0.06,
|
|
239
|
-
});
|
|
240
|
-
bonus += 0.06;
|
|
241
|
-
} else if (skew < 0 && Math.abs(kurt) > 1) {
|
|
242
|
-
// Negative skew with high kurtosis: inconsistent with physical timing noise
|
|
243
|
-
findings.push({
|
|
244
|
-
id: 'SKEW_KURTOSIS_ANOMALY',
|
|
245
|
-
label: 'Left-skewed distribution — inconsistent with natural hardware timing',
|
|
246
|
-
detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
|
|
247
|
-
severity: 'medium',
|
|
248
|
-
penalty: 0.06,
|
|
249
|
-
});
|
|
250
|
-
penalty += 0.06;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ── Physical floor protection (anti-compounding) ──────────────────────────
|
|
254
|
-
// When the three PRIMARY timing metrics are clearly consistent with real
|
|
255
|
-
// silicon, cap the penalty so that marginal secondary signals (weak Picket
|
|
256
|
-
// Fence, mild EJR, slight skew anomaly) cannot compound into a rejection.
|
|
257
|
-
//
|
|
258
|
-
// Why: a modern i7 laptop running heavy browser extensions may show:
|
|
259
|
-
// EJR = 1.01 → -0.10 penalty (just under the 1.02 threshold)
|
|
260
|
-
// lag50 = 0.31 → picket fence → -0.08 penalty (background process rhythm)
|
|
261
|
-
// slight negative skew → -0.06 penalty
|
|
262
|
-
// total: -0.24, drops score from 0.73 → 0.49 → wrongly flagged as synthetic
|
|
263
|
-
//
|
|
264
|
-
// Solution: if ≥ 2 of the 3 primary metrics are unambiguously physical,
|
|
265
|
-
// treat the device as "probably physical with some noise" and limit the
|
|
266
|
-
// penalty to 0.22 (enough to lower confidence but not enough to reject).
|
|
267
|
-
const clearQE = jitter.quantizationEntropy > 3.2;
|
|
268
|
-
const clearCV = stats.cv >= 0.05 && stats.cv <= 0.30;
|
|
269
|
-
const clearLag1 = Math.abs(autocorrelations?.lag1 ?? 1) < 0.22;
|
|
270
|
-
const clearPhysicalCount = [clearQE, clearCV, clearLag1].filter(Boolean).length;
|
|
271
|
-
|
|
272
|
-
// Also check: if at least one metric is a HARD VM indicator (QE < 2.0 or
|
|
273
|
-
// lag1 > 0.65), override the floor — the floor is for borderline noise, not
|
|
274
|
-
// for devices that are clearly VMs on at least one axis.
|
|
275
|
-
const hardVmSignal =
|
|
276
|
-
jitter.quantizationEntropy < 2.0 ||
|
|
277
|
-
Math.abs(autocorrelations?.lag1 ?? 0) > 0.65;
|
|
278
|
-
|
|
279
|
-
const penaltyCap = (!hardVmSignal && clearPhysicalCount >= 2)
|
|
280
|
-
? 0.22 // physical floor: cap compounding for clearly physical devices
|
|
281
|
-
: 0.60; // default: full penalty range for ambiguous or VM-like signals
|
|
282
|
-
|
|
283
|
-
// HARD KILL overrides the physical floor protection entirely.
|
|
284
|
-
// The floor was designed to protect legitimate hardware with multiple
|
|
285
|
-
// marginal-but-honest signals — it must never shelter a forged proof.
|
|
286
|
-
const totalPenalty = hardOverride === 'vm'
|
|
287
|
-
? Math.min(1.0, penalty) // hard kill: uncapped, overwhelms all bonuses
|
|
288
|
-
: Math.min(penaltyCap, penalty); // normal: apply floor protection
|
|
289
|
-
|
|
290
|
-
// When a hard kill is active, strip all bonuses — they were earned on
|
|
291
|
-
// data that has been proved untrustworthy.
|
|
292
|
-
const totalBonus = hardOverride === 'vm' ? 0 : Math.min(0.35, bonus);
|
|
293
|
-
|
|
294
|
-
return {
|
|
295
|
-
penalty: totalPenalty,
|
|
296
|
-
bonus: totalBonus,
|
|
297
|
-
netAdjustment: totalBonus - totalPenalty,
|
|
298
|
-
findings,
|
|
299
|
-
bonuses: hardOverride === 'vm' ? [] : bonuses,
|
|
300
|
-
entropyJitterRatio,
|
|
301
|
-
entropyJitterScore,
|
|
302
|
-
picketFence,
|
|
303
|
-
hardOverride,
|
|
304
|
-
coherenceFlags: findings.map(f => f.id),
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* @typedef {object} HeuristicReport
|
|
310
|
-
* @property {number} penalty - total score penalty [0, 1.0]
|
|
311
|
-
* @property {number} bonus - total score bonus [0, 0.35]
|
|
312
|
-
* @property {number} netAdjustment - bonus - penalty
|
|
313
|
-
* @property {object[]} findings - detected anomalies
|
|
314
|
-
* @property {object[]} bonuses - confirmed physical properties
|
|
315
|
-
* @property {number|null} entropyJitterRatio
|
|
316
|
-
* @property {'vm'|null} hardOverride - set when a mathematical impossibility is detected
|
|
317
|
-
* @property {object} picketFence
|
|
318
|
-
* @property {string[]} coherenceFlags
|
|
319
|
-
*/
|
|
320
|
-
|
|
321
|
-
// ---------------------------------------------------------------------------
|
|
322
|
-
// EJR evaluation helper (extracted so it can run with or without QE values)
|
|
323
|
-
// ---------------------------------------------------------------------------
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Applies the normal EJR classification logic (called only after the hard-kill
|
|
327
|
-
* check passes, meaning the EJR value has been verified as consistent).
|
|
328
|
-
*/
|
|
329
|
-
function _evaluateEJR(ejr, coldQE, hotQE, findings, bonuses, addPenalty, addBonus, setScore) {
|
|
330
|
-
const qeDetail = coldQE != null
|
|
331
|
-
? `cold_QE=${coldQE.toFixed(3)} hot_QE=${hotQE.toFixed(3)}`
|
|
332
|
-
: '';
|
|
333
|
-
|
|
334
|
-
if (ejr >= 1.08) {
|
|
335
|
-
setScore(1.0);
|
|
336
|
-
bonuses.push({
|
|
337
|
-
id: 'ENTROPY_GROWS_WITH_LOAD',
|
|
338
|
-
label: 'Entropy grew under load — thermal feedback confirmed',
|
|
339
|
-
detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
|
|
340
|
-
value: 0.12,
|
|
341
|
-
});
|
|
342
|
-
addBonus(0.12);
|
|
343
|
-
|
|
344
|
-
} else if (ejr >= 1.02) {
|
|
345
|
-
setScore(0.7);
|
|
346
|
-
findings.push({
|
|
347
|
-
id: 'ENTROPY_MILD_GROWTH',
|
|
348
|
-
label: 'Weak entropy growth under load',
|
|
349
|
-
detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
|
|
350
|
-
severity: 'info',
|
|
351
|
-
penalty: 0,
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
} else if (ejr > 0.95) {
|
|
355
|
-
// Flat entropy — hypervisor clock unresponsive to guest load
|
|
356
|
-
setScore(0.2);
|
|
357
|
-
findings.push({
|
|
358
|
-
id: 'ENTROPY_FLAT_UNDER_LOAD',
|
|
359
|
-
label: 'Entropy did not grow under load — hypervisor clock suspected',
|
|
360
|
-
detail: `ratio=${ejr.toFixed(3)} (expected ≥ 1.08 for real hardware) ${qeDetail}`,
|
|
361
|
-
severity: 'high',
|
|
362
|
-
penalty: 0.10,
|
|
363
|
-
});
|
|
364
|
-
addPenalty(0.10);
|
|
365
|
-
|
|
366
|
-
} else {
|
|
367
|
-
// Entropy DECREASED — hypervisor clock rounding became more aggressive
|
|
368
|
-
setScore(0.0);
|
|
369
|
-
findings.push({
|
|
370
|
-
id: 'ENTROPY_DECREASES_UNDER_LOAD',
|
|
371
|
-
label: 'Entropy shrank under load — hypervisor clock-rounding confirmed',
|
|
372
|
-
detail: `ratio=${ejr.toFixed(3)} (clock rounding more aggressive at high load) ${qeDetail}`,
|
|
373
|
-
severity: 'critical',
|
|
374
|
-
penalty: 0.18,
|
|
375
|
-
});
|
|
376
|
-
addPenalty(0.18);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// Picket Fence detector
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* Detects periodic steal-time bursts by finding the lag with the highest
|
|
386
|
-
* autocorrelation beyond lag-5. A strong periodic peak indicates the
|
|
387
|
-
* hypervisor is scheduling the guest on a fixed quantum.
|
|
388
|
-
*
|
|
389
|
-
* Named "Picket Fence" because of how the timing histogram looks: dense
|
|
390
|
-
* clusters at fixed intervals with empty space between them — like fence posts.
|
|
391
|
-
*/
|
|
392
|
-
function _detectPicketFence(autocorrelations) {
|
|
393
|
-
const longLags = [10, 25, 50].map(l => ({
|
|
394
|
-
lag: l,
|
|
395
|
-
ac: Math.abs(autocorrelations?.[`lag${l}`] ?? 0),
|
|
396
|
-
}));
|
|
397
|
-
|
|
398
|
-
const shortBaseline = (Math.abs(autocorrelations?.lag5 ?? 0) +
|
|
399
|
-
Math.abs(autocorrelations?.lag3 ?? 0)) / 2;
|
|
400
|
-
|
|
401
|
-
const peak = longLags.reduce((best, cur) =>
|
|
402
|
-
cur.ac > best.ac ? cur : best, { lag: 0, ac: 0 });
|
|
403
|
-
|
|
404
|
-
// "Picket fence" condition: a long-lag autocorr significantly exceeds baseline
|
|
405
|
-
if (peak.ac > 0.30 && peak.ac > shortBaseline + 0.20) {
|
|
406
|
-
return {
|
|
407
|
-
detected: true,
|
|
408
|
-
dominantLag: peak.lag,
|
|
409
|
-
peakAC: peak.ac,
|
|
410
|
-
baseline: shortBaseline,
|
|
411
|
-
detail: `lag${peak.lag}_AC=${peak.ac.toFixed(3)} baseline_AC=${shortBaseline.toFixed(3)} ` +
|
|
412
|
-
`estimated_quantum≈${(peak.lag * 5).toFixed(0)}ms (at 5ms/iter)`,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return { detected: false, dominantLag: null, peakAC: peak.ac, baseline: shortBaseline, detail: '' };
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function _empty() {
|
|
420
|
-
return {
|
|
421
|
-
penalty: 0, bonus: 0, netAdjustment: 0,
|
|
422
|
-
findings: [], bonuses: [],
|
|
423
|
-
entropyJitterRatio: null, entropyJitterScore: 0.5,
|
|
424
|
-
hardOverride: null,
|
|
425
|
-
picketFence: { detected: false },
|
|
426
|
-
coherenceFlags: [],
|
|
427
|
-
};
|
|
428
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — Cross-Metric Heuristic Engine
|
|
3
|
+
*
|
|
4
|
+
* Instead of checking individual thresholds in isolation, this module looks
|
|
5
|
+
* at the *relationships* between metrics. A sophisticated adversary can spoof
|
|
6
|
+
* any single number. Spoofing six metrics so they remain mutually consistent
|
|
7
|
+
* with physical laws is exponentially harder.
|
|
8
|
+
*
|
|
9
|
+
* Three core insights drive this engine:
|
|
10
|
+
*
|
|
11
|
+
* 1. Entropy-Jitter Coherence
|
|
12
|
+
* Real silicon gets noisier as it heats up. Under sustained load, the
|
|
13
|
+
* Quantization Entropy of the timing distribution grows because thermal
|
|
14
|
+
* fluctuations add variance. A VM's hypervisor clock doesn't care about
|
|
15
|
+
* guest temperature — its entropy is flat across all load phases.
|
|
16
|
+
*
|
|
17
|
+
* 2. Hurst-Autocorrelation Coherence
|
|
18
|
+
* Genuine Brownian noise has Hurst ≈ 0.5 and near-zero autocorrelation
|
|
19
|
+
* at all lags. These two values are physically linked. If they diverge —
|
|
20
|
+
* high autocorrelation but Hurst near 0.5, or vice versa — the timings
|
|
21
|
+
* were generated, not measured.
|
|
22
|
+
*
|
|
23
|
+
* 3. CV-Entropy Coherence
|
|
24
|
+
* High variance (CV) must come from somewhere. On real hardware, high CV
|
|
25
|
+
* means the timing distribution is spread out, which also means high
|
|
26
|
+
* entropy. A VM that inflates CV without inflating entropy (e.g. by
|
|
27
|
+
* adding synthetic outliers at fixed offsets) produces a coherence gap.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { detectQuantizationEntropy } from './jitter.js';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// runHeuristicEngine
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} p
|
|
38
|
+
* @param {import('./jitter.js').JitterAnalysis} p.jitter
|
|
39
|
+
* @param {object|null} p.phases - from entropy collector
|
|
40
|
+
* @param {object} p.autocorrelations
|
|
41
|
+
* @returns {HeuristicReport}
|
|
42
|
+
*/
|
|
43
|
+
export function runHeuristicEngine({ jitter, phases, autocorrelations }) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
const bonuses = [];
|
|
46
|
+
let penalty = 0; // accumulated penalty [0, 1]
|
|
47
|
+
let bonus = 0; // accumulated bonus [0, 1]
|
|
48
|
+
let hardOverride = null; // 'vm' | null — bypasses score entirely when set
|
|
49
|
+
|
|
50
|
+
const stats = jitter.stats;
|
|
51
|
+
if (!stats) return _empty();
|
|
52
|
+
|
|
53
|
+
// ── 1. Entropy-Jitter Ratio (phases required) ────────────────────────────
|
|
54
|
+
let entropyJitterRatio = null;
|
|
55
|
+
let entropyJitterScore = 0.5; // neutral if no phased data
|
|
56
|
+
|
|
57
|
+
if (phases) {
|
|
58
|
+
entropyJitterRatio = phases.entropyJitterRatio;
|
|
59
|
+
|
|
60
|
+
const coldQE = phases.cold?.qe ?? null;
|
|
61
|
+
const hotQE = phases.hot?.qe ?? null;
|
|
62
|
+
|
|
63
|
+
// ── HARD KILL: Phase trajectory mathematical contradiction ──────────────
|
|
64
|
+
//
|
|
65
|
+
// EJR is defined as: entropyJitterRatio = hot_QE / cold_QE
|
|
66
|
+
//
|
|
67
|
+
// Before trusting any EJR value, verify it is internally consistent with
|
|
68
|
+
// the QE measurements it purports to summarise. Two forgery vectors exist:
|
|
69
|
+
//
|
|
70
|
+
// Attack A — EJR field overwritten independently:
|
|
71
|
+
// Attacker sets entropyJitterRatio = 1.15 to claim thermal growth,
|
|
72
|
+
// but leaves cold_QE = 3.50, hot_QE = 3.00 unchanged.
|
|
73
|
+
// Computed EJR = 3.00 / 3.50 = 0.857.
|
|
74
|
+
// Discrepancy 1.15 − 0.857 = 0.293 >> 0.005 tolerance → HARD KILL.
|
|
75
|
+
//
|
|
76
|
+
// Attack B — QE values also faked but left inconsistent:
|
|
77
|
+
// Attacker overwrites both QE fields carelessly: cold_QE = 3.5,
|
|
78
|
+
// hot_QE = 3.0, but EJR = 1.15 is still written.
|
|
79
|
+
// cold_QE ≥ hot_QE with EJR ≥ 1.08 is a mathematical impossibility —
|
|
80
|
+
// if hot ≤ cold then hot/cold ≤ 1.0, which can never be ≥ 1.08.
|
|
81
|
+
//
|
|
82
|
+
// Tolerance of 0.005 accounts for floating-point rounding in the entropy
|
|
83
|
+
// collector (detectQuantizationEntropy uses discrete histogram bins).
|
|
84
|
+
//
|
|
85
|
+
// When HARD KILL fires:
|
|
86
|
+
// • hardOverride = 'vm' — fingerprint.js short-circuits isSynthetic
|
|
87
|
+
// • entropyJitterScore = 0.0 — no EJR contribution to stage-2 bonus
|
|
88
|
+
// • penalty += 1.0 — overwhelms the physical floor cap
|
|
89
|
+
// • No further EJR evaluation runs (the data cannot be trusted)
|
|
90
|
+
// • The physical floor protection is explicitly bypassed (see aggregate)
|
|
91
|
+
|
|
92
|
+
if (coldQE !== null && hotQE !== null) {
|
|
93
|
+
const computedEJR = coldQE > 0 ? hotQE / coldQE : null;
|
|
94
|
+
const fieldTampered = computedEJR !== null &&
|
|
95
|
+
Math.abs(entropyJitterRatio - computedEJR) > 0.005;
|
|
96
|
+
const qeContradicts = entropyJitterRatio >= 1.08 && coldQE >= hotQE;
|
|
97
|
+
|
|
98
|
+
if (fieldTampered || qeContradicts) {
|
|
99
|
+
hardOverride = 'vm';
|
|
100
|
+
entropyJitterScore = 0.0;
|
|
101
|
+
findings.push({
|
|
102
|
+
id: 'EJR_PHASE_HARD_KILL',
|
|
103
|
+
label: fieldTampered
|
|
104
|
+
? 'HARD KILL: stored EJR is inconsistent with cold/hot QE values — phase data tampered'
|
|
105
|
+
: 'HARD KILL: EJR ≥ 1.08 claims entropy growth but cold_QE ≥ hot_QE — physically impossible',
|
|
106
|
+
detail: `ejr_stored=${entropyJitterRatio.toFixed(4)} ` +
|
|
107
|
+
`ejr_computed=${computedEJR?.toFixed(4) ?? 'n/a'} ` +
|
|
108
|
+
`cold_QE=${coldQE.toFixed(4)} hot_QE=${hotQE.toFixed(4)} ` +
|
|
109
|
+
`delta=${computedEJR != null ? Math.abs(entropyJitterRatio - computedEJR).toFixed(4) : 'n/a'}`,
|
|
110
|
+
severity: 'critical',
|
|
111
|
+
penalty: 1.0,
|
|
112
|
+
});
|
|
113
|
+
penalty += 1.0;
|
|
114
|
+
|
|
115
|
+
} else {
|
|
116
|
+
// QE values confirmed consistent — proceed with normal EJR evaluation.
|
|
117
|
+
_evaluateEJR(entropyJitterRatio, coldQE, hotQE, findings, bonuses,
|
|
118
|
+
(p) => { penalty += p; }, (b) => { bonus += b; },
|
|
119
|
+
(s) => { entropyJitterScore = s; });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
} else {
|
|
123
|
+
// QE values unavailable — evaluate EJR ratio in isolation.
|
|
124
|
+
_evaluateEJR(entropyJitterRatio, null, null, findings, bonuses,
|
|
125
|
+
(p) => { penalty += p; }, (b) => { bonus += b; },
|
|
126
|
+
(s) => { entropyJitterScore = s; });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Phase mean drift: real CPU heats up → iterations get slower.
|
|
130
|
+
// Only apply if hard kill wasn't already triggered.
|
|
131
|
+
if (!hardOverride) {
|
|
132
|
+
const coldToHotDrift = (phases.hot?.mean ?? 0) - (phases.cold?.mean ?? 0);
|
|
133
|
+
if (coldToHotDrift > 0.05) {
|
|
134
|
+
bonuses.push({
|
|
135
|
+
id: 'THERMAL_DRIFT_CONFIRMED',
|
|
136
|
+
label: 'CPU mean timing increased from cold to hot phase (thermal drift)',
|
|
137
|
+
detail: `cold=${phases.cold.mean.toFixed(3)}ms hot=${phases.hot.mean.toFixed(3)}ms Δ=${coldToHotDrift.toFixed(3)}ms`,
|
|
138
|
+
value: 0.08,
|
|
139
|
+
});
|
|
140
|
+
bonus += 0.08;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 2. Hurst-Autocorrelation Coherence ───────────────────────────────────
|
|
146
|
+
const h = jitter.hurstExponent ?? 0.5;
|
|
147
|
+
const ac1 = Math.abs(autocorrelations?.lag1 ?? 0);
|
|
148
|
+
const ac5 = Math.abs(autocorrelations?.lag5 ?? 0);
|
|
149
|
+
const ac50 = Math.abs(autocorrelations?.lag50 ?? 0);
|
|
150
|
+
|
|
151
|
+
// Physical law: Brownian noise (H≈0.5) must have low autocorrelation.
|
|
152
|
+
// Divergence between these two means the data wasn't generated by physics.
|
|
153
|
+
const hurstExpectedAC = Math.abs(2 * h - 1); // theoretical max |autocorr| for given H
|
|
154
|
+
const actualAC = (ac1 + ac5) / 2;
|
|
155
|
+
const acHurstDivergence = Math.abs(actualAC - hurstExpectedAC);
|
|
156
|
+
|
|
157
|
+
if (acHurstDivergence > 0.35) {
|
|
158
|
+
findings.push({
|
|
159
|
+
id: 'HURST_AUTOCORR_INCOHERENT',
|
|
160
|
+
label: 'Hurst exponent and autocorrelation are physically inconsistent',
|
|
161
|
+
detail: `H=${h.toFixed(3)} expected_AC≈${hurstExpectedAC.toFixed(3)} actual_AC=${actualAC.toFixed(3)} divergence=${acHurstDivergence.toFixed(3)}`,
|
|
162
|
+
severity: 'high',
|
|
163
|
+
penalty: 0.12,
|
|
164
|
+
});
|
|
165
|
+
penalty += 0.12;
|
|
166
|
+
} else if (h > 0.45 && h < 0.55 && ac1 < 0.15) {
|
|
167
|
+
// Ideal Brownian + low autocorr — physically coherent
|
|
168
|
+
bonuses.push({
|
|
169
|
+
id: 'BROWNIAN_COHERENCE_CONFIRMED',
|
|
170
|
+
label: 'Hurst ≈ 0.5 and autocorrelation near zero — genuine Brownian noise',
|
|
171
|
+
detail: `H=${h.toFixed(3)} lag1_AC=${ac1.toFixed(3)}`,
|
|
172
|
+
value: 0.10,
|
|
173
|
+
});
|
|
174
|
+
bonus += 0.10;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 3. CV-Entropy Coherence ───────────────────────────────────────────────
|
|
178
|
+
// High CV should correlate with high QE. If CV is high but QE is low,
|
|
179
|
+
// the variance was added artificially (fixed-offset outliers, synthetic spikes).
|
|
180
|
+
const cv = stats.cv;
|
|
181
|
+
const qe = jitter.quantizationEntropy;
|
|
182
|
+
|
|
183
|
+
// Expected QE given CV, assuming roughly normal distribution
|
|
184
|
+
// Normal dist with σ/μ = CV: entropy ≈ log2(σ * sqrt(2πe)) + log2(n/binWidth)
|
|
185
|
+
// We use a simplified linear proxy calibrated against real benchmarks.
|
|
186
|
+
const expectedQE = Math.max(0, 1.5 + cv * 16); // empirical: CV=0.15 → QE≈3.9
|
|
187
|
+
const qeDivergence = expectedQE - qe; // positive = QE lower than expected
|
|
188
|
+
|
|
189
|
+
if (qeDivergence > 1.8 && cv > 0.05) {
|
|
190
|
+
// High variance but low entropy: synthetic outliers at fixed offsets
|
|
191
|
+
findings.push({
|
|
192
|
+
id: 'CV_ENTROPY_INCOHERENT',
|
|
193
|
+
label: 'High CV but low entropy — variance appears synthetic (fixed-offset outliers)',
|
|
194
|
+
detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} bits expected_QE≈${expectedQE.toFixed(3)} gap=${qeDivergence.toFixed(3)}`,
|
|
195
|
+
severity: 'high',
|
|
196
|
+
penalty: 0.10,
|
|
197
|
+
});
|
|
198
|
+
penalty += 0.10;
|
|
199
|
+
} else if (qeDivergence < 0.5 && cv > 0.08) {
|
|
200
|
+
// CV and QE are coherent — timings come from a real distribution
|
|
201
|
+
bonuses.push({
|
|
202
|
+
id: 'CV_ENTROPY_COHERENT',
|
|
203
|
+
label: 'Variance and entropy are physically coherent',
|
|
204
|
+
detail: `CV=${cv.toFixed(4)} QE=${qe.toFixed(3)} expected≈${expectedQE.toFixed(3)}`,
|
|
205
|
+
value: 0.06,
|
|
206
|
+
});
|
|
207
|
+
bonus += 0.06;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 4. Steal-time periodicity (the "Picket Fence" detector) ─────────────
|
|
211
|
+
// VM steal-time bursts create a periodic signal in the autocorrelation.
|
|
212
|
+
// If lag-50 autocorrelation is significantly higher than lag-5,
|
|
213
|
+
// the scheduler quantum is approximately 50× the mean iteration time.
|
|
214
|
+
const picketFence = _detectPicketFence(autocorrelations);
|
|
215
|
+
if (picketFence.detected) {
|
|
216
|
+
findings.push({
|
|
217
|
+
id: 'PICKET_FENCE_DETECTED',
|
|
218
|
+
label: `"Picket Fence" steal-time rhythm detected at lag ${picketFence.dominantLag}`,
|
|
219
|
+
detail: picketFence.detail,
|
|
220
|
+
severity: 'high',
|
|
221
|
+
penalty: 0.08,
|
|
222
|
+
});
|
|
223
|
+
penalty += 0.08;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── 5. Skewness-Kurtosis coherence ───────────────────────────────────────
|
|
227
|
+
// Real hardware timing is right-skewed (occasional slow outliers from OS preemption).
|
|
228
|
+
// VMs that add synthetic outliers at fixed offsets produce wrong skew/kurtosis.
|
|
229
|
+
const skew = stats.skewness ?? 0;
|
|
230
|
+
const kurt = stats.kurtosis ?? 0;
|
|
231
|
+
|
|
232
|
+
if (skew > 0.3 && kurt > 0) {
|
|
233
|
+
// Right-skewed, leptokurtic — consistent with OS preemption on real hardware
|
|
234
|
+
bonuses.push({
|
|
235
|
+
id: 'NATURAL_SKEW_CONFIRMED',
|
|
236
|
+
label: 'Right-skewed distribution with positive kurtosis — OS preemption pattern',
|
|
237
|
+
detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
|
|
238
|
+
value: 0.06,
|
|
239
|
+
});
|
|
240
|
+
bonus += 0.06;
|
|
241
|
+
} else if (skew < 0 && Math.abs(kurt) > 1) {
|
|
242
|
+
// Negative skew with high kurtosis: inconsistent with physical timing noise
|
|
243
|
+
findings.push({
|
|
244
|
+
id: 'SKEW_KURTOSIS_ANOMALY',
|
|
245
|
+
label: 'Left-skewed distribution — inconsistent with natural hardware timing',
|
|
246
|
+
detail: `skew=${skew.toFixed(3)} kurtosis=${kurt.toFixed(3)}`,
|
|
247
|
+
severity: 'medium',
|
|
248
|
+
penalty: 0.06,
|
|
249
|
+
});
|
|
250
|
+
penalty += 0.06;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Physical floor protection (anti-compounding) ──────────────────────────
|
|
254
|
+
// When the three PRIMARY timing metrics are clearly consistent with real
|
|
255
|
+
// silicon, cap the penalty so that marginal secondary signals (weak Picket
|
|
256
|
+
// Fence, mild EJR, slight skew anomaly) cannot compound into a rejection.
|
|
257
|
+
//
|
|
258
|
+
// Why: a modern i7 laptop running heavy browser extensions may show:
|
|
259
|
+
// EJR = 1.01 → -0.10 penalty (just under the 1.02 threshold)
|
|
260
|
+
// lag50 = 0.31 → picket fence → -0.08 penalty (background process rhythm)
|
|
261
|
+
// slight negative skew → -0.06 penalty
|
|
262
|
+
// total: -0.24, drops score from 0.73 → 0.49 → wrongly flagged as synthetic
|
|
263
|
+
//
|
|
264
|
+
// Solution: if ≥ 2 of the 3 primary metrics are unambiguously physical,
|
|
265
|
+
// treat the device as "probably physical with some noise" and limit the
|
|
266
|
+
// penalty to 0.22 (enough to lower confidence but not enough to reject).
|
|
267
|
+
const clearQE = jitter.quantizationEntropy > 3.2;
|
|
268
|
+
const clearCV = stats.cv >= 0.05 && stats.cv <= 0.30;
|
|
269
|
+
const clearLag1 = Math.abs(autocorrelations?.lag1 ?? 1) < 0.22;
|
|
270
|
+
const clearPhysicalCount = [clearQE, clearCV, clearLag1].filter(Boolean).length;
|
|
271
|
+
|
|
272
|
+
// Also check: if at least one metric is a HARD VM indicator (QE < 2.0 or
|
|
273
|
+
// lag1 > 0.65), override the floor — the floor is for borderline noise, not
|
|
274
|
+
// for devices that are clearly VMs on at least one axis.
|
|
275
|
+
const hardVmSignal =
|
|
276
|
+
jitter.quantizationEntropy < 2.0 ||
|
|
277
|
+
Math.abs(autocorrelations?.lag1 ?? 0) > 0.65;
|
|
278
|
+
|
|
279
|
+
const penaltyCap = (!hardVmSignal && clearPhysicalCount >= 2)
|
|
280
|
+
? 0.22 // physical floor: cap compounding for clearly physical devices
|
|
281
|
+
: 0.60; // default: full penalty range for ambiguous or VM-like signals
|
|
282
|
+
|
|
283
|
+
// HARD KILL overrides the physical floor protection entirely.
|
|
284
|
+
// The floor was designed to protect legitimate hardware with multiple
|
|
285
|
+
// marginal-but-honest signals — it must never shelter a forged proof.
|
|
286
|
+
const totalPenalty = hardOverride === 'vm'
|
|
287
|
+
? Math.min(1.0, penalty) // hard kill: uncapped, overwhelms all bonuses
|
|
288
|
+
: Math.min(penaltyCap, penalty); // normal: apply floor protection
|
|
289
|
+
|
|
290
|
+
// When a hard kill is active, strip all bonuses — they were earned on
|
|
291
|
+
// data that has been proved untrustworthy.
|
|
292
|
+
const totalBonus = hardOverride === 'vm' ? 0 : Math.min(0.35, bonus);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
penalty: totalPenalty,
|
|
296
|
+
bonus: totalBonus,
|
|
297
|
+
netAdjustment: totalBonus - totalPenalty,
|
|
298
|
+
findings,
|
|
299
|
+
bonuses: hardOverride === 'vm' ? [] : bonuses,
|
|
300
|
+
entropyJitterRatio,
|
|
301
|
+
entropyJitterScore,
|
|
302
|
+
picketFence,
|
|
303
|
+
hardOverride,
|
|
304
|
+
coherenceFlags: findings.map(f => f.id),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @typedef {object} HeuristicReport
|
|
310
|
+
* @property {number} penalty - total score penalty [0, 1.0]
|
|
311
|
+
* @property {number} bonus - total score bonus [0, 0.35]
|
|
312
|
+
* @property {number} netAdjustment - bonus - penalty
|
|
313
|
+
* @property {object[]} findings - detected anomalies
|
|
314
|
+
* @property {object[]} bonuses - confirmed physical properties
|
|
315
|
+
* @property {number|null} entropyJitterRatio
|
|
316
|
+
* @property {'vm'|null} hardOverride - set when a mathematical impossibility is detected
|
|
317
|
+
* @property {object} picketFence
|
|
318
|
+
* @property {string[]} coherenceFlags
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// EJR evaluation helper (extracted so it can run with or without QE values)
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Applies the normal EJR classification logic (called only after the hard-kill
|
|
327
|
+
* check passes, meaning the EJR value has been verified as consistent).
|
|
328
|
+
*/
|
|
329
|
+
function _evaluateEJR(ejr, coldQE, hotQE, findings, bonuses, addPenalty, addBonus, setScore) {
|
|
330
|
+
const qeDetail = coldQE != null
|
|
331
|
+
? `cold_QE=${coldQE.toFixed(3)} hot_QE=${hotQE.toFixed(3)}`
|
|
332
|
+
: '';
|
|
333
|
+
|
|
334
|
+
if (ejr >= 1.08) {
|
|
335
|
+
setScore(1.0);
|
|
336
|
+
bonuses.push({
|
|
337
|
+
id: 'ENTROPY_GROWS_WITH_LOAD',
|
|
338
|
+
label: 'Entropy grew under load — thermal feedback confirmed',
|
|
339
|
+
detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
|
|
340
|
+
value: 0.12,
|
|
341
|
+
});
|
|
342
|
+
addBonus(0.12);
|
|
343
|
+
|
|
344
|
+
} else if (ejr >= 1.02) {
|
|
345
|
+
setScore(0.7);
|
|
346
|
+
findings.push({
|
|
347
|
+
id: 'ENTROPY_MILD_GROWTH',
|
|
348
|
+
label: 'Weak entropy growth under load',
|
|
349
|
+
detail: `ratio=${ejr.toFixed(3)} ${qeDetail}`,
|
|
350
|
+
severity: 'info',
|
|
351
|
+
penalty: 0,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
} else if (ejr > 0.95) {
|
|
355
|
+
// Flat entropy — hypervisor clock unresponsive to guest load
|
|
356
|
+
setScore(0.2);
|
|
357
|
+
findings.push({
|
|
358
|
+
id: 'ENTROPY_FLAT_UNDER_LOAD',
|
|
359
|
+
label: 'Entropy did not grow under load — hypervisor clock suspected',
|
|
360
|
+
detail: `ratio=${ejr.toFixed(3)} (expected ≥ 1.08 for real hardware) ${qeDetail}`,
|
|
361
|
+
severity: 'high',
|
|
362
|
+
penalty: 0.10,
|
|
363
|
+
});
|
|
364
|
+
addPenalty(0.10);
|
|
365
|
+
|
|
366
|
+
} else {
|
|
367
|
+
// Entropy DECREASED — hypervisor clock rounding became more aggressive
|
|
368
|
+
setScore(0.0);
|
|
369
|
+
findings.push({
|
|
370
|
+
id: 'ENTROPY_DECREASES_UNDER_LOAD',
|
|
371
|
+
label: 'Entropy shrank under load — hypervisor clock-rounding confirmed',
|
|
372
|
+
detail: `ratio=${ejr.toFixed(3)} (clock rounding more aggressive at high load) ${qeDetail}`,
|
|
373
|
+
severity: 'critical',
|
|
374
|
+
penalty: 0.18,
|
|
375
|
+
});
|
|
376
|
+
addPenalty(0.18);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Picket Fence detector
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Detects periodic steal-time bursts by finding the lag with the highest
|
|
386
|
+
* autocorrelation beyond lag-5. A strong periodic peak indicates the
|
|
387
|
+
* hypervisor is scheduling the guest on a fixed quantum.
|
|
388
|
+
*
|
|
389
|
+
* Named "Picket Fence" because of how the timing histogram looks: dense
|
|
390
|
+
* clusters at fixed intervals with empty space between them — like fence posts.
|
|
391
|
+
*/
|
|
392
|
+
function _detectPicketFence(autocorrelations) {
|
|
393
|
+
const longLags = [10, 25, 50].map(l => ({
|
|
394
|
+
lag: l,
|
|
395
|
+
ac: Math.abs(autocorrelations?.[`lag${l}`] ?? 0),
|
|
396
|
+
}));
|
|
397
|
+
|
|
398
|
+
const shortBaseline = (Math.abs(autocorrelations?.lag5 ?? 0) +
|
|
399
|
+
Math.abs(autocorrelations?.lag3 ?? 0)) / 2;
|
|
400
|
+
|
|
401
|
+
const peak = longLags.reduce((best, cur) =>
|
|
402
|
+
cur.ac > best.ac ? cur : best, { lag: 0, ac: 0 });
|
|
403
|
+
|
|
404
|
+
// "Picket fence" condition: a long-lag autocorr significantly exceeds baseline
|
|
405
|
+
if (peak.ac > 0.30 && peak.ac > shortBaseline + 0.20) {
|
|
406
|
+
return {
|
|
407
|
+
detected: true,
|
|
408
|
+
dominantLag: peak.lag,
|
|
409
|
+
peakAC: peak.ac,
|
|
410
|
+
baseline: shortBaseline,
|
|
411
|
+
detail: `lag${peak.lag}_AC=${peak.ac.toFixed(3)} baseline_AC=${shortBaseline.toFixed(3)} ` +
|
|
412
|
+
`estimated_quantum≈${(peak.lag * 5).toFixed(0)}ms (at 5ms/iter)`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { detected: false, dominantLag: null, peakAC: peak.ac, baseline: shortBaseline, detail: '' };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function _empty() {
|
|
420
|
+
return {
|
|
421
|
+
penalty: 0, bonus: 0, netAdjustment: 0,
|
|
422
|
+
findings: [], bonuses: [],
|
|
423
|
+
entropyJitterRatio: null, entropyJitterScore: 0.5,
|
|
424
|
+
hardOverride: null,
|
|
425
|
+
picketFence: { detected: false },
|
|
426
|
+
coherenceFlags: [],
|
|
427
|
+
};
|
|
428
|
+
}
|