@s4e/jsentinel
Version:
Script integrity and clipboard tamper detection for browsers.
377 lines (281 loc) • 9.34 kB
Markdown
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);
});
```
All options below are safe for SSR. The `/auto` module checks `window`/`document` and only runs on the client.
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} />;
}
```
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>
);
}
```
```tsx
// src/main.tsx or src/index.tsx
import "@s4e/jsentinel/auto";
// ... your root render
```
```ts
// src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import "@s4e/jsentinel/auto";
createApp(App).mount("#app");
```
Create a client-only plugin under `plugins/`:
```ts
// plugins/jsentinel.client.ts
export default defineNuxtPlugin(() => import("@s4e/jsentinel/auto"));
```
```ts
// src/main.ts
import "@s4e/jsentinel/auto";
// then bootstrapModule(...)
```
```svelte
<!-- src/routes/+layout.svelte -->
<script>
import { onMount } from "svelte";
onMount(() => {
import("@s4e/jsentinel/auto");
});
}</script>
<slot />
```
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>
```
```js
import "@s4e/jsentinel/auto";
```
Copy `node_modules/@s4e/jsentinel/dist/auto.js` to your `public/` and add:
```html
<script type="module" src="/auto.js"></script>
```
---
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 /"
}
}
```
---
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);
```
`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.
---
- 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.
- Targets modern browsers that implement `crypto.subtle.digest` and the Clipboard API.
- Older browsers may not be supported.
- 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.
Types are bundled. Event payloads and helper functions are fully typed.
```bash
npm run build
```
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.
MIT