UNPKG

@s4e/jsentinel

Version:

Script integrity and clipboard tamper detection for browsers.

377 lines (281 loc) 9.34 kB
## @s4e/jsentinel Lightweight browser sentinel for script integrity and clipboard tamper detection. Zero setup at runtime, event-driven, framework-agnostic. - Script integrity: hashes every `<script>` on the page (SHA‑256) and compares against a manifest you ship. - Clipboard tamper: after a copy event, warns if pasted clipboard text differs from the text the user selected. ### Install ```bash npm install @s4e/jsentinel # or yarn add @s4e/jsentinel # or pnpm add @s4e/jsentinel # or bun add @s4e/jsentinel ``` ### Quick start The fastest way is the auto entry, which safely no-ops on the server and only runs in the browser: ```ts import "@s4e/jsentinel/auto"; ``` Manual init (for custom options): ```ts import { initJSentinel, attachConsoleLogger, onJSentinel, } from "@s4e/jsentinel"; await initJSentinel({ manifestPath: "/manifest.json", watchClipboard: true, verboseChecks: false, }); // Optional: log to console const stopLogging = attachConsoleLogger(); // Example: react to mismatches const remove = onJSentinel("script.mismatch", (data) => { console.error("Script mismatch:", data); }); ``` ### Framework recipes All options below are safe for SSR. The `/auto` module checks `window`/`document` and only runs on the client. #### Next.js (Pages Router – `_app.tsx`) 1. Direct import (simple): ```tsx // pages/_app.tsx import type { AppProps } from "next/app"; import "@s4e/jsentinel/auto"; export default function MyApp({ Component, pageProps }: AppProps) { return <Component {...pageProps} />; } ``` 2. Client-only dynamic import (stricter SSR separation): ```tsx // pages/_app.tsx import type { AppProps } from "next/app"; import { useEffect } from "react"; export default function MyApp({ Component, pageProps }: AppProps) { useEffect(() => { import("@s4e/jsentinel/auto"); }, []); return <Component {...pageProps} />; } ``` #### Next.js (App Router – `app/`) Import through a Client Component provider: ```tsx // app/providers.tsx "use client"; import { useEffect } from "react"; export default function Providers({ children }: { children: React.ReactNode }) { useEffect(() => { import("@s4e/jsentinel/auto"); }, []); return <>{children}</>; } ``` ```tsx // app/layout.tsx import Providers from "./providers"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> ); } ``` #### React (Vite, CRA) ```tsx // src/main.tsx or src/index.tsx import "@s4e/jsentinel/auto"; // ... your root render ``` #### Vue 3 ```ts // src/main.ts import { createApp } from "vue"; import App from "./App.vue"; import "@s4e/jsentinel/auto"; createApp(App).mount("#app"); ``` #### Nuxt 3 Create a client-only plugin under `plugins/`: ```ts // plugins/jsentinel.client.ts export default defineNuxtPlugin(() => import("@s4e/jsentinel/auto")); ``` #### Angular ```ts // src/main.ts import "@s4e/jsentinel/auto"; // then bootstrapModule(...) ``` #### SvelteKit ```svelte <!-- src/routes/+layout.svelte --> <script> import { onMount } from "svelte"; onMount(() => { import("@s4e/jsentinel/auto"); }); }</script> <slot /> ``` #### Astro Add `auto` as a module script: ```astro --- // inside an .astro component --- <script type="module"> import "@s4e/jsentinel/auto"; // or dynamic import() // await import("@s4e/jsentinel/auto"); // (runs client-side only) </script> ``` #### Vanilla JS (ESM) ```js import "@s4e/jsentinel/auto"; ``` #### Classic script tag Copy `node_modules/@s4e/jsentinel/dist/auto.js` to your `public/` and add: ```html <script type="module" src="/auto.js"></script> ``` --- ## Manifest JSentinel reads expected script information from a JSON manifest (default path: `/manifest.json`). The schema is array-based to support same-origin resources, pinned CDNs and inline scripts. Example: ```json { "version": "2024-07-01T12:34:56.000Z", "scripts": [ { "src": "/assets/app.js", "type": "same-origin", "hash": "sha256-..." }, { "src": "/assets/vendor.js", "type": "same-origin", "hash": "sha256-..." }, { "inline": "console.log('hi')", "type": "inline-stable", "hash": "sha256-..." }, { "inline": "{\"@context\":\"https://schema.org\"}", "type": "inline-volatile" }, { "src": "https://cdn.example.com/pkg@1.2.3/dist/index.min.js", "type": "versioned-cdn" }, { "src": "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX", "type": "dynamic-loader", "host": "www.googletagmanager.com", "path": "/gtm.js", "query": "id=GTM-XXXX" } ] } ``` How matching works at runtime: - Same‑origin scripts: JSentinel fetches the script (no-store), computes SHA‑256 and compares to `hash`. It also tolerates keys by pathname, decoded pathname, full URL, and as a last resort by filename. - Inline scripts: contents are normalized (trim, EOLs, whitespace) before hashing. Entries marked `inline-stable` must match the provided `hash`. Entries marked `inline-volatile` are observed but mismatches are downgraded to warnings (reason: `volatile-inline`). - Cross‑origin scripts: if not HTTPS → warn (`insecure-http`). If HTTPS: accepted when either URL is version-pinned (`versioned-cdn`) or matches a dynamic pin (`dynamic-loader` with host/path/query). If CORS allows, JSentinel may also include a computed `got` hash in events. ### Generate a manifest (CLI) Use the companion CLI to generate the array-based manifest from your build output: ```bash npm i -D @s4e/jsentinel-cli # Examples jsentinel manifest .next/static public --base-url / jsentinel manifest --base-url / --inputs ".next/static,public" --output public/manifest.json jsentinel manifest dist --base-url /app/ --exclude "**/*.map,**/*.txt" ``` Options: - `--base-url <url>`: Base URL prefix for keys (default `/`). Next.js static is auto-mapped to `/_next/static/`. - `--inputs <csv>`: Comma-separated input directories. Positionals are also accepted. - `--output <file>`: Output file (default `public/manifest.json`). - `--exclude <csv>`: Comma-separated glob patterns to exclude. Recommended package.json script (in your app, not this library): ```json { "scripts": { "build": "next build && jsentinel manifest .next/static public --base-url /" } } ``` --- ## API and events All signals are dispatched through a single `CustomEvent("jsentinel")`. Helpers are provided for ergonomic listening. Exported API: - `initJSentinel(options?: JSentinelOptions): Promise<void>` - `onJSentinel<T extends JSentinelMessage["type"]>(type, handler)` - `onJSentinelAny(handler)` - `offJSentinel(listener)` - `attachConsoleLogger()` Event types: - `ready` - `script.ok` - `script.mismatch` (reason: `hash-different` | `manifest-missing-key` | `fetch-error` | `volatile-inline` | `url-pin-mismatch` | `insecure-http` | `unknown`) - `scan.complete` → `{ total, ok, mismatched }` - `clipboard.tamper` → `{ expected, got }` - `clipboard.permission` - `error` → `{ stage, message }` Listening examples: ```ts import { onJSentinel, onJSentinelAny, offJSentinel, attachConsoleLogger, } from "@s4e/jsentinel"; const stopVerbose = attachConsoleLogger(); const removeMismatch = onJSentinel("script.mismatch", (data) => { console.warn("Mismatch:", data); }); const removeAll = onJSentinelAny((msg) => { console.log("JSentinel:", msg); }); // Later offJSentinel(removeMismatch); offJSentinel(removeAll); offJSentinel(stopVerbose); ``` ### Options `initJSentinel(options?: JSentinelOptions)` ```ts type JSentinelOptions = { manifestPath?: string; // default: "/manifest.json" watchClipboard?: boolean; // default: true verboseChecks?: boolean; // if true, emits additional "script.checked" telemetry per script }; ``` The `/auto` entry calls `initJSentinel()` with defaults. --- ## SSR and permissions - The `/auto` entry can be imported in SSR environments; it guards for browser globals and only runs on the client. - Clipboard reading depends on browser permissions and user interaction. If disallowed, a `clipboard.permission` event is emitted. - To read cross-origin script contents, CORS must allow `fetch`. Without CORS, integrity checks for those scripts fallback to URL pin logic and still emit events. ## Browser support - Targets modern browsers that implement `crypto.subtle.digest` and the Clipboard API. - Older browsers may not be supported. ## FAQ - What if the manifest is 404? → An `error` event is emitted with `stage: "init"`. - Hash differs from expected? → `script.mismatch` with `reason: "hash-different"`. - Missing key in manifest? → `script.mismatch` with `reason: "manifest-missing-key"`. - How are inline scripts matched? → By hashing normalized contents; stable vs volatile classification comes from the manifest. ## TypeScript Types are bundled. Event payloads and helper functions are fully typed. ## Developing this package ```bash npm run build # Produces ESM outputs and .d.ts under dist/ ``` ## Security note This library does not replace browser-native defenses like CSP/SRI. It adds an observation layer. For best results, use together with a strict CSP, SRI, and a secure build/deploy pipeline. ## License MIT