@lincoln504/pi-research 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ import {
24
24
  MAX_SCRAPE_CONTENT_CHARS_PER_DOC,
25
25
  } from '../constants.ts';
26
26
  import { truncateWithMarker } from '../utils/text-utils.ts';
27
+ import { redactSecrets } from '../utils/log-utils.ts';
27
28
  import { type Config, getConfig } from '../config.ts';
28
29
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
29
30
  import { ServiceNames } from '../core/service-interfaces.ts';
@@ -261,16 +262,17 @@ export function createScrapeTool(options: {
261
262
  const concurrency = p.maxConcurrency || defaultConcurrency;
262
263
 
263
264
  try {
264
- const results = await scrape(urlsToFetch, concurrency, signal, options.config, getGlobalState().researchId, (result) => {
265
+ const results = await scrape(urlsToFetch, concurrency, signal, config, getGlobalState().researchId, (result) => {
265
266
  options.onUrlScrapeResult?.(result.url, result.success);
266
267
  }, container);
267
268
  freshResults = Array.isArray(results) ? results : [];
268
269
  } catch (error) {
269
- logger.error(`[scrape tool] Scrape failed: ${error instanceof Error ? error.message : String(error)}`);
270
+ const message = error instanceof Error ? error.message : String(error);
271
+ logger.error(`[scrape tool] Scrape failed: ${message}`);
270
272
  metrics.increment('tool_scrape_calls_total', 1, { status: 'error' });
271
273
  return {
272
- content: [{ type: 'text', text: `# Scrape Failed\n\n${error instanceof Error ? error.message : String(error)}` }],
273
- details: { error: String(error) },
274
+ content: [{ type: 'text', text: `# Scrape Failed\n\n${redactSecrets(message)}` }],
275
+ details: { error: redactSecrets(String(error)) },
274
276
  };
275
277
  }
276
278
 
@@ -13,6 +13,15 @@ import { getMaxGatheringCalls } from '../constants.ts';
13
13
  import { metrics } from '../utils/metrics.ts';
14
14
  import type { SecuritySearchParams } from '../security/types.ts';
15
15
 
16
+ const MAX_DESCRIPTION_CHARS = 300;
17
+
18
+ /** Truncates an advisory description for the markdown summary line. */
19
+ function truncateDescription(description: string): string {
20
+ return description.length > MAX_DESCRIPTION_CHARS
21
+ ? `${description.substring(0, MAX_DESCRIPTION_CHARS)}...`
22
+ : description;
23
+ }
24
+
16
25
  export function createSecuritySearchTool(options: {
17
26
  ctx: ExtensionContext;
18
27
  tracker: ToolUsageTracker;
@@ -229,7 +238,7 @@ export function createSecuritySearchTool(options: {
229
238
  }
230
239
  }
231
240
  const description = vuln.description;
232
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}\n`;
241
+ markdown += `- **Description:** ${truncateDescription(description)}\n`;
233
242
  if (vuln.knownExploited === true) {
234
243
  markdown += '- **[Actively Exploited]**\n';
235
244
  }
@@ -270,7 +279,7 @@ export function createSecuritySearchTool(options: {
270
279
  markdown += `- **Product:** ${vuln.product}\n`;
271
280
  }
272
281
  const description = vuln.description;
273
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}\n`;
282
+ markdown += `- **Description:** ${truncateDescription(description)}\n`;
274
283
  if (vuln.dueDate !== undefined) {
275
284
  markdown += `- **Due Date:** ${vuln.dueDate}\n`;
276
285
  }
@@ -310,7 +319,7 @@ export function createSecuritySearchTool(options: {
310
319
  }
311
320
  markdown += `- **Summary:** ${adv.summary}\n`;
312
321
  const description = adv.description ?? '';
313
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}\n`;
322
+ markdown += `- **Description:** ${truncateDescription(description)}\n`;
314
323
  if (adv.published) {
315
324
  markdown += `- **Published:** ${adv.published}\n`;
316
325
  }
@@ -344,7 +353,7 @@ export function createSecuritySearchTool(options: {
344
353
  markdown += `### ${vuln.id}\n`;
345
354
  markdown += `- **Severity:** ${vuln.severity}\n`;
346
355
  const description = vuln.description;
347
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}\n`;
356
+ markdown += `- **Description:** ${truncateDescription(description)}\n`;
348
357
  if (vuln.affectedProducts && vuln.affectedProducts.length > 0) {
349
358
  markdown += `- **Affected:** ${vuln.affectedProducts.join(', ')}\n`;
350
359
  }
@@ -10,6 +10,40 @@ export interface RotationLogger {
10
10
  log(...args: unknown[]): void;
11
11
  }
12
12
 
13
+ // Numeric equivalent of the 'a' flag string (O_WRONLY|O_APPEND|O_CREAT), with
14
+ // O_NOFOLLOW added when the platform defines it (not available on Windows) so
15
+ // the open refuses to follow a symlink an attacker with write access to the
16
+ // log directory could plant at this path in the window between the preceding
17
+ // unlink/rename and this reopen — defense-in-depth on top of an already-narrow
18
+ // threat model (it requires local write access to this directory to begin with).
19
+ const APPEND_CREATE_NOFOLLOW_FLAGS =
20
+ fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT |
21
+ (typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0);
22
+
23
+ /** Best-effort (re)create `filePath` with `mode`, refusing to follow a symlink
24
+ * planted at the path, and unconditionally stamp `mode` on it. Uses fchmodSync
25
+ * on the already-open (and O_NOFOLLOW-verified) descriptor rather than
26
+ * fs.chmodSync(filePath, ...) — chmodSync operates on the path and follows
27
+ * symlinks, which would silently re-open the exact hole O_NOFOLLOW closes if
28
+ * a symlink appears at this path between the open above and a path-based
29
+ * chmod. mode is only applied by open() when O_CREAT actually creates the
30
+ * file, so callers relying on the file always ending up at `mode` (even when
31
+ * a concurrent writer already recreated it) still need this explicit stamp.
32
+ * Failures are swallowed by design — see call sites.
33
+ * Exported for unit testing of the symlink-refusal behavior. */
34
+ export function touchOwnerOnly(filePath: string, mode: number): void {
35
+ let fd: number | undefined;
36
+ try {
37
+ fd = fs.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode);
38
+ fs.fchmodSync(fd, mode);
39
+ } catch { /* best-effort — the next append recreates the file */ }
40
+ finally {
41
+ if (fd !== undefined) {
42
+ try { fs.closeSync(fd); } catch { /* best-effort */ }
43
+ }
44
+ }
45
+ }
46
+
13
47
  export class LogRotation {
14
48
  private readonly MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB max file size
15
49
  private readonly MAX_LOG_FILES = 10; // Keep last 10 archived logs
@@ -29,15 +63,11 @@ export class LogRotation {
29
63
  fs.unlinkSync(logFile);
30
64
  // Same rationale as the post-rotation recreate in rotateLogFile: the
31
65
  // unlink discarded the 0o600 file, and a umask-default recreate by the
32
- // next append would drop the owner-only posture.
33
- try {
34
- fs.closeSync(fs.openSync(logFile, 'a', 0o600));
35
- } catch { /* best-effort — the next append recreates the file */ }
36
- // And, as there, re-stamp the mode: a concurrent append landing between
37
- // the unlink and the open makes the creation-time 0o600 a no-op.
38
- try {
39
- fs.chmodSync(logFile, 0o600);
40
- } catch { /* best-effort */ }
66
+ // next append would drop the owner-only posture. touchOwnerOnly stamps
67
+ // the mode unconditionally (via fchmod on its own fd), covering the
68
+ // case where a concurrent append already recreated the file at umask
69
+ // perms between the unlink and this call.
70
+ touchOwnerOnly(logFile, 0o600);
41
71
  }
42
72
 
43
73
  const files = fs.readdirSync(logDir);
@@ -92,20 +122,12 @@ export class LogRotation {
92
122
  // Rotation moved the 0o600 file aside; without an explicit recreate the next
93
123
  // append materializes it at the umask default (typically 0644) in a
94
124
  // world-traversable tmpdir, silently dropping the owner-only posture Logger
95
- // establishes at construction. Mode applies only at creation, so this is
96
- // harmless where a file already exists or mode semantics differ (win32).
97
- try {
98
- fs.closeSync(fs.openSync(logFile, 'a', 0o600));
99
- } catch { /* best-effort — the next append recreates the file */ }
100
- // The mode above applies only if THIS open created the file. Several
101
- // processes append to this consolidated log, so a concurrent async append
102
- // can recreate it at umask perms between the rename and the open, turning
103
- // the 0o600 into a no-op — stamp the mode unconditionally to close that
104
- // window for good. ENOENT (nothing recreated it and the open failed) is
125
+ // establishes at construction. touchOwnerOnly stamps the mode
126
+ // unconditionally (via fchmod on its own fd) regardless of whether this
127
+ // open created the file or a concurrent append already recreated it
128
+ // between the rename and this call — ENOENT (open itself failed) is
105
129
  // tolerable: best-effort posture, same as the recreate itself.
106
- try {
107
- fs.chmodSync(logFile, 0o600);
108
- } catch { /* best-effort */ }
130
+ touchOwnerOnly(logFile, 0o600);
109
131
 
110
132
 
111
133
  // Clean up old archives
@@ -133,6 +133,25 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
133
133
  * Applied to every message before it reaches the log file or the console, so
134
134
  * neither sink receives clear-text secrets or unbounded network data.
135
135
  */
136
+ // How much raw input the regex chain below is allowed to scan before a final
137
+ // truncation to MAX_LOG_MESSAGE_LENGTH. A multiple of MAX_LOG_MESSAGE_LENGTH,
138
+ // not 1:1, because most patterns below SHRINK text (a 64-char secret becomes
139
+ // "[REDACTED]"), so more than `keep` raw characters can be needed to fully
140
+ // populate the final truncated output. Nothing past this point can ever
141
+ // survive into the visible output regardless, so skipping it is free — this
142
+ // is what bounds redactSecrets() to O(MAX_LOG_MESSAGE_LENGTH) instead of
143
+ // O(input length) on multi-megabyte scraped payloads.
144
+ const REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
145
+
146
+ // Characters that can appear inside an unbroken secret token (hex, base64url,
147
+ // JWT segments, API keys). Used to push the REDACT_SCAN_LENGTH cut point past
148
+ // the end of whatever token it would otherwise land in the middle of.
149
+ const TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
150
+ // Hard cap on how far the boundary can be pushed past REDACT_SCAN_LENGTH —
151
+ // bounds the extra work to a fixed amount even against an adversarial run of
152
+ // token-like characters.
153
+ const REDACT_SCAN_EXTENSION_MAX = 2048;
154
+
136
155
  export function redactSecrets(message: string): string {
137
156
  // Redact BEFORE truncating. Every pattern below needs to see a secret in
138
157
  // full to match it (e.g. LONG_HEX_SECRET_PATTERN requires 32+ contiguous
@@ -140,7 +159,18 @@ export function redactSecrets(message: string): string {
140
159
  // whatever fragment survives the cut (e.g. the first 20 chars of a 64-hex
141
160
  // browser auth secret) too short for any pattern to recognize, so it was
142
161
  // written out in the clear right before the "…[truncated N chars]" marker.
143
- let out = message;
162
+ const rawLength = message.length;
163
+ // The REDACT_SCAN_LENGTH cut below is itself a truncation, subject to the
164
+ // exact same mid-token risk — extend it forward past any run of token
165
+ // characters straddling the cut point so it never slices a secret in half.
166
+ let scanEnd = REDACT_SCAN_LENGTH;
167
+ if (rawLength > scanEnd) {
168
+ const extensionLimit = Math.min(rawLength, scanEnd + REDACT_SCAN_EXTENSION_MAX);
169
+ while (scanEnd < extensionLimit && TOKEN_CHAR_PATTERN.test(message.charAt(scanEnd))) {
170
+ scanEnd += 1;
171
+ }
172
+ }
173
+ let out = rawLength > scanEnd ? message.slice(0, scanEnd) : message;
144
174
  out = out.replace(ANSI_PATTERN, '');
145
175
  out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
146
176
  // Redact whole-token formats BEFORE the key/value pass: the KV value class
@@ -172,7 +202,12 @@ export function redactSecrets(message: string): string {
172
202
  if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
173
203
  }
174
204
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
175
- out = `${out.slice(0, keep)}…[truncated ${out.length - keep} chars]`;
205
+ // rawLength can be SMALLER than out.length when redaction net-expands text
206
+ // (e.g. "key=1" → "key=[REDACTED]"), which would make `rawLength - keep`
207
+ // negative. out.length is always > keep in this branch, so the max() is
208
+ // never negative; it still prefers the larger rawLength when the
209
+ // REDACT_SCAN_LENGTH cut discarded raw content redaction then shrank.
210
+ out = `${out.slice(0, keep)}…[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
176
211
  }
177
212
  return out;
178
213
  }
@@ -188,8 +188,12 @@ export function formatDuration(ms: number): string {
188
188
  if (ms < 1000) return `${ms}ms`;
189
189
  const seconds = ms / 1000;
190
190
  if (seconds < 60) return `${seconds.toFixed(1)}s`;
191
- const minutes = Math.floor(seconds / 60);
192
- const remainingSeconds = Math.round(seconds % 60);
191
+ // Round the total first, then derive minutes/seconds from that integer —
192
+ // rounding remainingSeconds independently of minutes let e.g. 59.6s round up
193
+ // to a nonsensical "Xm 60s" instead of rolling over to the next minute.
194
+ const totalSeconds = Math.round(seconds);
195
+ const minutes = Math.floor(totalSeconds / 60);
196
+ const remainingSeconds = totalSeconds % 60;
193
197
  if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
194
198
  const hours = Math.floor(minutes / 60);
195
199
  const remainingMinutes = minutes % 60;
@@ -42,6 +42,7 @@
42
42
  */
43
43
 
44
44
  import { logger } from '../logger.ts';
45
+ import { safeUnref } from '../utils/safe-unref.ts';
45
46
  import { metrics } from '../utils/metrics.ts';
46
47
 
47
48
  /**
@@ -76,6 +77,22 @@ function describeError(error: unknown): string {
76
77
  // Module-level mutex: serialize all globalThis mutation across concurrent callers.
77
78
  let mintChain: Promise<unknown> = Promise.resolve();
78
79
 
80
+ // Set when a hard-release fires while its doMint() is still bridging globals —
81
+ // globalThis is a single process-global resource, so unlike a per-context lock
82
+ // ANY new bridge while one is still installed is unsafe, not just one from the
83
+ // same caller. Blocks new mints until the stuck call's own doMint() actually
84
+ // settles (success or failure) and unbridges for real.
85
+ let wedged = false;
86
+
87
+ // How long the mutex waits for a straggling doMint() before force-releasing
88
+ // the chain for the next caller. doMint() awaits non-fetch operations with no
89
+ // abort signal (interpreter eval, BotGuard VM execution against a
90
+ // hostile/malformed attestation script) that could in principle hang forever;
91
+ // without this bound, a single wedged call would permanently break YouTube
92
+ // transcript fetching for the rest of the process. Mirrors the hard-release
93
+ // timer on the browser page-creation mutex in thread-worker-messaging.ts.
94
+ const MINT_HARD_RELEASE_MS = 5 * 60 * 1000;
95
+
79
96
  /**
80
97
  * Mint a session-bound token (for `identifiers[0]`, the visitorData) plus one
81
98
  * content-bound token per remaining identifier (the video IDs), all from a
@@ -88,10 +105,39 @@ export async function mintPoTokens(
88
105
  identifiers: string[],
89
106
  opts: MintOptions = {},
90
107
  ): Promise<Map<string, string>> {
108
+ if (wedged) {
109
+ throw new Error('PoToken minter is wedged by a prior mint that never settled; refusing to start a new mint until it resolves.');
110
+ }
91
111
  // Chain on the mutex so only one bridged section runs at a time.
92
112
  const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
93
- // Keep the chain alive regardless of this call's success/failure.
94
- mintChain = run.then(() => undefined, () => undefined);
113
+ // Keep the chain alive regardless of this call's success/failure, but bound
114
+ // how long the NEXT caller waits on it: if `run` never settles, force the
115
+ // chain open after the hard timeout rather than wedging every future
116
+ // mintPoTokens() call behind it forever. Marking `wedged` (rather than
117
+ // letting the next caller straight through to its own doMint()) prevents
118
+ // two calls from bridging globalThis at once — the abandoned `run` may
119
+ // still be mid-bridge in the background, and a second bridge/unbridge pair
120
+ // racing it would restore the WRONG saved descriptors, permanently
121
+ // corrupting globalThis.window/document/navigator for the process.
122
+ //
123
+ // Derived from `settled` (which never rejects) rather than attaching
124
+ // directly to `run` — `run.finally(...)` would return a new, unconsumed
125
+ // promise that adopts `run`'s rejection, becoming an unhandled rejection
126
+ // whenever doMint() throws.
127
+ const settled = run.then(() => undefined, () => undefined);
128
+ // Once doMint() actually settles (success or failure), its own `finally`
129
+ // always runs unbridge() for real — safe for a new mint to bridge again.
130
+ settled.then(() => { wedged = false; });
131
+ let hardTimer: NodeJS.Timeout;
132
+ const hardRelease = new Promise<void>(resolve => {
133
+ hardTimer = setTimeout(() => {
134
+ logger.warn('[youtube] potoken mint mutex force-released — doMint() did not settle within the hard timeout');
135
+ wedged = true;
136
+ resolve();
137
+ }, MINT_HARD_RELEASE_MS);
138
+ safeUnref(hardTimer);
139
+ });
140
+ mintChain = Promise.race([settled, hardRelease]).finally(() => clearTimeout(hardTimer));
95
141
  return run;
96
142
  }
97
143
 
@@ -20,6 +20,7 @@
20
20
  import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-research/retry-utils.ts';
21
21
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
22
22
  import { logger } from '../logger.ts';
23
+ import { redactSecrets } from '../utils/log-utils.ts';
23
24
  import { metrics } from '../utils/metrics.ts';
24
25
  import { readTextCapped } from '../utils/http-body.ts';
25
26
  import { mintPoTokens } from './potoken.ts';
@@ -355,8 +356,10 @@ function parseJson3(body: string): string {
355
356
  // Log the real cause: an unparseable body is NOT necessarily an empty
356
357
  // bot-protection response (the caller's default diagnosis) — it can be an HTML
357
358
  // interstitial or a response-shape change. A short snippet disambiguates without
358
- // dumping a huge/again-sensitive body.
359
- logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${body.slice(0, 120)}`);
359
+ // dumping a huge/again-sensitive body. Redact BEFORE truncating to 120 chars —
360
+ // slicing first can cut a secret-shaped token mid-way, leaving a fragment too
361
+ // short for redactSecrets' whole-token patterns to recognize.
362
+ logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${redactSecrets(body).slice(0, 120)}`);
360
363
  return '';
361
364
  }
362
365
  const events = (parsed as { events?: unknown[] }).events;