@tindalabs/shield 0.1.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 (118) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +357 -0
  3. package/dist/assess.d.ts +16 -0
  4. package/dist/assess.js +220 -0
  5. package/dist/config/default-extensions-config.json +103 -0
  6. package/dist/core/ContentProtector.d.ts +63 -0
  7. package/dist/core/ContentProtector.js +281 -0
  8. package/dist/core/index.d.ts +1 -0
  9. package/dist/core/index.js +2 -0
  10. package/dist/core/mediator/ContentProtectionMediator.d.ts +86 -0
  11. package/dist/core/mediator/ContentProtectionMediator.js +238 -0
  12. package/dist/core/mediator/eventDataTypes.d.ts +112 -0
  13. package/dist/core/mediator/eventDataTypes.js +23 -0
  14. package/dist/core/mediator/handlers/abstractEventHandler.d.ts +41 -0
  15. package/dist/core/mediator/handlers/abstractEventHandler.js +59 -0
  16. package/dist/core/mediator/handlers/devToolsEventHandler.d.ts +9 -0
  17. package/dist/core/mediator/handlers/devToolsEventHandler.js +95 -0
  18. package/dist/core/mediator/handlers/eventHandlerRegistry.d.ts +9 -0
  19. package/dist/core/mediator/handlers/eventHandlerRegistry.js +34 -0
  20. package/dist/core/mediator/handlers/extensionEventHandlers.d.ts +40 -0
  21. package/dist/core/mediator/handlers/extensionEventHandlers.js +140 -0
  22. package/dist/core/mediator/handlers/iFrameEventHandlers.d.ts +27 -0
  23. package/dist/core/mediator/handlers/iFrameEventHandlers.js +93 -0
  24. package/dist/core/mediator/handlers/screenShotEventHandlers.d.ts +34 -0
  25. package/dist/core/mediator/handlers/screenShotEventHandlers.js +111 -0
  26. package/dist/core/mediator/protection-event.d.ts +77 -0
  27. package/dist/core/mediator/protection-event.js +32 -0
  28. package/dist/core/mediator/types.d.ts +105 -0
  29. package/dist/core/mediator/types.js +1 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.js +7 -0
  32. package/dist/otel.d.ts +24 -0
  33. package/dist/otel.js +83 -0
  34. package/dist/policy.d.ts +98 -0
  35. package/dist/policy.js +97 -0
  36. package/dist/strategies/AbstractStrategy.d.ts +124 -0
  37. package/dist/strategies/AbstractStrategy.js +256 -0
  38. package/dist/strategies/ClipboardStrategy.d.ts +67 -0
  39. package/dist/strategies/ClipboardStrategy.js +291 -0
  40. package/dist/strategies/ContextMenuStrategy.d.ts +60 -0
  41. package/dist/strategies/ContextMenuStrategy.js +454 -0
  42. package/dist/strategies/DevToolsStrategy.d.ts +55 -0
  43. package/dist/strategies/DevToolsStrategy.js +314 -0
  44. package/dist/strategies/ExtensionStrategy.d.ts +66 -0
  45. package/dist/strategies/ExtensionStrategy.js +486 -0
  46. package/dist/strategies/IFrameStrategy.d.ts +49 -0
  47. package/dist/strategies/IFrameStrategy.js +255 -0
  48. package/dist/strategies/KeyboardStrategy.d.ts +35 -0
  49. package/dist/strategies/KeyboardStrategy.js +130 -0
  50. package/dist/strategies/PrintStrategy.d.ts +47 -0
  51. package/dist/strategies/PrintStrategy.js +201 -0
  52. package/dist/strategies/ScreenshotStrategy.d.ts +90 -0
  53. package/dist/strategies/ScreenshotStrategy.js +502 -0
  54. package/dist/strategies/SelectionStrategy.d.ts +49 -0
  55. package/dist/strategies/SelectionStrategy.js +216 -0
  56. package/dist/strategies/WatermarkStrategy.d.ts +56 -0
  57. package/dist/strategies/WatermarkStrategy.js +287 -0
  58. package/dist/strategies/index.d.ts +10 -0
  59. package/dist/strategies/index.js +11 -0
  60. package/dist/types/assessment.d.ts +62 -0
  61. package/dist/types/assessment.js +1 -0
  62. package/dist/types/index.d.ts +278 -0
  63. package/dist/types/index.js +17 -0
  64. package/dist/utils/DOMObserver.d.ts +68 -0
  65. package/dist/utils/DOMObserver.js +134 -0
  66. package/dist/utils/base/LoggableComponent.d.ts +44 -0
  67. package/dist/utils/base/LoggableComponent.js +56 -0
  68. package/dist/utils/detectors/AbstractDevToolsDetector.d.ts +98 -0
  69. package/dist/utils/detectors/AbstractDevToolsDetector.js +127 -0
  70. package/dist/utils/detectors/dateToStringDetector.d.ts +43 -0
  71. package/dist/utils/detectors/dateToStringDetector.js +96 -0
  72. package/dist/utils/detectors/debugLibDetector.d.ts +64 -0
  73. package/dist/utils/detectors/debugLibDetector.js +195 -0
  74. package/dist/utils/detectors/debuggerDetector.d.ts +51 -0
  75. package/dist/utils/detectors/debuggerDetector.js +211 -0
  76. package/dist/utils/detectors/defineGetterDetector.d.ts +48 -0
  77. package/dist/utils/detectors/defineGetterDetector.js +150 -0
  78. package/dist/utils/detectors/detectorInterface.d.ts +36 -0
  79. package/dist/utils/detectors/detectorInterface.js +1 -0
  80. package/dist/utils/detectors/devToolsDetectorManager.d.ts +88 -0
  81. package/dist/utils/detectors/devToolsDetectorManager.js +243 -0
  82. package/dist/utils/detectors/funcToStringDetector.d.ts +43 -0
  83. package/dist/utils/detectors/funcToStringDetector.js +90 -0
  84. package/dist/utils/detectors/regToStringDetector.d.ts +43 -0
  85. package/dist/utils/detectors/regToStringDetector.js +129 -0
  86. package/dist/utils/detectors/sizeDetector.d.ts +54 -0
  87. package/dist/utils/detectors/sizeDetector.js +134 -0
  88. package/dist/utils/detectors/timingDetector.d.ts +55 -0
  89. package/dist/utils/detectors/timingDetector.js +143 -0
  90. package/dist/utils/dom.d.ts +20 -0
  91. package/dist/utils/dom.js +83 -0
  92. package/dist/utils/environment.d.ts +29 -0
  93. package/dist/utils/environment.js +267 -0
  94. package/dist/utils/eventManager.d.ts +162 -0
  95. package/dist/utils/eventManager.js +548 -0
  96. package/dist/utils/index.d.ts +2 -0
  97. package/dist/utils/index.js +3 -0
  98. package/dist/utils/intervalManager.d.ts +91 -0
  99. package/dist/utils/intervalManager.js +221 -0
  100. package/dist/utils/keyboardShortcutManager/keyboardShortcutManager.d.ts +41 -0
  101. package/dist/utils/keyboardShortcutManager/keyboardShortcutManager.js +135 -0
  102. package/dist/utils/keyboardShortcutManager/keyboardShortcuts.d.ts +18 -0
  103. package/dist/utils/keyboardShortcutManager/keyboardShortcuts.js +195 -0
  104. package/dist/utils/logging/simple/Loggable.d.ts +33 -0
  105. package/dist/utils/logging/simple/Loggable.js +1 -0
  106. package/dist/utils/logging/simple/LoggingDelegate.d.ts +42 -0
  107. package/dist/utils/logging/simple/LoggingDelegate.js +53 -0
  108. package/dist/utils/logging/simple/SimpleLoggingService.d.ts +39 -0
  109. package/dist/utils/logging/simple/SimpleLoggingService.js +58 -0
  110. package/dist/utils/orientation.d.ts +15 -0
  111. package/dist/utils/orientation.js +32 -0
  112. package/dist/utils/protectedContentManager.d.ts +155 -0
  113. package/dist/utils/protectedContentManager.js +424 -0
  114. package/dist/utils/securityOverlayManager.d.ts +253 -0
  115. package/dist/utils/securityOverlayManager.js +786 -0
  116. package/dist/utils/timeoutManager.d.ts +50 -0
  117. package/dist/utils/timeoutManager.js +113 -0
  118. package/package.json +61 -0
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Iker Laforga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # @tindalabs/shield
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@tindalabs/shield.svg)](https://www.npmjs.com/package/@tindalabs/shield)
4
+ [![CI](https://github.com/tindalabs/shield/actions/workflows/ci.yml/badge.svg)](https://github.com/tindalabs/shield/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Zero runtime dependencies](https://img.shields.io/badge/runtime%20deps-0-brightgreen.svg)](./package.json)
7
+
8
+ **Browser tamper detection for hostile environments.**
9
+
10
+ Shield detects DevTools, automation drivers, extension injection, and environment spoofing — surfaces findings as structured risk signals composable with [Blindspot](https://github.com/tindalabs/blindspot) spans and [Scent](https://github.com/tindalabs/scent) identity risk scoring.
11
+
12
+ ---
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @tindalabs/shield
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Quick start — `assess()`
23
+
24
+ The primary API is a single async call that returns structured signals, a risk summary, and OTel-compatible span attributes:
25
+
26
+ ```ts
27
+ import { assess } from '@tindalabs/shield';
28
+
29
+ const result = await assess();
30
+
31
+ console.log(result.signals);
32
+ // {
33
+ // 'shield.devtools.open': false,
34
+ // 'shield.automation.webdriver': false,
35
+ // 'shield.automation.headless': false,
36
+ // 'shield.frame.embedded': false,
37
+ // 'shield.extension.detected': false,
38
+ // 'shield.extension.names': '',
39
+ // }
40
+
41
+ console.log(result.risk);
42
+ // { score: 0, flags: [] }
43
+
44
+ // Attach to a Blindspot / OpenTelemetry span
45
+ span.setAttributes(result.spanAttributes);
46
+ ```
47
+
48
+ ### With Blindspot
49
+
50
+ ```ts
51
+ import { assess } from '@tindalabs/shield';
52
+ import { useSpan } from '@tindalabs/blindspot-react';
53
+
54
+ const { setAttribute } = useSpan();
55
+ const shield = await assess();
56
+ Object.entries(shield.spanAttributes).forEach(([k, v]) => setAttribute(k, v));
57
+ ```
58
+
59
+ ### With Scent
60
+
61
+ Shield's signals compose directly with Scent's identity and risk engine:
62
+
63
+ ```ts
64
+ import { assess } from '@tindalabs/shield';
65
+ import { init as initScent } from '@tindalabs/scent-sdk';
66
+
67
+ const scent = initScent({ apiKey: '...', endpoint: '...' });
68
+ const shield = await assess();
69
+
70
+ // shield.signals merges into the snapshot alongside browser fingerprint signals
71
+ const obs = await scent.observe({ extraSignals: shield.signals });
72
+ await scent.flush();
73
+ // The server risk engine now sees webdriver/headless/devtools signals
74
+ // alongside canvas, fonts, hardware and all other collected signals.
75
+ ```
76
+
77
+ ---
78
+
79
+ ## `assess(options?)` reference
80
+
81
+ | Option | Type | Default | Description |
82
+ |---|---|---|---|
83
+ | `devtools` | `boolean` | `true` | Run DevTools detection (async, timing-based) |
84
+ | `extensions` | `boolean` | `true` | Run browser extension DOM/JS scan |
85
+ | `timeout` | `number` | `400` | Max ms before async detections resolve with `false` |
86
+ | `extensionConfig` | `ExtensionConfig[]` | built-in | Extension signatures to check against |
87
+
88
+ ### `ShieldAssessment`
89
+
90
+ ```ts
91
+ interface ShieldAssessment {
92
+ signals: {
93
+ 'shield.devtools.open': boolean;
94
+ 'shield.automation.webdriver': boolean;
95
+ 'shield.automation.headless': boolean;
96
+ 'shield.frame.embedded': boolean;
97
+ 'shield.extension.detected': boolean;
98
+ 'shield.extension.names': string; // comma-separated
99
+ };
100
+ risk: {
101
+ score: number; // 0–1 normalised threat score
102
+ flags: string[]; // ['webdriver', 'devtools_open', ...]
103
+ };
104
+ spanAttributes: Record<string, string | boolean | number>;
105
+ }
106
+ ```
107
+
108
+ ### Risk flags and weights
109
+
110
+ | Flag | Score contribution | Triggered by |
111
+ |---|---|---|
112
+ | `webdriver` | 0.9 | `navigator.webdriver === true` |
113
+ | `headless` | 0.7 | Headless UA string, zero plugins, missing Permissions API |
114
+ | `devtools_open` | 0.4 | Timing/debugger/size detectors |
115
+ | `frame_embedded` | 0.3 | `window.self !== window.top` (cross-origin) |
116
+ | `extension` | 0.2 | DOM selector or JS global signature match |
117
+
118
+ ---
119
+
120
+ ## Risk-gated protection — `assessAndProtect()`
121
+
122
+ `assessAndProtect()` bridges both APIs with a declarative policy engine. It runs `assess()`, evaluates a set of rules against the result, and activates a `ContentProtector` with exactly the strategies each session warrants — zero overhead for legitimate users, full defence for automation and high-risk sessions.
123
+
124
+ ```ts
125
+ import { assessAndProtect } from '@tindalabs/shield';
126
+
127
+ const { assessment, protector } = await assessAndProtect(contentEl, {
128
+ policies: [
129
+ // Watermark any session with measurable risk — embed score for traceability
130
+ {
131
+ when: { riskScore: { gte: 0.2 } },
132
+ enable: ['enableWatermark'],
133
+ watermarkOptions: (a) => ({ text: `RISK-${Math.round(a.risk.score * 100)}` }),
134
+ },
135
+ // Selection + clipboard lockdown for high-risk sessions
136
+ {
137
+ when: { riskScore: { gte: 0.6 } },
138
+ enable: ['preventSelection', 'preventClipboard', 'preventKeyboardShortcuts'],
139
+ },
140
+ // Always block headless browsers regardless of score
141
+ {
142
+ when: { signals: { 'shield.automation.headless': true } },
143
+ enable: ['preventScreenshots', 'preventContextMenu'],
144
+ },
145
+ ],
146
+ });
147
+
148
+ // protector is null when no rules matched (legitimate session — no overhead)
149
+ if (protector) {
150
+ console.log('Protection active — score:', assessment.risk.score);
151
+ }
152
+ ```
153
+
154
+ All matched rules are merged: a session with score `0.8` triggers watermark + selection + clipboard + keyboard in one pass.
155
+
156
+ ### With OTel / Blindspot
157
+
158
+ Pass a `spanEmitter` to emit `shield.policy.triggered` events and wire `ContentProtector` callbacks to child spans:
159
+
160
+ ```ts
161
+ import { assessAndProtect } from '@tindalabs/shield';
162
+ import { getTracer, getRouteContext } from '@tindalabs/blindspot';
163
+
164
+ await assessAndProtect(contentEl, {
165
+ policies: [/* ... */],
166
+ spanEmitter: (name, attrs) => {
167
+ const span = getTracer().startSpan(name, { attributes: attrs }, getRouteContext());
168
+ span.end();
169
+ },
170
+ });
171
+ ```
172
+
173
+ ### `PolicyEngineOptions`
174
+
175
+ | Option | Type | Description |
176
+ |---|---|---|
177
+ | `policies` | `PolicyRule[]` | Ordered list of rules. All matching rules are merged. |
178
+ | `targetElement` | `HTMLElement \| null` | Element to protect. Defaults to `document.body`. |
179
+ | `customHandlers` | `CustomEventHandlers` | Forwarded to `ContentProtector`. |
180
+ | `spanEmitter` | `SpanEmitter` | Uses `attachShieldToSpan` and emits policy OTel events. |
181
+ | `assessOptions` | `AssessOptions` | Forwarded to the internal `assess()` call. |
182
+
183
+ ### `PolicyRule`
184
+
185
+ | Field | Type | Description |
186
+ |---|---|---|
187
+ | `when` | `PolicyCondition` | Conditions that must all match. An empty `{}` always matches. |
188
+ | `enable` | `StrategyKey[]` | Strategies to activate when the condition matches. |
189
+ | `watermarkOptions` | `WatermarkOptions \| (a: ShieldAssessment) => WatermarkOptions` | Static or factory watermark config. Last matched rule wins. |
190
+
191
+ ### `PolicyCondition`
192
+
193
+ | Field | Type | Description |
194
+ |---|---|---|
195
+ | `riskScore.gte` | `number` | Score must be ≥ this value. |
196
+ | `riskScore.lt` | `number` | Score must be < this value. |
197
+ | `signals` | `Partial<ShieldSignals>` | All listed signal values must match. |
198
+
199
+ ### OTel events emitted
200
+
201
+ | Event | When |
202
+ |---|---|
203
+ | `shield.policy.triggered` | At least one rule matched — includes `shield.policy.risk_score`, `shield.policy.matched_rules`, `shield.policy.enabled_strategies` |
204
+ | `shield.policy.evaluated` | No rules matched — includes `shield.policy.matched_rules: 0`, `shield.policy.protection_activated: false` |
205
+
206
+ ---
207
+
208
+ ## Use cases — adaptive content protection
209
+
210
+ `assessAndProtect()` exists for the awkward middle ground: blanket protection breaks the experience for legitimate users, but no protection lets scrapers walk away with everything. The policy engine activates only the strategies the session's risk profile warrants — humans see nothing, automation hits a wall.
211
+
212
+ ### Anti-AI scraping
213
+
214
+ LLM training crawlers, prompt-based scrapers, and headless research agents share the same signal profile as conventional automation: `shield.automation.webdriver`, `shield.automation.headless`, patched-API heuristics, missing plugin metadata. A single policy rule flips watermarking and selection/clipboard lockdown on those sessions while human visitors get the unmodified page.
215
+
216
+ The `watermarkOptions` factory receives the full assessment, so anything that *does* get scraped carries a forensic trace back to the session that extracted it:
217
+
218
+ ```ts
219
+ {
220
+ when: { signals: { 'shield.automation.headless': true } },
221
+ enable: ['enableWatermark', 'preventSelection', 'preventClipboard'],
222
+ watermarkOptions: (a) => ({
223
+ text: `SHIELD-${Math.round(a.risk.score * 100)}-${Date.now().toString(36)}`,
224
+ }),
225
+ }
226
+ ```
227
+
228
+ Pair with `spanEmitter` and every triggered rule emits `shield.policy.triggered` to your OTel pipeline — operators can see in real time *how often, and by what signal,* their content is being targeted, without instrumenting strategies by hand.
229
+
230
+ ### Risk-proportional DRM
231
+
232
+ Financial, legal, and media documents need strong protection — but blanket DRM breaks screen readers, accessibility tooling, and developers debugging their own portal. Risk-keyed policy rules flip protection on only when warranted (high risk, automation signals, specific extensions) and leave the long tail of normal sessions completely untouched. The same engine handles both ends of the risk spectrum with one config.
233
+
234
+ ### When *not* to reach for it
235
+
236
+ If every session needs the same protection (e.g. a known-private internal tool where any visitor is high-trust by definition), skip the engine and use `ContentProtector` directly (next section). `assessAndProtect()` adds an `assess()` round trip and is only worth it when protection should vary per session.
237
+
238
+ ---
239
+
240
+ ## Active protection — `ContentProtector`
241
+
242
+ Shield also exports the full protection suite for active content defense: blocks DevTools, prevents copy/print/selection, adds watermarks, detects extension injection and iframe embedding.
243
+
244
+ ```ts
245
+ import { ContentProtector } from '@tindalabs/shield';
246
+
247
+ const protector = new ContentProtector({
248
+ preventDevTools: true,
249
+ preventKeyboardShortcuts: true,
250
+ preventPrinting: true,
251
+ preventClipboard: true,
252
+ clipboardOptions: { preventCopy: true, preventCut: true, preventPaste: false },
253
+ enableWatermark: true,
254
+ watermarkOptions: { text: 'Confidential', userId: 'user-123' },
255
+ // …and more (selection, context menu, screenshots, extensions, iframe embedding)
256
+ // — see REFERENCE.md for the complete options table.
257
+ });
258
+
259
+ protector.protect();
260
+
261
+ // Later:
262
+ protector.unprotect();
263
+ protector.dispose();
264
+ ```
265
+
266
+ See [REFERENCE.md](./REFERENCE.md) for the full `ContentProtector` API and all strategy options.
267
+
268
+ ---
269
+
270
+ ## OTel-instrumented protection — `attachShieldToSpan()`
271
+
272
+ `attachShieldToSpan()` is a thin wrapper around `ContentProtector` that turns every protection event into a span event. Each blocked copy, print, keyboard shortcut, devtools open, or screenshot attempt becomes a `shield.*` event in your tracing pipeline — no manual callback wiring per strategy.
273
+
274
+ Shield is framework-agnostic about OTel — it doesn't depend on `@opentelemetry/api`. You provide a `SpanEmitter` callback; Shield calls it.
275
+
276
+ ```ts
277
+ import { attachShieldToSpan } from '@tindalabs/shield';
278
+
279
+ const protector = attachShieldToSpan(
280
+ { preventClipboard: true, enableWatermark: true, watermarkOptions: { text: 'Confidential' } },
281
+ (name, attrs) => span.addEvent(name, attrs),
282
+ );
283
+
284
+ protector.protect();
285
+ ```
286
+
287
+ ### With Blindspot
288
+
289
+ ```ts
290
+ import { attachShieldToSpan } from '@tindalabs/shield';
291
+ import { getTracer, getRouteContext } from '@tindalabs/blindspot';
292
+
293
+ const protector = attachShieldToSpan(
294
+ { preventClipboard: true, preventScreenshots: true },
295
+ (name, attrs) => {
296
+ const span = getTracer().startSpan(name, { attributes: attrs }, getRouteContext());
297
+ span.end();
298
+ },
299
+ );
300
+
301
+ protector.protect();
302
+ ```
303
+
304
+ Emitting each event as a short-lived span (start + immediately end) means findings reach Tempo/Honeycomb/Jaeger without waiting for the long-lived navigation span to close.
305
+
306
+ ### Events emitted
307
+
308
+ | Event name | Fires when | Attributes |
309
+ |---|---|---|
310
+ | `shield.devtools.opened` / `shield.devtools.closed` | DevTools state changes | — |
311
+ | `shield.selection.attempted` | User tries to select content | — |
312
+ | `shield.context_menu.attempted` | Right-click / long-press | — |
313
+ | `shield.print.attempted` | Print dialog opens | — |
314
+ | `shield.keyboard_shortcut.blocked` | Blocked shortcut fires | `shield.keyboard.key`, `shield.keyboard.code` |
315
+ | `shield.clipboard.copy` / `.cut` / `.paste` | Clipboard action attempted | — |
316
+ | `shield.screenshot.attempted` | PrintScreen / Win+Shift+S / Cmd+Shift+3/4/5 | — |
317
+ | `shield.extension.detected` | Known scraping extension found | `shield.extension.id`, `shield.extension.name`, `shield.extension.risk` |
318
+ | `shield.frame.embedding.detected` | Page rendered inside an iframe | `shield.frame.external` |
319
+ | `shield.protection.bypassed` | A protection strategy was circumvented | `shield.bypass.method` |
320
+ | `shield.content.hidden` / `.restored` | Protected content toggled | `shield.hidden.reason` (hidden only) |
321
+
322
+ Emitter exceptions are swallowed — telemetry sink failure never crashes the protected page. Any `customHandlers` you also pass in `options` still get called after the emit.
323
+
324
+ Pair with `assess()` for an end-to-end picture: route-level risk score plus per-interaction protection events, all keyed off the same span.
325
+
326
+ ---
327
+
328
+ ## Demo
329
+
330
+ A local demo app is included at [`demo/`](./demo). It exercises both APIs in a dark-themed single-page app:
331
+
332
+ - **Environment Assessment** — runs `assess()` and displays each signal, the risk score bar, and the raw OTel span attributes ready to copy.
333
+ - **Active Content Protection** — full `ContentProtector` controls: every strategy toggle, watermark options, live events log.
334
+
335
+ ```bash
336
+ cd demo
337
+ npm install
338
+ npm run dev # http://localhost:5175 (or next available port)
339
+ ```
340
+
341
+ ---
342
+
343
+ ## The Tindalabs stack
344
+
345
+ Shield is one of three composable browser-layer packages:
346
+
347
+ | Package | What it does |
348
+ |---|---|
349
+ | **[@tindalabs/blindspot](https://github.com/tindalabs/blindspot)** | Privacy-first OTel frontend observability |
350
+ | **[@tindalabs/shield](https://github.com/tindalabs/shield)** | Tamper detection & active content protection |
351
+ | **[@tindalabs/scent](https://github.com/tindalabs/scent)** | Probabilistic identity continuity |
352
+
353
+ ---
354
+
355
+ ## License
356
+
357
+ MIT © [Tindalabs](https://github.com/tindalabs)
@@ -0,0 +1,16 @@
1
+ import type { AssessOptions, ShieldAssessment } from './types/assessment.js';
2
+ /**
3
+ * Run a one-shot tamper-detection assessment and return structured signals,
4
+ * a risk summary, and OTel-compatible span attributes.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { assess } from '@tindalabs/shield';
9
+ *
10
+ * const result = await assess();
11
+ * if (result.risk.score > 0.5) {
12
+ * span.setAttributes(result.spanAttributes);
13
+ * }
14
+ * ```
15
+ */
16
+ export declare function assess(options?: AssessOptions): Promise<ShieldAssessment>;
package/dist/assess.js ADDED
@@ -0,0 +1,220 @@
1
+ import { isBrowser } from './utils/environment.js';
2
+ import { DevToolsDetectorManager } from './utils/detectors/devToolsDetectorManager.js';
3
+ // ── Headless detection ────────────────────────────────────────────────────────
4
+ function detectHeadless() {
5
+ if (!isBrowser())
6
+ return false;
7
+ // Headless Chrome/Chromium sets this
8
+ if (/HeadlessChrome|Headless/i.test(navigator.userAgent))
9
+ return true;
10
+ // Chrome with zero plugins is a strong headless signal
11
+ // (real browsers always have at least one built-in plugin)
12
+ if ('chrome' in window &&
13
+ navigator.plugins.length === 0 &&
14
+ !navigator.userAgent.includes('Mobile'))
15
+ return true;
16
+ // Permissions API is absent in many headless environments
17
+ if (!('permissions' in navigator))
18
+ return true;
19
+ return false;
20
+ }
21
+ // ── DevTools detection ────────────────────────────────────────────────────────
22
+ function detectDevTools(timeoutMs) {
23
+ // DebuggerDetector defers worker init by 100ms via setTimeout; calling
24
+ // checkDevTools() before that returns immediately (worker null). We wait
25
+ // 150ms so the worker is ready before triggering the actual check.
26
+ const WARMUP_MS = 150;
27
+ return new Promise((resolve) => {
28
+ let settled = false;
29
+ const settle = (value) => {
30
+ if (settled)
31
+ return;
32
+ settled = true;
33
+ manager.dispose();
34
+ resolve(value);
35
+ };
36
+ const manager = new DevToolsDetectorManager({
37
+ delayInitialCheck: false,
38
+ // Only settle true on open — the timeout below handles the closed case.
39
+ onDevToolsChange: (isOpen) => { if (isOpen)
40
+ settle(true); },
41
+ });
42
+ setTimeout(() => {
43
+ if (settled)
44
+ return;
45
+ manager.checkDevTools();
46
+ // If DevTools are open, the debugger timeout (50ms) fires the callback.
47
+ // If still no result after the remaining budget, DevTools are closed.
48
+ setTimeout(() => settle(false), timeoutMs - WARMUP_MS);
49
+ }, WARMUP_MS);
50
+ });
51
+ }
52
+ // ── Extension detection ───────────────────────────────────────────────────────
53
+ async function detectExtensions(config) {
54
+ if (!isBrowser() || !config?.length)
55
+ return { detected: false, names: [] };
56
+ const names = [];
57
+ for (const ext of config) {
58
+ let found = false;
59
+ // DOM selector checks
60
+ if (ext.detectionMethods?.domSelectors) {
61
+ for (const selector of ext.detectionMethods.domSelectors) {
62
+ try {
63
+ if (document.querySelector(selector)) {
64
+ found = true;
65
+ break;
66
+ }
67
+ }
68
+ catch {
69
+ // invalid selector — skip
70
+ }
71
+ }
72
+ }
73
+ // JS global signature checks
74
+ if (!found && ext.detectionMethods?.jsSignatures) {
75
+ for (const path of ext.detectionMethods.jsSignatures) {
76
+ try {
77
+ const parts = path.split('.');
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ let obj = window;
80
+ for (const part of parts) {
81
+ if (obj == null || !(part in obj)) {
82
+ obj = undefined;
83
+ break;
84
+ }
85
+ obj = obj[part];
86
+ }
87
+ if (obj !== undefined) {
88
+ found = true;
89
+ break;
90
+ }
91
+ }
92
+ catch {
93
+ // property access threw — skip
94
+ }
95
+ }
96
+ }
97
+ if (found)
98
+ names.push(ext.name);
99
+ }
100
+ return { detected: names.length > 0, names };
101
+ }
102
+ // ── Risk scoring ──────────────────────────────────────────────────────────────
103
+ const FLAG_WEIGHTS = {
104
+ webdriver: 0.9,
105
+ headless: 0.7,
106
+ devtools_open: 0.4,
107
+ frame_embedded: 0.3,
108
+ extension: 0.2,
109
+ };
110
+ function computeRisk(flags) {
111
+ const score = Math.min(1, flags.reduce((sum, f) => sum + (FLAG_WEIGHTS[f] ?? 0.2), 0));
112
+ return { score: Math.round(score * 1000) / 1000, flags };
113
+ }
114
+ // ── Default extension signatures ─────────────────────────────────────────────
115
+ // A minimal built-in set of high-risk extensions. Consumers can extend this
116
+ // by passing extensionConfig to assess().
117
+ const DEFAULT_EXTENSION_CONFIG = [
118
+ {
119
+ name: 'React DevTools',
120
+ description: 'React component inspector',
121
+ risk: 'low',
122
+ detectionMethods: { jsSignatures: ['__REACT_DEVTOOLS_GLOBAL_HOOK__'] },
123
+ },
124
+ {
125
+ name: 'Redux DevTools',
126
+ description: 'Redux state inspector',
127
+ risk: 'low',
128
+ detectionMethods: { jsSignatures: ['__REDUX_DEVTOOLS_EXTENSION__'] },
129
+ },
130
+ {
131
+ name: 'Tampermonkey',
132
+ description: 'Userscript manager',
133
+ risk: 'medium',
134
+ detectionMethods: {
135
+ jsSignatures: ['GM_info', 'unsafeWindow'],
136
+ domSelectors: ['[class*="tampermonkey"]'],
137
+ },
138
+ },
139
+ {
140
+ name: 'Greasemonkey',
141
+ description: 'Userscript manager',
142
+ risk: 'medium',
143
+ detectionMethods: { jsSignatures: ['GM', 'greasemonkey'] },
144
+ },
145
+ ];
146
+ // ── Public API ────────────────────────────────────────────────────────────────
147
+ /**
148
+ * Run a one-shot tamper-detection assessment and return structured signals,
149
+ * a risk summary, and OTel-compatible span attributes.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * import { assess } from '@tindalabs/shield';
154
+ *
155
+ * const result = await assess();
156
+ * if (result.risk.score > 0.5) {
157
+ * span.setAttributes(result.spanAttributes);
158
+ * }
159
+ * ```
160
+ */
161
+ export async function assess(options = {}) {
162
+ const { devtools: runDevtools = true, extensions: runExtensions = true, timeout = 400, extensionConfig = DEFAULT_EXTENSION_CONFIG, } = options;
163
+ // SSR / non-browser environments return a clean result immediately.
164
+ if (!isBrowser()) {
165
+ return {
166
+ signals: {
167
+ 'shield.devtools.open': false,
168
+ 'shield.automation.webdriver': false,
169
+ 'shield.automation.headless': false,
170
+ 'shield.frame.embedded': false,
171
+ 'shield.extension.detected': false,
172
+ 'shield.extension.names': '',
173
+ },
174
+ risk: { score: 0, flags: [] },
175
+ spanAttributes: {},
176
+ };
177
+ }
178
+ // Run all detections concurrently.
179
+ const [devtoolsOpen, extensionResult] = await Promise.all([
180
+ runDevtools ? detectDevTools(timeout) : Promise.resolve(false),
181
+ runExtensions ? detectExtensions(extensionConfig) : Promise.resolve({ detected: false, names: [] }),
182
+ ]);
183
+ const webdriver = Boolean(navigator.webdriver);
184
+ const headless = detectHeadless();
185
+ const frameEmbedded = (() => { try {
186
+ return window.self !== window.top;
187
+ }
188
+ catch {
189
+ return true;
190
+ } })();
191
+ const signals = {
192
+ 'shield.devtools.open': devtoolsOpen,
193
+ 'shield.automation.webdriver': webdriver,
194
+ 'shield.automation.headless': headless,
195
+ 'shield.frame.embedded': frameEmbedded,
196
+ 'shield.extension.detected': extensionResult.detected,
197
+ 'shield.extension.names': extensionResult.names.join(','),
198
+ };
199
+ const flags = [
200
+ webdriver ? 'webdriver' : null,
201
+ headless ? 'headless' : null,
202
+ devtoolsOpen ? 'devtools_open' : null,
203
+ frameEmbedded ? 'frame_embedded' : null,
204
+ extensionResult.detected ? 'extension' : null,
205
+ ].filter(Boolean);
206
+ const risk = computeRisk(flags);
207
+ // Only include non-default values in spanAttributes to keep spans lean.
208
+ const spanAttributes = {};
209
+ for (const [key, val] of Object.entries(signals)) {
210
+ if (val === true)
211
+ spanAttributes[key] = true;
212
+ if (typeof val === 'string' && val !== '')
213
+ spanAttributes[key] = val;
214
+ }
215
+ if (risk.score > 0) {
216
+ spanAttributes['shield.risk.score'] = risk.score;
217
+ spanAttributes['shield.risk.flags'] = risk.flags.join(',');
218
+ }
219
+ return { signals, risk, spanAttributes };
220
+ }