Skip to content

tindalabs/shield

@tindalabs/shield

npm version CI License: MIT Zero runtime dependencies

Browser tamper detection for hostile environments.

Shield detects DevTools, automation drivers, extension injection, and environment spoofing — surfaces findings as structured risk signals composable with Blindspot spans and Scent identity risk scoring.


Install

npm install @tindalabs/shield

Which API do I want?

Shield ships three top-level APIs. Pick by what you want to do with the result:

Goal API What it does
Observe / score the session — log signals, gate a feature, feed a risk model assess() Detects DevTools / automation / extensions / frame embedding. Returns structured signals + risk.score (0–1) + OTel span attributes. No side effects.
Block devtools / copy / print / screenshots / clipboard / keyboard shortcuts on an element ContentProtector Manual control: pass an element + options, get an instance with dispose(). Always-on for everyone who hits the page.
Both — observe first, then activate protections only when the session warrants it assessAndProtect() Runs assess(), evaluates a declarative PolicyRule[] (e.g. "watermark when riskScore ≥ 0.3"), activates the matching strategies. Legitimate users see no overhead.

For OTel-instrumented protection (every block / detection becomes a span event), wrap any of the above with attachShieldToSpan().


Quick start — assess()

The primary API is a single async call that returns structured signals, a risk summary, and OTel-compatible span attributes:

import { assess } from '@tindalabs/shield';

const result = await assess();

console.log(result.signals);
// {
//   'shield.devtools.open': false,
//   'shield.automation.webdriver': false,
//   'shield.automation.headless': false,
//   'shield.frame.embedded': false,
//   'shield.extension.detected': false,
//   'shield.extension.names': '',
// }

console.log(result.risk);
// { score: 0, flags: [] }

// Attach to a Blindspot / OpenTelemetry span
span.setAttributes(result.spanAttributes);

With Blindspot

import { assess } from '@tindalabs/shield';
import { useSpan } from '@tindalabs/blindspot-react';

const { setAttribute } = useSpan();
const shield = await assess();
Object.entries(shield.spanAttributes).forEach(([k, v]) => setAttribute(k, v));

With Scent

Shield's signals compose directly with Scent's identity and risk engine:

import { assess } from '@tindalabs/shield';
import { init as initScent } from '@tindalabs/scent-sdk';

const scent = initScent({ apiKey: '...', endpoint: '...' });
const shield = await assess();

// shield.signals merges into the snapshot alongside browser fingerprint signals
const obs = await scent.observe({ extraSignals: shield.signals });
await scent.flush();
// The server risk engine now sees webdriver/headless/devtools signals
// alongside canvas, fonts, hardware and all other collected signals.

assess(options?) reference

Option Type Default Description
devtools boolean true Run DevTools detection (async, timing-based)
extensions boolean true Run browser extension DOM/JS scan
timeout number 400 Max ms before async detections resolve with false
extensionConfig ExtensionConfig[] built-in Extension signatures to check against

ShieldAssessment

interface ShieldAssessment {
  signals: {
    'shield.devtools.open': boolean;
    'shield.automation.webdriver': boolean;
    'shield.automation.headless': boolean;
    'shield.frame.embedded': boolean;
    'shield.extension.detected': boolean;
    'shield.extension.names': string; // comma-separated
  };
  risk: {
    score: number;   // 0–1 normalised threat score
    flags: string[]; // ['webdriver', 'devtools_open', ...]
  };
  spanAttributes: Record<string, string | boolean | number>;
}

Risk flags and weights

Flag Score contribution Triggered by
webdriver 0.9 navigator.webdriver === true
headless 0.7 Headless UA string, zero plugins, missing Permissions API
devtools_open 0.4 Timing/debugger/size detectors
frame_embedded 0.3 window.self !== window.top (cross-origin)
extension 0.2 DOM selector or JS global signature match

Risk-gated protection — assessAndProtect()

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.

import { assessAndProtect } from '@tindalabs/shield';

const { assessment, protector } = await assessAndProtect(contentEl, {
  policies: [
    // Watermark any session with measurable risk — embed score for traceability
    {
      when: { riskScore: { gte: 0.2 } },
      enable: ['enableWatermark'],
      watermarkOptions: (a) => ({ text: `RISK-${Math.round(a.risk.score * 100)}` }),
    },
    // Selection + clipboard lockdown for high-risk sessions
    {
      when: { riskScore: { gte: 0.6 } },
      enable: ['preventSelection', 'preventClipboard', 'preventKeyboardShortcuts'],
    },
    // Always block headless browsers regardless of score
    {
      when: { signals: { 'shield.automation.headless': true } },
      enable: ['preventScreenshots', 'preventContextMenu'],
    },
  ],
});

// protector is null when no rules matched (legitimate session — no overhead)
if (protector) {
  console.log('Protection active — score:', assessment.risk.score);
}

All matched rules are merged: a session with score 0.8 triggers watermark + selection + clipboard + keyboard in one pass.

With OTel / Blindspot

Pass a spanEmitter to emit shield.policy.triggered events and wire ContentProtector callbacks to child spans:

import { assessAndProtect } from '@tindalabs/shield';
import { getTracer, getRouteContext } from '@tindalabs/blindspot';

await assessAndProtect(contentEl, {
  policies: [/* ... */],
  spanEmitter: (name, attrs) => {
    const span = getTracer().startSpan(name, { attributes: attrs }, getRouteContext());
    span.end();
  },
});

