Thanks to visit codestin.com
Credit goes to github.com

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
100 changes: 100 additions & 0 deletions extension/src/lib/inline-text-redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

// Factory for rules that scan text nodes and replace matched ranges in
// place with click-to-reveal inline placeholders. Used by `pii-redact`,
// `secrets-redact`, and `encoded-payload-redact`, which previously
// duplicated the same lifecycle scaffolding around a one-line difference
// in `collectMatches`.
//
// Lifecycle the factory owns:
// - One `ReusableAbortController` whose signal is threaded into every
// chunked text walk. Route changes call `abortAndReset` to cancel
// in-flight scans against the old tree; teardown does the same.
// - One lazy `subscribeRouteChange` registration created on first
// `apply`. Teardown unsubscribes and clears the slot so a later
// `apply` re-registers cleanly.
// - One `createSubtreeWatcher` with `skipPlaceholderSubtrees: true`
// so the rule's own inserted placeholders don't re-trigger it.
//
// Incremental subtree-watcher batches deliberately do NOT abort on
// route change — they target their own scoped root and don't compete
// with the previous scan.

import { ReusableAbortController } from "abort-utils";
import type { Rule } from "../rules/types";
import type { InlineMatch } from "./placeholder";
import { replaceMatchesInTextNode } from "./placeholder";
import { subscribeRouteChange } from "./route-change";
import type { RuleId } from "./storage";
import { createSubtreeWatcher } from "./subtree-watcher";
import { walkTextNodesChunked } from "./yielding-text-walk";

export interface InlineTextRedactRuleOptions {
id: RuleId;
label: string;
description: string;
// Skip text nodes shorter than this — cheap per-node early-out before
// the regex pass runs. Set to the shortest pattern the rule can match.
minLength: number;
// Pure function: given a text node's value, return any inline matches
// (already merged / sorted as the rule wants them applied).
collectMatches: (text: string) => InlineMatch[];
}

// Tighter than `Rule` — the factory always installs a teardown, so callers
// (notably tests that call `rule.teardown()` directly in `afterEach`) don't
// need to widen with `?.()` or assertion.
export type InlineTextRedactRule = Rule & { teardown: () => void };

export function defineInlineTextRedactRule(
options: InlineTextRedactRuleOptions,
): InlineTextRedactRule {
const { id, label, description, minLength, collectMatches } = options;

const lifecycle = new ReusableAbortController();
let unsubscribeRouteChange: (() => void) | null = null;

function scanAndMask(root: ParentNode): void {
walkTextNodesChunked(root, {
signal: lifecycle.signal,
minLength,
process: (chunk) => {
for (const node of chunk) {
const matches = collectMatches(node.nodeValue ?? "");
if (matches.length > 0) {
replaceMatchesInTextNode(node, matches, id);
}
}
},
});
}

const watcher = createSubtreeWatcher({
skipPlaceholderSubtrees: true,
onSubtrees: (roots) => {
for (const root of roots) {
scanAndMask(root);
}
},
});

return {
id,
label,
description,
apply(root: ParentNode): void {
unsubscribeRouteChange ??= subscribeRouteChange(() => {
lifecycle.abortAndReset();
});
scanAndMask(root);
watcher.start(root);
},
teardown(): void {
watcher.stop();
lifecycle.abortAndReset();
unsubscribeRouteChange?.();
unsubscribeRouteChange = null;
},
} satisfies InlineTextRedactRule;
}
61 changes: 6 additions & 55 deletions extension/src/rules/encoded-payload-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,8 @@
// Matches are replaced inline with a click-to-reveal placeholder. False
// positives cost one click, not lost data.

import { ReusableAbortController } from "abort-utils";
import { defineInlineTextRedactRule } from "../lib/inline-text-redact";
import type { InlineMatch } from "../lib/placeholder";
import { replaceMatchesInTextNode } from "../lib/placeholder";
import { subscribeRouteChange } from "../lib/route-change";
import { createSubtreeWatcher } from "../lib/subtree-watcher";
import { walkTextNodesChunked } from "../lib/yielding-text-walk";
import type { Rule } from "./types";

const RULE_ID = "encoded-payload-redact" as const;

// Length floors per encoding. Tuned to sit above common hash/fingerprint
// sizes (SHA-512 hex = 128, so 160 leaves headroom) and below typical
Expand Down Expand Up @@ -298,53 +291,11 @@ function collectMatches(text: string): InlineMatch[] {
return merged;
}

// See pii-redact for lifecycle rationale.
const lifecycle = new ReusableAbortController();
let unsubscribeRouteChange: (() => void) | null = null;

function scanAndMask(root: ParentNode): void {
const signal = lifecycle.signal;
walkTextNodesChunked(root, {
signal,
minLength: MIN_TEXT_LENGTH,
process: (chunk) => {
for (const node of chunk) {
const matches = collectMatches(node.nodeValue ?? "");
if (matches.length > 0) {
replaceMatchesInTextNode(node, matches, RULE_ID);
}
}
},
});
}

const watcher = createSubtreeWatcher({
skipPlaceholderSubtrees: true,
onSubtrees: (roots) => {
for (const root of roots) {
scanAndMask(root);
}
},
});

function apply(root: ParentNode): void {
unsubscribeRouteChange ??= subscribeRouteChange(() => {
lifecycle.abortAndReset();
});
scanAndMask(root);
watcher.start(root);
}

