@svrnsec/pulse 0.6.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.
Files changed (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -622
  3. package/SECURITY.md +86 -86
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6379 -6420
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6380 -6421
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +895 -846
  10. package/package.json +185 -165
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -390
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -0
  16. package/src/analysis/heuristic.js +428 -428
  17. package/src/analysis/jitter.js +446 -446
  18. package/src/analysis/llm.js +473 -472
  19. package/src/analysis/populationEntropy.js +404 -403
  20. package/src/analysis/provider.js +248 -248
  21. package/src/analysis/refraction.js +392 -0
  22. package/src/analysis/trustScore.js +356 -356
  23. package/src/cli/args.js +36 -36
  24. package/src/cli/commands/scan.js +192 -192
  25. package/src/cli/runner.js +157 -157
  26. package/src/collector/adaptive.js +200 -200
  27. package/src/collector/bio.js +297 -287
  28. package/src/collector/canvas.js +247 -239
  29. package/src/collector/dram.js +203 -203
  30. package/src/collector/enf.js +311 -311
  31. package/src/collector/entropy.js +195 -195
  32. package/src/collector/gpu.js +248 -245
  33. package/src/collector/idleAttestation.js +480 -480
  34. package/src/collector/sabTimer.js +189 -191
  35. package/src/fingerprint.js +475 -475
  36. package/src/index.js +342 -342
  37. package/src/integrations/react-native.js +462 -459
  38. package/src/integrations/react.js +184 -185
  39. package/src/middleware/express.js +155 -155
  40. package/src/middleware/next.js +174 -175
  41. package/src/proof/challenge.js +249 -249
  42. package/src/proof/engagementToken.js +426 -394
  43. package/src/proof/fingerprint.js +268 -268
  44. package/src/proof/validator.js +83 -143
  45. package/src/registry/serializer.js +349 -349
  46. package/src/terminal.js +263 -263
  47. package/src/update-notifier.js +259 -264
  48. package/dist/pulse.cjs.js.map +0 -1
@@ -0,0 +1,392 @@
1
+ /**
2
+ * @svrnsec/pulse — Refraction
3
+ *
4
+ * The same physics signal "refracts" through different measurement mediums.
5
+ * A browser's clamped performance.now() (~100μs Spectre mitigation) shifts
6
+ * every statistical distribution compared to Node.js process.hrtime.bigint().
7
+ *
8
+ * Instead of maintaining two sets of hardcoded thresholds, Refraction:
9
+ * 1. Probes the timer to measure actual resolution
10
+ * 2. Detects the execution environment (browser, Node, Deno, worker, etc.)
11
+ * 3. Computes a calibration profile that shifts all scoring bands
12
+ * 4. Exposes the profile so every downstream analyzer (jitter, trustScore,
13
+ * population entropy) can score against the correct baseline
14
+ *
15
+ * Core insight:
16
+ * A VM in Node.js and real hardware in a browser can produce identical
17
+ * Hurst exponents. Without knowing the medium, the score is meaningless.
18
+ * Refraction makes the medium explicit.
19
+ *
20
+ * Usage:
21
+ * import { calibrate, getProfile } from '@svrnsec/pulse/refraction'
22
+ * const profile = await calibrate() // run once at init
23
+ * const score = classifyJitter(timings, { refraction: profile })
24
+ */
25
+
26
+ // ─── Environment detection ───────────────────────────────────────────────────
27
+
28
+ const ENV = Object.freeze({
29
+ NODE: 'node',
30
+ BROWSER: 'browser',
31
+ WORKER: 'worker', // Web Worker / Service Worker
32
+ DENO: 'deno',
33
+ BUN: 'bun',
34
+ UNKNOWN: 'unknown',
35
+ });
36
+
37
+ /**
38
+ * Detect the current JS runtime without relying on user-agent sniffing.
39
+ * Uses capability detection — what APIs exist, not what strings say.
40
+ */
41
+ function detectEnvironment() {
42
+ // Deno has Deno global
43
+ if (typeof globalThis.Deno !== 'undefined') return ENV.DENO;
44
+ // Bun has Bun global
45
+ if (typeof globalThis.Bun !== 'undefined') return ENV.BUN;
46
+ // Node.js has process.versions.node
47
+ if (typeof globalThis.process !== 'undefined' &&
48
+ globalThis.process.versions?.node) return ENV.NODE;
49
+ // Web Worker has self but no document
50
+ if (typeof globalThis.WorkerGlobalScope !== 'undefined') return ENV.WORKER;
51
+ // Browser has window + document
52
+ if (typeof globalThis.window !== 'undefined' &&
53
+ typeof globalThis.document !== 'undefined') return ENV.BROWSER;
54
+ return ENV.UNKNOWN;
55
+ }
56
+
57
+ // ─── Timer resolution probe ──────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Measure the actual timer resolution by collecting minimum non-zero deltas.
61
+ * Returns resolution in microseconds.
62
+ *
63
+ * Browser (Spectre-mitigated): typically 100μs
64
+ * Node.js hrtime: typically < 1μs
65
+ * Node.js performance.now(): typically ~1μs
66
+ *
67
+ * We probe with performance.now() since it's cross-platform.
68
+ * In Node.js we also check process.hrtime.bigint() availability.
69
+ */
70
+ function probeTimerResolution(iterations = 500) {
71
+ const deltas = [];
72
+ const pnow = typeof performance !== 'undefined' && performance.now
73
+ ? () => performance.now()
74
+ : () => Date.now(); // fallback — 1ms resolution
75
+
76
+ for (let i = 0; i < iterations; i++) {
77
+ const t0 = pnow();
78
+ // Minimal work — just enough to not get optimized away
79
+ let x = 0;
80
+ for (let j = 0; j < 10; j++) x += j;
81
+ const t1 = pnow();
82
+ const dt = t1 - t0;
83
+ if (dt > 0) deltas.push(dt);
84
+ if (x === -1) deltas.push(0); // prevent dead code elimination
85
+ }
86
+
87
+ if (deltas.length === 0) return { resolutionUs: 1000, grain: 'coarse' };
88
+
89
+ deltas.sort((a, b) => a - b);
90
+
91
+ // Minimum non-zero delta = timer resolution floor
92
+ const minDelta = deltas[0];
93
+ // Median gives stable estimate
94
+ const medDelta = deltas[Math.floor(deltas.length / 2)];
95
+ // Count unique values — clamped timers produce few unique values
96
+ const unique = new Set(deltas.map(d => d.toFixed(4))).size;
97
+ const uniqueRatio = unique / deltas.length;
98
+
99
+ const resolutionUs = minDelta * 1000; // ms → μs
100
+
101
+ let grain;
102
+ if (resolutionUs < 5) grain = 'nanosecond'; // < 5μs — Node.js / Deno
103
+ else if (resolutionUs < 50) grain = 'fine'; // 5–50μs — some browsers with relaxed policy
104
+ else if (resolutionUs < 200) grain = 'clamped'; // 50–200μs — standard Spectre mitigation
105
+ else grain = 'coarse'; // > 200μs — aggressive clamping or Date.now()
106
+
107
+ return {
108
+ resolutionUs: +resolutionUs.toFixed(2),
109
+ minDeltaMs: +minDelta.toFixed(6),
110
+ medDeltaMs: +medDelta.toFixed(6),
111
+ uniqueRatio: +uniqueRatio.toFixed(4),
112
+ grain,
113
+ samples: deltas.length,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Check if high-resolution timer is available (Node.js process.hrtime).
119
+ */
120
+ function hasHrtime() {
121
+ return typeof globalThis.process !== 'undefined' &&
122
+ typeof globalThis.process.hrtime?.bigint === 'function';
123
+ }
124
+
125
+ // ─── Threshold profile ───────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Scoring thresholds shift based on timer grain.
129
+ *
130
+ * Why each threshold changes:
131
+ *
132
+ * CV (Coefficient of Variation):
133
+ * Clamped timers absorb small jitter → CV appears lower on real hardware.
134
+ * But heavy ops (32K+) push deltas above clamp floor, creating burst variance.
135
+ * Browser real HW: CV 0.01–0.90. Node real HW: CV 0.04–0.35.
136
+ *
137
+ * Hurst Exponent:
138
+ * Timer clamping introduces quantization steps that shift H upward.
139
+ * Browser real HW: H 0.15–0.82. Node real HW: H 0.25–0.55.
140
+ *
141
+ * Autocorrelation:
142
+ * Browser event loop scheduling adds baseline AC ~0.3–0.5 on real hardware.
143
+ * VMs in browser still show higher AC > 0.65 from hypervisor tick.
144
+ * Node real HW: AC < 0.20. Browser real HW: AC < 0.50.
145
+ *
146
+ * Quantization Entropy:
147
+ * Fewer unique timer values → fewer populated bins → lower QE.
148
+ * Node real HW: QE > 3.0. Browser real HW: QE > 0.8.
149
+ *
150
+ * Unique Value Ratio:
151
+ * Clamped timers repeat values more.
152
+ * Node real HW: UVR > 0.60. Browser real HW: UVR > 0.15.
153
+ */
154
+
155
+ const PROFILES = {
156
+ nanosecond: {
157
+ label: 'High-resolution timer (Node.js / Deno)',
158
+ cv: { floor: 0.04, ceil: 0.35, vmFloor: 0.02 },
159
+ hurst: { floor: 0.25, ceil: 0.55, vmCeil: 0.60 },
160
+ ac: { pass: 0.20, warn: 0.35, fail: 0.50 },
161
+ qe: { pass: 3.0, warn: 1.5 },
162
+ uvr: { pass: 0.60, warn: 0.30 },
163
+ dram: { elFloor: 2, mcvFloor: 0.04 },
164
+ },
165
+ fine: {
166
+ label: 'Fine timer (relaxed browser policy)',
167
+ cv: { floor: 0.02, ceil: 0.60, vmFloor: 0.01 },
168
+ hurst: { floor: 0.20, ceil: 0.70, vmCeil: 0.75 },
169
+ ac: { pass: 0.35, warn: 0.50, fail: 0.60 },
170
+ qe: { pass: 1.5, warn: 0.8 },
171
+ uvr: { pass: 0.30, warn: 0.15 },
172
+ dram: { elFloor: 3, mcvFloor: 0.03 },
173
+ },
174
+ clamped: {
175
+ label: 'Spectre-mitigated browser timer (~100μs)',
176
+ cv: { floor: 0.01, ceil: 0.90, vmFloor: 0.005 },
177
+ hurst: { floor: 0.15, ceil: 0.82, vmCeil: 0.88 },
178
+ ac: { pass: 0.50, warn: 0.60, fail: 0.70 },
179
+ qe: { pass: 0.8, warn: 0.5 },
180
+ uvr: { pass: 0.15, warn: 0.08 },
181
+ dram: { elFloor: 5, mcvFloor: 0.02 },
182
+ },
183
+ coarse: {
184
+ label: 'Coarse timer (aggressive clamping / Date.now)',
185
+ cv: { floor: 0.005, ceil: 1.0, vmFloor: 0.002 },
186
+ hurst: { floor: 0.10, ceil: 0.88, vmCeil: 0.92 },
187
+ ac: { pass: 0.55, warn: 0.65, fail: 0.75 },
188
+ qe: { pass: 0.5, warn: 0.3 },
189
+ uvr: { pass: 0.08, warn: 0.04 },
190
+ dram: { elFloor: 5, mcvFloor: 0.02 },
191
+ },
192
+ };
193
+
194
+ // ─── Calibration ─────────────────────────────────────────────────────────────
195
+
196
+ /** @type {RefractionProfile|null} */
197
+ // Module-level singleton — environment-specific. Not safe to share between server-side and client-side timing data. Use resetProfile() to clear.
198
+ let _cached = null;
199
+
200
+ /**
201
+ * Run the full calibration sequence. Call once at init; results are cached.
202
+ *
203
+ * @param {object} [opts]
204
+ * @param {boolean} [opts.force=false] - bypass cache and re-probe
205
+ * @returns {Promise<RefractionProfile>}
206
+ */
207
+ export async function calibrate(opts = {}) {
208
+ if (_cached && !opts.force) return _cached;
209
+
210
+ const env = detectEnvironment();
211
+ const timer = probeTimerResolution();
212
+ const hrtime = hasHrtime();
213
+
214
+ // If Node.js with hrtime, always use nanosecond profile regardless of
215
+ // performance.now() resolution (which may be coarser on some builds).
216
+ const grain = (env === ENV.NODE && hrtime) ? 'nanosecond' : timer.grain;
217
+ const thresholds = PROFILES[grain];
218
+
219
+ _cached = Object.freeze({
220
+ env,
221
+ timer,
222
+ grain,
223
+ hrtime,
224
+ thresholds,
225
+ label: thresholds.label,
226
+ calibratedAt: Date.now(),
227
+ });
228
+
229
+ return _cached;
230
+ }
231
+
232
+ /**
233
+ * Get the cached profile. Returns null if calibrate() hasn't been called.
234
+ * @returns {RefractionProfile|null}
235
+ */
236
+ export function getProfile() {
237
+ return _cached;
238
+ }
239
+
240
+ /**
241
+ * Synchronous calibration for environments that can't await.
242
+ * Slightly less accurate than async version but sufficient for most cases.
243
+ */
244
+ export function calibrateSync() {
245
+ if (_cached) return _cached;
246
+
247
+ const env = detectEnvironment();
248
+ const timer = probeTimerResolution();
249
+ const hrtime = hasHrtime();
250
+ const grain = (env === ENV.NODE && hrtime) ? 'nanosecond' : timer.grain;
251
+ const thresholds = PROFILES[grain];
252
+
253
+ _cached = Object.freeze({
254
+ env,
255
+ timer,
256
+ grain,
257
+ hrtime,
258
+ thresholds,
259
+ label: thresholds.label,
260
+ calibratedAt: Date.now(),
261
+ });
262
+
263
+ return _cached;
264
+ }
265
+
266
+ /**
267
+ * Reset cached profile. Useful for testing.
268
+ */
269
+ export function resetProfile() {
270
+ _cached = null;
271
+ }
272
+
273
+ // ─── Threshold accessors ─────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Get the active thresholds for a specific signal.
277
+ * Falls back to 'clamped' profile if not calibrated (safe default).
278
+ *
279
+ * @param {'cv'|'hurst'|'ac'|'qe'|'uvr'|'dram'} signal
280
+ * @returns {object}
281
+ */
282
+ export function getThresholds(signal) {
283
+ const profile = _cached?.thresholds ?? PROFILES.clamped;
284
+ return profile[signal];
285
+ }
286
+
287
+ /**
288
+ * Score a value against refraction-aware thresholds.
289
+ * Returns { score: 0-1, pass: boolean, flag: string|null }
290
+ *
291
+ * @param {'cv'|'hurst'|'ac'|'qe'|'uvr'} signal
292
+ * @param {number} value
293
+ * @returns {{ score: number, pass: boolean, flag: string|null }}
294
+ */
295
+ export function scoreSignal(signal, value) {
296
+ const t = getThresholds(signal);
297
+
298
+ switch (signal) {
299
+ case 'cv': {
300
+ if (value >= t.floor && value <= t.ceil) return { score: 1, pass: true, flag: null };
301
+ if (value < t.vmFloor) return { score: 0.3, pass: false, flag: 'CV_FLAT_HYPERVISOR' };
302
+ if (value < t.floor) return { score: 0.6 + (value - t.vmFloor) / (t.floor - t.vmFloor) * 0.4, pass: false, flag: 'CV_LOW_BORDERLINE' };
303
+ if (value > t.ceil) return { score: 0.6, pass: false, flag: 'CV_HIGH_BURST' };
304
+ return { score: 0.5, pass: false, flag: 'CV_ANOMALOUS' };
305
+ }
306
+ case 'hurst': {
307
+ if (value >= t.floor && value <= t.ceil) return { score: 1, pass: true, flag: null };
308
+ if (value > t.vmCeil) return { score: 0.3, pass: false, flag: 'HURST_PERSISTENT_VM' };
309
+ if (value > t.ceil) return { score: 0.6, pass: false, flag: 'HURST_HIGH_BORDERLINE' };
310
+ if (value < t.floor) return { score: 0.65, pass: false, flag: 'HURST_WEAK' };
311
+ return { score: 0.5, pass: false, flag: 'HURST_ANOMALOUS' };
312
+ }
313
+ case 'ac': {
314
+ if (value < t.pass) return { score: 1, pass: true, flag: null };
315
+ if (value < t.warn) return { score: 0.7, pass: false, flag: 'AC_MODERATE' };
316
+ if (value < t.fail) return { score: 0.5, pass: false, flag: 'AC_HIGH' };
317
+ return { score: 0.2, pass: false, flag: 'AC_VM_PERIODIC' };
318
+ }
319
+ case 'qe': {
320
+ if (value >= t.pass) return { score: 1, pass: true, flag: null };
321
+ if (value >= t.warn) return { score: 0.75, pass: false, flag: 'QE_LOW_BORDERLINE' };
322
+ return { score: 0.35, pass: false, flag: 'QE_QUANTIZED' };
323
+ }
324
+ case 'uvr': {
325
+ if (value >= t.pass) return { score: 1, pass: true, flag: null };
326
+ if (value >= t.warn) return { score: 0.7, pass: false, flag: 'UVR_LOW_DIVERSITY' };
327
+ return { score: 0.3, pass: false, flag: 'UVR_CLAMPED' };
328
+ }
329
+ default:
330
+ return { score: 0.5, pass: false, flag: 'UNKNOWN_SIGNAL' };
331
+ }
332
+ }
333
+
334
+ // ─── Composite scoring ───────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Score an entire jitter analysis result through the refraction lens.
338
+ * This is the primary API — pass your raw stats and get back a
339
+ * refraction-aware score with full breakdown.
340
+ *
341
+ * @param {object} stats - { cv, hurst, ac1, qe, uvr }
342
+ * @returns {{ score: number, signals: object, flags: string[], profile: string }}
343
+ */
344
+ export function scoreJitter(stats) {
345
+ const profile = _cached ?? calibrateSync();
346
+
347
+ const cv = scoreSignal('cv', stats.cv);
348
+ const hurst = scoreSignal('hurst', stats.hurst ?? stats.H);
349
+ const ac = scoreSignal('ac', stats.ac1 ?? stats.a1);
350
+ const qe = scoreSignal('qe', stats.qe ?? stats.QE);
351
+ const uvr = scoreSignal('uvr', stats.uvr ?? stats.ur);
352
+
353
+ const flags = [cv, hurst, ac, qe, uvr]
354
+ .map(s => s.flag)
355
+ .filter(Boolean);
356
+
357
+ // Weighted fusion — same weights regardless of medium
358
+ const raw = cv.score * 0.20 +
359
+ hurst.score * 0.20 +
360
+ ac.score * 0.20 +
361
+ qe.score * 0.15 +
362
+ uvr.score * 0.25;
363
+
364
+ return {
365
+ score: +Math.min(0.99, Math.max(0.01, raw)).toFixed(4),
366
+ signals: { cv, hurst, ac, qe, uvr },
367
+ flags,
368
+ grain: profile.grain,
369
+ profile: profile.label,
370
+ };
371
+ }
372
+
373
+ // ─── Exports ─────────────────────────────────────────────────────────────────
374
+
375
+ export {
376
+ ENV,
377
+ PROFILES,
378
+ detectEnvironment,
379
+ probeTimerResolution,
380
+ hasHrtime,
381
+ };
382
+
383
+ /**
384
+ * @typedef {object} RefractionProfile
385
+ * @property {string} env - 'node' | 'browser' | 'worker' | 'deno' | 'bun' | 'unknown'
386
+ * @property {object} timer - { resolutionUs, minDeltaMs, medDeltaMs, uniqueRatio, grain, samples }
387
+ * @property {string} grain - 'nanosecond' | 'fine' | 'clamped' | 'coarse'
388
+ * @property {boolean} hrtime - true if process.hrtime.bigint() is available
389
+ * @property {object} thresholds - the active PROFILES[grain] threshold set
390
+ * @property {string} label - human-readable description
391
+ * @property {number} calibratedAt - Date.now() when calibration ran
392
+ */