Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/src/__tests__/devtools-config-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { resolvePangularConfig } from '@pangular-inspector/devtools/internal/config';
import { describe, expect, it } from 'vitest';
import { withRedaction } from '../devtools-config';

describe('withRedaction', () => {
const config = resolvePangularConfig();

it('adds the redaction lists the server answers, with entropy only when it is on', () => {
expect(withRedaction(config, { secretNames: ['passport'], unmask: [] }).redaction).toEqual({
secretNames: ['passport'],
unmask: [],
});
expect(
withRedaction(config, { secretNames: [], unmask: [], entropy: { minLength: 32 } }).redaction,
).toEqual({ secretNames: [], unmask: [], entropy: { minLength: 32, threshold: 4.5 } });
expect(
withRedaction(config, { secretNames: [], unmask: [], entropy: 'on' }).redaction,
).not.toHaveProperty('entropy');
});

it('keeps the config for a missing or malformed answer', () => {
expect(withRedaction(config, null)).toBe(config);
expect(withRedaction(config, { secretNames: 'passport', unmask: [] })).toBe(config);
});
});
7 changes: 6 additions & 1 deletion app/src/devtools-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DevframeRpcClient } from 'devframe/client';
import {
actionBlockedMessage,
configFromConnection,
resolveEntropy,
type PangularAction,
type PangularInspector,
type ResolvedPangularConfig,
Expand Down Expand Up @@ -34,7 +35,11 @@ export function withRedaction(
const secretNames = names((answer as { secretNames?: unknown }).secretNames);
const unmask = names((answer as { unmask?: unknown }).unmask);
if (!secretNames || !unmask) return config;
return { ...config, redaction: { secretNames, unmask } };
const entropy = resolveEntropy((answer as { entropy?: unknown }).entropy);
return {
...config,
redaction: entropy ? { secretNames, unmask, entropy } : { secretNames, unmask },
};
}

export function actionAllowed(client: DevframeRpcClient | null, action: PangularAction): boolean {
Expand Down
61 changes: 42 additions & 19 deletions apps/docs/src/content/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ interface PangularConfig {
browserTools?: boolean;
};
actions?: boolean | Partial<Record<PangularAction, boolean>>;
redaction?: {secretNames?: string[]; unmask?: string[]};
redaction?: {
secretNames?: string[];
unmask?: string[];
entropy?: boolean | {minLength?: number; threshold?: number};
};
limits?: {
refreshMs?: number;
navigations?: number;
Expand Down Expand Up @@ -174,12 +178,30 @@ The server refuses a blocked action, and with `actions.components` the page refu

### Redaction

| Option | Default | What it does |
| ----------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `redaction.secretNames` | `[]` | Extra field names to treat as secret, on top of the built-in list. They match by words, like the built-in list, so `passport` also covers `passportNumber`. |
| `redaction.unmask` | `[]` | Field names to show even when they look secret. They join the `unmask` list of `window.__PANGULAR_FORMS__`. |
| Option | Default | What it does |
| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `redaction.secretNames` | `[]` | Extra field names to treat as secret, on top of the built-in list. They match by words, like the built-in list, so `passport` also covers `passportNumber`. |
| `redaction.unmask` | `[]` | Field names to show even when they look secret. They join the `unmask` list of `window.__PANGULAR_FORMS__`. |
| `redaction.entropy` | `false` | Also mask high-entropy strings that no name or shape rule catches, such as an opaque token in a harmless-looking field. Hex values and ULIDs stay readable. `true` turns it on with the defaults; an object turns it on and sets the options below. |

| `redaction.entropy` field | Default | What it sets |
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `minLength` | `24` | The shortest run that can be masked, a whole number from 16 to 512. Raise it if ids in your app are masked that shouldn't be. |
| `threshold` | `4.5` | The most bits per character a run needs, from 2 to 6. Shorter runs need less (see [High-entropy strings](../security.md#high-entropy-strings)). |

A field that is out of range takes its default, and the server reports the problem when it starts. To mask only longer runs:

```ts
// vite.config.ts
import pangular from '@pangular-inspector/devtools/vite';
import {defineConfig} from 'vite';

export default defineConfig({
plugins: [pangular({redaction: {entropy: {minLength: 32}}})],
});
```

Forms, the router, component inputs, signals, NgRx, pipes, Analog previews, SSR & HTTP URLs and Capacitor plugin calls use the extra secret names. Each list keeps up to 100 names. The server sends both lists to a page only after the page is trusted, so they are not in `__connection.json`. See [Access and redaction](../security.md#what-is-redacted) for what is redacted and what unmasking allows.
Forms, the router, component inputs, signals, NgRx, pipes, Analog previews, SSR & HTTP URLs and Capacitor plugin calls use the extra secret names. Each list keeps up to 100 names. `redaction.entropy` applies to the same text, on the page and again on the devtools server; [High-entropy strings](../security.md#high-entropy-strings) says what it masks. The server sends the `redaction` options to a page only after the page is trusted, so they are not in `__connection.json`. See [Access and redaction](../security.md#what-is-redacted) for what is redacted and what unmasking allows.

### Limits

Expand Down Expand Up @@ -330,19 +352,20 @@ A value that the server ignores falls back to its default, which is usually the

The [Dashboard](../inspectors/dashboard.md#configuration-block) has a **Configuration** block. It lists the options that differ from the defaults, or says **Defaults** when nothing is changed. The panel reads the `redaction` rows from the server once it is trusted, since `__connection.json` leaves them out. A static report has no server to ask, so it shows no **Extra secret names** or **Unmasked names** rows.

| Row | Lists |
| ------------------------------- | ------------------------------------------------- |
| **Inspectors off** | Inspectors set to `false`. |
| **Agent** | **Read-only** when `agent.readOnly` is `true`. |
| **Hidden from the agent** | Inspectors whose agent tools are hidden. |
| **Blocked actions** | Actions set to `false`. |
| **Extra secret names** | `redaction.secretNames`. |
| **Unmasked names** | `redaction.unmask`. |
| **Limits** | Limits that differ from the defaults. |
| **Popup shortcut** | `popup.hotkey`, or **Off** when it is `false`. |
| **Plugins off** | Plugins set to `false` in `plugins`. |
| **Plugin tools or actions off** | Plugins with `tools` or `actions` set to `false`. |
| **Lint rules off** | Rules set to `false` in `lint.rules`. |
| Row | Lists |
| ------------------------------- | -------------------------------------------------------------------------------------- |
| **Inspectors off** | Inspectors set to `false`. |
| **Agent** | **Read-only** when `agent.readOnly` is `true`. |
| **Hidden from the agent** | Inspectors whose agent tools are hidden. |
| **Blocked actions** | Actions set to `false`. |
| **Extra secret names** | `redaction.secretNames`. |
| **Unmasked names** | `redaction.unmask`. |
| **Entropy masking** | **On** when `redaction.entropy` is on, with the options that differ from the defaults. |
| **Limits** | Limits that differ from the defaults. |
| **Popup shortcut** | `popup.hotkey`, or **Off** when it is `false`. |
| **Plugins off** | Plugins set to `false` in `plugins`. |
| **Plugin tools or actions off** | Plugins with `tools` or `actions` set to `false`. |
| **Lint rules off** | Rules set to `false` in `lint.rules`. |

## Where to next

Expand Down
20 changes: 20 additions & 0 deletions apps/docs/src/content/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,26 @@ An email address in a URL is replaced with `[email]`, in a path segment, a query

An `@` that isn't part of an address stays, as in `/@angular/core`, `lodash@4.17.21` or `/users/@alice`. The user in a URL's authority, such as `git` in `ssh://git@github.com`, stays visible like the user before a URL password. Inside an encoded URL in a query value (`?next=https%3A%2F%2Fbob%40example.com`) there is no authority to tell apart, so the address is masked, host included. The router lint (`lint-routes` and the [Lint](./inspectors/router.md#lint) view) still reports the `email-in-url` rule for a navigation whose address was masked.

### High-entropy strings

Set `redaction.entropy` to `true` to also mask strings that look random, such as an opaque session token in a field with a harmless name or a cloud secret with no known prefix. It is off by default. An object such as `{minLength: 32, threshold: 4}` turns it on with other limits. See [Redaction options](./getting-started/configuration.md#redaction).

A run of letters, digits, `_`, `-`, `+` and `/` (and any trailing `=`) is replaced with `[redacted]` when all of these hold:

| Check | Why |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| It has `minLength` characters or more (24) | Short ids, words and numbers stay readable. |
| It mixes lowercase, uppercase and digits | Plain words, numbers and lowercase slugs such as `css-1a2b3c` stay readable. |
| It is not hex and not a ULID | Hashes, commit SHAs, UUIDs, Mongo ids and ULIDs stay readable. |
| It doesn't read as words | camelCase and snake_case identifiers, such as `getUserProfileById2024`, stay readable. |
| Its Shannon entropy is high enough | `log2(length) - 1` bits per character, at most `threshold` (4.5): 3.6 bits at 24 characters, 4 at 32, 4.5 from 45. |

About 99 in 100 random 24-character tokens pass these checks, and more of longer ones. A run with `/` is judged whole, as base64 is, unless one of its segments is a plain word or number, as in a path. Then each segment is judged on its own, so `/api/v2/Users/<token>/edit` keeps its path readable. Long base64 data, such as an inline image, is masked too.

The check runs last, after the name and shape rules, so a Stripe key still shows its `sk_live_` prefix. It covers every text the other rules cover: values, keys, messages, URLs, previews and headers in serialized values, NgRx state, forms, the router, SSR & HTTP calls, Analog reports and server logs, Capacitor calls and errors, on the page and again on the devtools server.

The check reads each text once, so its cost grows in step with the text, and a long text of 200 KB takes milliseconds to check.

### Router

These are replaced with `[redacted]` in URLs, params, data and messages:
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion extension/ui/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/devtools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN
- A **Terminals** dock that runs the project's build and test commands (opt in with `terminals: true`).
- Hub messages and toasts, a dock group with badges, and an **All tools** dock.
- New entry points: `/overlay-manual` (starts nothing on import), `/plugin`, `/capacitor-hook`, `/store-hook`, `/startup-hook`, `/ssr` and `/cd-data`.
- New config options: `agent.browserTools`, `limits.capacitorCalls`, `limits.idleAfterMs`, `popup` and `plugins`.
- New config options: `agent.browserTools`, `limits.capacitorCalls`, `limits.idleAfterMs`, `popup`, `plugins` and `redaction.entropy`.
- `redaction.entropy`, off by default, masks long high-entropy strings that no name or shape rule catches, such as an opaque token in a harmless field. `true` masks runs of 24 characters or more; `{minLength, threshold}` sets other limits. Hex values, UUIDs, ULIDs and camelCase identifiers stay readable, and known shapes keep their prefix. It applies on every redaction path, on the page and on the devtools server, and shows as **Entropy masking** on the Dashboard.
- After pairing, focus moves to the active tab with a **Connected** note.

#### Inspectors
Expand Down
Loading
Loading