-
Notifications
You must be signed in to change notification settings - Fork 4
feat(hermes-base): 上报前脱敏 + 失败指纹(失败遥测 P0 的 CLI 侧) #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| /** | ||
| * One implementation of the failure fingerprint, shared by everything that | ||
| * groups the same failure: the version/create report, the fuzzer's dedup of | ||
| * its findings, and (later) the replay of the stored corpus. Two | ||
| * implementations would mean the counts behind "how often does this happen" | ||
| * are fiction, so callers import from here rather than writing their own | ||
| * regexes. | ||
| * | ||
| * Redaction is the other half. A detail line carries whatever hermesc printed, | ||
| * which is the user's own code: the first rejection seen in production named | ||
| * the property `promotionRequestItemId`. Details travel to the server, into | ||
| * issue lists and -- once the fix loop runs -- into public pull requests and CI | ||
| * fixtures, so the identifiers are replaced by tokens *before* the report | ||
| * leaves the machine. The local console keeps the unredacted text: that is | ||
| * where the name is actually useful. | ||
| */ | ||
| import { createHash } from 'node:crypto'; | ||
|
|
||
| const sha = (value: string) => createHash('sha256').update(value).digest('hex'); | ||
|
|
||
| /** stable stand-in for one redacted value; the same input always yields it */ | ||
| const token = (kind: string, value: string) => | ||
| `${kind}#${sha(value).slice(0, 8)}`; | ||
|
|
||
| /** | ||
| * Replace the parts of a detail line that can only come from the user's code: | ||
| * quoted string operands (property names, string literals), function names, | ||
| * and filesystem paths that reach the line through a compiler's stderr. What | ||
| * stays is the shape a fix is reasoned about -- opcodes, registers, counts, | ||
| * literal kinds -- plus each redacted value's length and character class. | ||
| * | ||
| * This is redaction by class, not a proof: it covers the shapes the comparison | ||
| * and the compilers are known to emit. Anything that arrives in an unknown | ||
| * shape still has its paths and quoted runs stripped, so a new detail format | ||
| * cannot silently start leaking identifiers. | ||
| */ | ||
| export function redactFailureDetail(detail: string): string { | ||
| return ( | ||
| detail | ||
| // Paths first: a compiler's stderr reaches the line with them, and | ||
| // running this pass after the others would eat the `/<length>` suffix | ||
| // the string pass writes. | ||
| .replace(/(?:\.{0,2}\/)[^\s:,)"']*/g, (path: string) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win Sensitive Data Exposure Reachability: External Reachability pathRedact complete paths before reporting diagnostics. If a compiler diagnostic contains 🤖 Prompt for AI Agents |
||
| const ext = /\.([A-Za-z0-9]+)$/.exec(path); | ||
| return `${token('path', path)}${ext ? `.${ext[1]}` : ''}`; | ||
| }) | ||
| // Function<name>(…) headers, including the raw-audit variants | ||
| .replace( | ||
| /\b(Function|NCFunction|Constructor)<([^>]*)>/g, | ||
| (_all, kind: string, name: string) => | ||
| `${kind}<${name ? token('fn', name) : ''}>`, | ||
| ) | ||
| // Quoted operands. hermesc does not escape quotes inside strings, so the | ||
| // run is taken as-is up to the next quote; a stray tail keeps whatever | ||
| // the earlier passes left rather than being reconstructed. | ||
| .replace(/"([^"\n]*)"/g, (_all, value: string) => { | ||
| const units = Array.from(value); | ||
| const ascii = units.every((char) => char.charCodeAt(0) < 0x80); | ||
| return `"${token('str', value)}/${units.length}${ascii ? '' : '/u16'}"`; | ||
| }) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The grouping key: a redacted detail with everything that varies between two | ||
| * occurrences of the same defect removed -- registers, ids, offsets, labels, | ||
| * counts and the redaction tokens themselves. Sixteen bytes; the server stores | ||
| * it as 32 hex characters. | ||
| */ | ||
| export function failureFingerprint(detail: string): string { | ||
| const shape = redactFailureDetail(detail) | ||
| .replace(/#[0-9a-f]{8}/g, '#') | ||
| .replace(/\br\d+\b/g, 'r') | ||
| .replace(/\bL\d+\b/g, 'L') | ||
| .replace(/\d+/g, 'N') | ||
| .replace(/\s+/g, ' ') | ||
| .trim(); | ||
| return sha(shape).slice(0, 32); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { describe, expect, test } from 'bun:test'; | ||
| import { | ||
| failureFingerprint, | ||
| redactFailureDetail, | ||
| } from '../src/utils/failure-fingerprint'; | ||
|
|
||
| // The detail of a rejected Hermes base, in the shape the comparison emits. | ||
| const REJECTED = | ||
| 'Function<h>(3 params, 21 registers, 1 numbers, 2 non-pointers): +72: ' + | ||
| 'DefineOwnById r3, r6, 1, "shipmentTrackingR"... vs ' + | ||
| 'DefineOwnById r3, r6, 1, "shipmentTrackingReference"'; | ||
|
|
||
| describe('redactFailureDetail', () => { | ||
| test('keeps the shape and drops every name that comes from user code', () => { | ||
| const redacted = redactFailureDetail(REJECTED); | ||
| expect(redacted).not.toContain('shipmentTracking'); | ||
| expect(redacted).not.toContain('Function<h>'); | ||
| // what a fix is reasoned about survives | ||
| expect(redacted).toContain('DefineOwnById r3, r6, 1,'); | ||
| expect(redacted).toContain('+72:'); | ||
| // each redacted value keeps its length, so a truncated operand still | ||
| // reads as the shorter one | ||
| expect(redacted).toContain('/17'); | ||
| expect(redacted).toContain('/25'); | ||
| }); | ||
|
|
||
| test('marks non-ASCII strings without revealing them', () => { | ||
| const redacted = redactFailureDetail( | ||
| 'Array Buffer entry 1: [String "中文属性名"] vs [String "bar"]', | ||
| ); | ||
| expect(redacted).not.toContain('中文'); | ||
| expect(redacted).toContain('/5/u16'); | ||
| expect(redacted).toContain('/3'); | ||
| }); | ||
|
|
||
| test('strips paths that reach the line through a compiler stderr', () => { | ||
| const redacted = redactFailureDetail( | ||
| 'base dump: exit 3: boom: /Users/someone/app/build/delta.hbc', | ||
| ); | ||
| expect(redacted).not.toContain('someone'); | ||
| expect(redacted).toContain('.hbc'); | ||
| expect(redacted).toContain('exit 3'); | ||
| }); | ||
|
|
||
| test('the same value always redacts to the same token', () => { | ||
| expect(redactFailureDetail(REJECTED)).toBe(redactFailureDetail(REJECTED)); | ||
| }); | ||
| }); | ||
|
|
||
| describe('failureFingerprint', () => { | ||
| test('groups the same defect across apps, registers and ids', () => { | ||
| const otherApp = REJECTED.replace(/shipmentTracking/g, 'promotionRequest') | ||
| .replace('r3, r6', 'r9, r2') | ||
| .replace('+72', '+8'); | ||
| expect(failureFingerprint(otherApp)).toBe(failureFingerprint(REJECTED)); | ||
| }); | ||
|
|
||
| test('the jump-table offsets of one SwitchImm gap are one group', () => { | ||
| const at = (offset: number) => | ||
| `Function<ui>(4 params, 21 registers, 0 symbols): +5: SwitchImm r0, ${offset}, L4, 3, 31 vs SwitchImm r0, 616, L4, 3, 31`; | ||
| expect(failureFingerprint(at(620))).toBe(failureFingerprint(at(618))); | ||
| }); | ||
|
|
||
| test('a different instruction is a different group', () => { | ||
| expect(failureFingerprint(REJECTED)).not.toBe( | ||
| failureFingerprint(REJECTED.replace(/DefineOwnById/g, 'PutByIdLoose')), | ||
| ); | ||
| }); | ||
|
|
||
| test('is 32 hex characters, as the server column stores it', () => { | ||
| expect(failureFingerprint(REJECTED)).toMatch(/^[0-9a-f]{32}$/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | 🏗️ Heavy lift
Sensitive Data Exposure
Reachability: External
Exploitability: Moderate
CWE: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
Reachability path
Make published redaction tokens resistant to dictionary lookup.
When a reported operand is a common name or short literal, a reader can hash candidate values and match this unkeyed eight-hex-character token. The published length narrows the candidates further. Use opaque tokens or a keyed scheme whose key is not available to readers of the report.
failureFingerprintremoves the token before grouping, so grouping does not require a public hash of the operand.🤖 Prompt for AI Agents