Files
orca/config/scripts/diagnostics/terminal-transcripts/pty-transcript-secret-scan.mjs
Neil 6bb2e13c35 refactor(tooling): organize remaining scripts by domain owners (#38407)
* refactor(tooling): group remaining scripts by concrete domain owners

* fix(tooling): preserve relocated launcher resource roots
2026-11-05 00:29:16 -08:00

131 lines
4.7 KiB
JavaScript

// Finds account identifiers and credentials in a captured PTY transcript before it is committed.
import os from 'node:os'
// Preserve span length: shorter redaction reflows captured wrapping/columns and destroys evidence.
const EMAIL_DOMAIN = '@example.com'
const PLACEHOLDER_UUID = '00000000-0000-4000-8000-000000000000'
/** Ordered most-specific first; the first pattern to claim a span owns it. */
function buildPatterns() {
const username = os.userInfo().username
const hostname = os.hostname()
const patterns = [
{ kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g },
{ kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{20,}/g },
{ kind: 'google-refresh-token', re: /\b1\/\/[0-9A-Za-z_-]{20,}/g },
{ kind: 'vendor-key', re: /\b(?:sk-|ghp_|gho_|github_pat_|xoxb-|xoxp-)[A-Za-z0-9_-]{16,}/g },
{ kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi },
{ kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
// UUIDs can identify resumable agy sessions, projects or installations, so redact operator identifiers too.
{ kind: 'uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi },
{ kind: 'opaque-token', re: /\b[A-Za-z0-9_-]{40,}\b/g }
]
if (username.length >= 3) {
patterns.splice(5, 0, { kind: 'local-username', re: literalPattern(username) })
}
if (hostname.length >= 3) {
patterns.splice(5, 0, { kind: 'local-hostname', re: literalPattern(hostname) })
}
return patterns
}
function literalPattern(value) {
return new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
}
/**
* @param {string} text raw transcript, escapes intact
* @returns {{kind: string, line: number, column: number, index: number, match: string}[]}
*/
export function scanTranscriptForSecrets(text) {
const claimed = []
const findings = []
for (const { kind, re } of buildPatterns()) {
re.lastIndex = 0
let match = re.exec(text)
while (match !== null) {
const start = match.index
const end = start + match[0].length
if (!claimed.some(([from, to]) => start < to && end > from)) {
claimed.push([start, end])
if (!isAlreadyScrubbed(kind, match[0])) {
findings.push({ kind, index: start, match: match[0], ...locate(text, start) })
}
}
match = re.exec(text)
}
}
return findings.sort((left, right) => left.index - right.index)
}
// Recognize own placeholders so scrubbed fixtures can pass clean verification.
const PLACEHOLDER_DOMAIN_RE = /@(?:example\.(?:com|org|net)|localhost)$/i
function isAlreadyScrubbed(kind, match) {
if (kind === 'email') {
return PLACEHOLDER_DOMAIN_RE.test(match)
}
if (kind === 'uuid') {
return match.toLowerCase() === PLACEHOLDER_UUID
}
return /^(.)\1*$/.test(match)
}
function locate(text, index) {
let line = 1
let lineStart = 0
for (let cursor = 0; cursor < index; cursor += 1) {
if (text.charCodeAt(cursor) === 10) {
line += 1
lineStart = cursor + 1
}
}
return { line, column: index - lineStart + 1 }
}
/** Same-length stand-in so redaction cannot reflow the captured screen. */
export function placeholderFor(kind, length) {
if (kind === 'uuid' && length === PLACEHOLDER_UUID.length) {
return PLACEHOLDER_UUID
}
if (kind === 'email' && length > EMAIL_DOMAIN.length) {
return 'u'.repeat(length - EMAIL_DOMAIN.length) + EMAIL_DOMAIN
}
return kind === 'local-username' || kind === 'local-hostname'
? 'x'.repeat(length)
: 'X'.repeat(length)
}
/** @returns {{text: string, redacted: number}} */
export function redactTranscript(text) {
const findings = scanTranscriptForSecrets(text)
let out = ''
let cursor = 0
for (const finding of findings) {
out += text.slice(cursor, finding.index)
out += placeholderFor(finding.kind, finding.match.length)
cursor = finding.index + finding.match.length
}
return { text: out + text.slice(cursor), redacted: findings.length }
}
export function formatFindings(label, findings) {
if (findings.length === 0) {
return `${label}: clean — no account identifier or credential shapes found.`
}
const rows = findings.map(
(finding) => ` ${finding.line}:${finding.column} ${finding.kind} ${preview(finding.match)}`
)
return [`${label}: ${findings.length} finding(s) — scrub before committing.`, ...rows].join('\n')
}
// Use codepoints: formatter turns escaped control-byte classes into raw bytes, making this transcript gate binary and unreviewable in VCS.
function preview(value) {
const head = value.length <= 24 ? value : `${value.slice(0, 21)}...`
let printable = ''
for (const char of head) {
printable += (char.codePointAt(0) ?? 0) < 0x20 ? '?' : char
}
return printable
}