export const encodedPayloadRedactRule = {
id: RULE_ID,
export const encodedPayloadRedactRule = defineInlineTextRedactRule({
id: "encoded-payload-redact",
label: "Redact Encoded Payloads",
description:
"Redact long base64, hex, or percent-encoded runs in text nodes whose decoded bytes are mostly readable text. Defends against the 'decode this and follow it' indirect-injection carrier; hashes, fingerprints, and binary blobs are left alone.",
apply,
teardown: () => {
watcher.stop();
lifecycle.abortAndReset();
unsubscribeRouteChange?.();
unsubscribeRouteChange = null;
},
} satisfies Rule;
minLength: MIN_TEXT_LENGTH,
collectMatches,
});
65 changes: 6 additions & 59 deletions extension/src/rules/pii-redact.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

import { ReusableAbortController } from "abort-utils";
import { defineInlineTextRedactRule } from "../lib/inline-text-redact";
import type { InlineMatch } from "../lib/placeholder";
import { replaceMatchesInTextNode } from "../lib/placeholder";
import { subscribeRouteChange } from "../lib/route-change";
import { createSubtreeWatcher } from "../lib/subtree-watcher";
import { walkTextNodesChunked } from "../lib/yielding-text-walk";
import type { Rule } from "./types";

const RULE_ID = "pii-redact" as const;
const MIN_TEXT_LENGTH = 9; // shortest pattern is a hyphenated 9-digit SSN.

const CC_PATTERN = /\b(?:\d[ -]?){12,18}\d\b/g;
Expand Down Expand Up @@ -78,57 +72,10 @@ function collectMatches(text: string): InlineMatch[] {
return merged;
}

// Lifecycle controller covers every async scan kicked off by apply
// and the watcher. abortAndReset on route change cancels any in-flight
// chunked walk so a scan started against the old tree can't keep
// mutating the new one; abortAndReset on teardown stops everything.
// Incremental subtree-watcher batches do NOT abort — they target their
// own scoped root and don't conflict with the previous scan.
const lifecycle = new ReusableAbortController();
let unsubscribeRouteChange: (() => void) | null = null;

function scanAndMask(root: ParentNode): void {
const signal = lifecycle.signal;
walkTextNodesChunked(root, {
signal,
minLength: MIN_TEXT_LENGTH,
process: (chunk) => {
for (const node of chunk) {
const matches = collectMatches(node.nodeValue ?? "");
if (matches.length > 0) {
replaceMatchesInTextNode(node, matches, RULE_ID);
}
}
},
});
}

const watcher = createSubtreeWatcher({
skipPlaceholderSubtrees: true,
onSubtrees: (roots) => {
for (const root of roots) {
scanAndMask(root);
}
},
});

function apply(root: ParentNode): void {
unsubscribeRouteChange ??= subscribeRouteChange(() => {
lifecycle.abortAndReset();
});
scanAndMask(root);
watcher.start(root);
}

export const piiRedactRule = {
id: RULE_ID,
export const piiRedactRule = defineInlineTextRedactRule({
id: "pii-redact",
label: "Mask PII",
description: "Hide credit card numbers, phone numbers, and SSNs.",
apply,
teardown: () => {
watcher.stop();
lifecycle.abortAndReset();
unsubscribeRouteChange?.();
unsubscribeRouteChange = null;
},
} satisfies Rule;
minLength: MIN_TEXT_LENGTH,
collectMatches,
});
62 changes: 6 additions & 56 deletions extension/src/rules/secrets-redact.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

import { ReusableAbortController } from "abort-utils";
import { defineInlineTextRedactRule } from "../lib/inline-text-redact";
import type { InlineMatch } from "../lib/placeholder";
import { replaceMatchesInTextNode } from "../lib/placeholder";
import { subscribeRouteChange } from "../lib/route-change";
import { createSubtreeWatcher } from "../lib/subtree-watcher";
import { walkTextNodesChunked } from "../lib/yielding-text-walk";
import type { Rule } from "./types";

const RULE_ID = "secrets-redact" as const;
// Shortest provider-prefixed pattern (npm_<32>) is 36 chars; cap below that
// to skip prose nodes early without losing real candidates.
const MIN_TEXT_LENGTH = 16;
Expand Down Expand Up @@ -149,55 +143,11 @@ function collectMatches(text: string): InlineMatch[] {
return merged;
}

// See pii-redact for lifecycle rationale — same pattern: route-change
// aborts in-flight chunked walks; incremental subtree-watcher batches
// do not.
const lifecycle = new ReusableAbortController();
let unsubscribeRouteChange: (() => void) | null = null;

function scanAndMask(root: ParentNode): void {
const signal = lifecycle.signal;
walkTextNodesChunked(root, {
signal,
minLength: MIN_TEXT_LENGTH,
process: (chunk) => {
for (const node of chunk) {
const matches = collectMatches(node.nodeValue ?? "");
if (matches.length > 0) {
replaceMatchesInTextNode(node, matches, RULE_ID);
}
}
},
});
}

const watcher = createSubtreeWatcher({
skipPlaceholderSubtrees: true,
onSubtrees: (roots) => {
for (const root of roots) {
scanAndMask(root);
}
},
});

function apply(root: ParentNode): void {
unsubscribeRouteChange ??= subscribeRouteChange(() => {
lifecycle.abortAndReset();
});
scanAndMask(root);
watcher.start(root);
}

export const secretsRedactRule = {
id: RULE_ID,
export const secretsRedactRule = defineInlineTextRedactRule({
id: "secrets-redact",
label: "Mask Secrets",
description:
"Hide API keys, tokens, JWTs, private keys, and other high-entropy credentials.",
apply,
teardown: () => {
watcher.stop();
lifecycle.abortAndReset();
unsubscribeRouteChange?.();
unsubscribeRouteChange = null;
},
} satisfies Rule;
minLength: MIN_TEXT_LENGTH,
collectMatches,
});
Loading