PolicyEngineOptions

Option Type Description
policies PolicyRule[] Ordered list of rules. All matching rules are merged.
targetElement HTMLElement | null Element to protect. Defaults to document.body.
customHandlers CustomEventHandlers Forwarded to ContentProtector.
spanEmitter SpanEmitter Uses attachShieldToSpan and emits policy OTel events.
assessOptions AssessOptions Forwarded to the internal assess() call.

PolicyRule

Field Type Description
when PolicyCondition Conditions that must all match. An empty {} always matches.
enable StrategyKey[] Strategies to activate when the condition matches.
watermarkOptions WatermarkOptions | (a: ShieldAssessment) => WatermarkOptions Static or factory watermark config. Last matched rule wins.

PolicyCondition

Field Type Description
riskScore.gte number Score must be ≥ this value.
riskScore.lt number Score must be < this value.
signals Partial<ShieldSignals> All listed signal values must match.

OTel events emitted

Event When
shield.policy.triggered At least one rule matched — includes shield.policy.risk_score, shield.policy.matched_rules, shield.policy.enabled_strategies
shield.policy.evaluated No rules matched — includes shield.policy.matched_rules: 0, shield.policy.protection_activated: false

Use cases — adaptive content protection

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.

Anti-AI scraping

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.

The watermarkOptions factory receives the full assessment, so anything that does get scraped carries a forensic trace back to the session that extracted it:

{
  when: { signals: { 'shield.automation.headless': true } },
  enable: ['enableWatermark', 'preventSelection', 'preventClipboard'],
  watermarkOptions: (a) => ({
    text: `SHIELD-${Math.round(a.risk.score * 100)}-${Date.now().toString(36)}`,
  }),
}

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.

Risk-proportional DRM

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.

When not to reach for it

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.


Active protection — ContentProtector

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.

import { ContentProtector } from '@tindalabs/shield';

const protector = new ContentProtector({
  preventDevTools: true,
  preventKeyboardShortcuts: true,
  preventPrinting: true,
  preventClipboard: true,
  clipboardOptions: { preventCopy: true, preventCut: true, preventPaste: false },
  enableWatermark: true,
  watermarkOptions: { text: 'Confidential', userId: 'user-123' },
  // …and more (selection, context menu, screenshots, extensions, iframe embedding)
  // — see REFERENCE.md for the complete options table.
});

protector.protect();

// Later:
protector.unprotect();
protector.dispose();

See REFERENCE.md for the full ContentProtector API and all strategy options.


OTel-instrumented protection — attachShieldToSpan()

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.

Shield is framework-agnostic about OTel — it doesn't depend on @opentelemetry/api. You provide a SpanEmitter callback; Shield calls it.

import { attachShieldToSpan } from '@tindalabs/shield';

const protector = attachShieldToSpan(
  { preventClipboard: true, enableWatermark: true, watermarkOptions: { text: 'Confidential' } },
  (name, attrs) => span.addEvent(name, attrs),
);

protector.protect();

With Blindspot

import { attachShieldToSpan } from '@tindalabs/shield';
import { getTracer, getRouteContext } from '@tindalabs/blindspot';

const protector = attachShieldToSpan(
  { preventClipboard: true, preventScreenshots: true },
  (name, attrs) => {
    const span = getTracer().startSpan(name, { attributes: attrs }, getRouteContext());
    span.end();
  },
);

protector.protect();

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.

Events emitted

Event name Fires when Attributes
shield.devtools.opened / shield.devtools.closed DevTools state changes
shield.selection.attempted User tries to select content
shield.context_menu.attempted Right-click / long-press
shield.print.attempted Print dialog opens
shield.keyboard_shortcut.blocked Blocked shortcut fires shield.keyboard.key, shield.keyboard.code
shield.clipboard.copy / .cut / .paste Clipboard action attempted
shield.screenshot.attempted PrintScreen / Win+Shift+S / Cmd+Shift+3/4/5
shield.extension.detected Known scraping extension found shield.extension.id, shield.extension.name, shield.extension.risk
shield.frame.embedding.detected Page rendered inside an iframe shield.frame.external
shield.protection.bypassed A protection strategy was circumvented shield.bypass.method
shield.content.hidden / .restored Protected content toggled shield.hidden.reason (hidden only)

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.

Pair with assess() for an end-to-end picture: route-level risk score plus per-interaction protection events, all keyed off the same span.


Demo

A local demo app is included at demo/. It exercises both APIs in a dark-themed single-page app:

  • Environment Assessment — runs assess() and displays each signal, the risk score bar, and the raw OTel span attributes ready to copy.
  • Active Content Protection — full ContentProtector controls: every strategy toggle, watermark options, live events log.
cd demo
npm install
npm run dev   # http://localhost:5175 (or next available port)

The Tindalabs stack

Shield is one of three composable browser-layer packages:

Package What it does
@tindalabs/blindspot Privacy-first OTel frontend observability
@tindalabs/shield Tamper detection & active content protection
@tindalabs/scent Probabilistic identity continuity

License

MIT © Tindalabs

About

Browser tamper detection for hostile environments. Detects and blocks DevTools, automation drivers, extension injection and environment spoofing. Surfaces findings as structured risk signals, not just boolean flags.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors