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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,38 @@ All notable changes to ReproCase are documented here.
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versioning follows [Semantic Versioning](https://semver.org/).

## [0.3.2] — 2026-11-01

Post-release hardening: one security fix and one report-honesty fix. No new
features and no UI rebuild.

### Security

- **The HTML report no longer turns untrusted URLs into clickable links.**
`report.html` assigned `result.json` values (finding page URL, cluster member
URL, reproduction-plan URL, page-table URL, header target) directly to anchor
`href`. The bundled scanner only emits `http(s)`, but the generator is a general
renderer over an arbitrary `ScanResult`, so a crafted / shared / older /
third-party `result.json` — opened as an offline report — could produce a
`javascript:` or `data:` link that executes on click (self-XSS). Every dynamic
link now passes an HTTP(S) scheme check and renders as non-clickable text if the
URL is not `http(s)`; the header target is guarded server-side. Legitimate
`http(s)` links are unchanged. A real-browser test asserts zero
`javascript:`/`data:`/`file:` anchors while normal links still render.

### Changed

- **Trace links set honest expectations.** Playwright traces stay private and are
still stripped from CI and GitHub Pages, but the report now labels the trace link
as a *local* artifact and its tooltip states that traces are not included in
hosted / public copies — so users no longer see a dead "download" for evidence
that was intentionally removed.

### Tests

- Suite: **136 tests across 20 files** (was 132) — added a real-browser URL-safety
audit, a header-URL guard test, and a trace-copy honesty test.

## [0.3.1] — 2026-11-01

Reliability and trust refinement. No new capabilities — this release corrects how the
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,13 @@ fault through the real Playwright runner.

## Status

v0.3.1 — reliability & trust refinement of the v0.3.0 line. Cross-page fault clusters,
adaptive async observation (configurable, budgeted), open shadow DOM + same-origin iframe
detection and `scan --compare-with` are all retained; this patch hardens how clusters are
described (message-only matches are shown as *possible duplicates*, never a proven shared
root cause), keeps lone faults out of the cluster view, and tightens report accuracy. See the
[changelog](CHANGELOG.md), [test baselines](docs/test-baselines.md) and the
v0.3.2 — post-release hardening: the offline HTML report now validates link schemes so
untrusted `result.json` values can never become clickable `javascript:`/`data:` links, and
trace evidence is labelled honestly as local-only. Built on v0.3.1 (honest fault-cluster
labelling, ≥2-page clusters, accurate `issues.md`) and the v0.3.0 line — cross-page fault
clusters, adaptive async observation (configurable, budgeted), open shadow DOM + same-origin
iframe detection and `scan --compare-with`. See the [changelog](CHANGELOG.md),
[test baselines](docs/test-baselines.md) and the
[compatibility matrix](docs/compatibility.md).
Known limitations are listed in the changelog and in the "Not checked" section of every report.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ npm run test:all # 单元 + 集成(真实浏览器)+ CLI 端到端

## 状态

v0.3.1 —— 在 v0.3.0 基础上的可靠性与可信度精修:保留跨页面故障聚类、自适应异步观测(可配置/有预算)、开放 Shadow DOM 与同源 iframe 检测、scan --compare-with 跨扫描比较;本补丁收紧故障聚类的表述(仅凭相同文本、无可信堆栈的匹配只显示为"疑似关联/可能重复",绝不声称已证实同一根因),让孤立故障不再冒充聚类,并提升报告准确性。详见
v0.3.2 —— 发布后加固:离线 HTML 报告对链接协议做校验,来自不可信 `result.json` 的值不会再变成可点击的 `javascript:`/`data:` 链接;并对 Trace 证据作诚实标注(仅本地、公共副本不包含)。基于 v0.3.1(故障聚类诚实表述、聚类需跨≥2页、`issues.md` 表头准确)与 v0.3.0 特性线——跨页面故障聚类、自适应异步观测(可配置/有预算)、开放 Shadow DOM 与同源 iframe 检测、scan --compare-with。详见
[变更日志](CHANGELOG.md)、[测试基线](docs/test-baselines.md) 与[兼容性矩阵](docs/compatibility.md)。

## 许可证
Expand Down
14 changes: 7 additions & 7 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

Honest status per platform. "Real pass" means the full automated suite (or the listed
components) actually ran on that platform; nothing here is inferred from source review.
Windows and macOS results come from real GitHub Actions runners (`ci.yml` matrix, run for
commit `2ef101c`, 2026-11-01); Linux from ubuntu runners (Node 20 & 22). The **Windows local**
column is re-run for every release and now reports the current suite: 122/122 at v0.3.0 and
132/132 at v0.3.1. The CI columns above the v0.3.x line reflect the `2ef101c` matrix run; a
fresh `ci.yml` matrix executes on every push to `main` (including the v0.3.1 release push) and
its results are recorded on that run, not asserted here until observed.
The **Windows local** column is re-run for every release and reports the current suite:
122/122 at v0.3.0, 132/132 at v0.3.1 and 136/136 at v0.3.2. The full `ci.yml` matrix
(Windows / macOS / Ubuntu Node 20 & 22) runs on every push to `main` and every PR; the
v0.3.1 matrix (run #41295959003) completed **success** on all four OS jobs, and the v0.3.2
results are attached to that release's PR/push once observed. The matrix cells below are
annotated with the run each platform result is drawn from.

| Component | Windows 11 x64 (local dev) | Windows CI (windows-latest, Node 22) | Linux CI (ubuntu-latest, Node 20 & 22) | macOS CI (macos-latest, Node 22) |
| --- | --- | --- | --- | --- |
| Full test suite (`vitest run`) | ✅ real pass (99/99 v0.2.1; 101/101 v0.2.2; 122/122 v0.3.0; 132/132 v0.3.1) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) |
| Full test suite (`vitest run`) | ✅ real pass (101/101 v0.2.2; 122/122 v0.3.0; 132/132 v0.3.1; 136/136 v0.3.2) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) | ✅ real pass (CI @ v0.2.2) |
| CLI `scan` / `demo` / `init` / `doctor` / `compare` | ✅ real pass | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) |
| Chromium via Playwright (headless) | ✅ real pass | ✅ real pass (CI) | ✅ real pass (`--with-deps`) | ✅ real pass (CI) |
| Generated reproduction tests via `playwright test` | ✅ real pass | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) | ✅ real pass (CI e2e) |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "reprocase",
"version": "0.3.1",
"version": "0.3.2",
"description": "Automated website fault detection & reproduction. Scans a site in a real browser, verifies each fault independently, and emits reproducible Playwright regression tests plus human- and CI-friendly reports.",
"type": "module",
"license": "MIT",
Expand Down
60 changes: 45 additions & 15 deletions src/report/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ function escapeAttr(s: string): string {
return escapeHtml(s);
}

/**
* Server-side guard: return the URL only when it is an absolute http(s) link,
* else null. Untrusted result.json values must never become a live `href`
* (blocks javascript:/data:/file: self-XSS in the offline report).
*/
function httpHref(u: string | undefined | null): string | null {
if (!u) return null;
try {
const p = new URL(u);
return p.protocol === 'http:' || p.protocol === 'https:' ? u : null;
} catch {
return null;
}
}

const CSS = `
:root {
--bg: #f5f6f8; --card: #ffffff; --ink: #111827; --muted: #6b7280; --line: #e5e7eb;
Expand Down Expand Up @@ -69,6 +84,7 @@ main { max-width: 1080px; margin: 0 auto; padding: 20px 24px 60px; }
.cluster-group { font-size: 12px; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; margin: 20px 0 6px; }
.cluster-group:first-child { margin-top: 0; }
.cluster-caution { margin: 10px 0 0; padding: 9px 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; color: #92400e; font-size: 12.5px; line-height: 1.5; }
.blocked-url { color: var(--muted); word-break: break-all; font-family: var(--mono); font-size: 12.5px; }
.copy-btn { font: inherit; font-size: 12px; border: 1px solid var(--line); background: #f8fafc; color: var(--accent); border-radius: 6px; padding: 3px 10px; cursor: pointer; }
.copy-btn:hover { border-color: var(--accent); }
.finding { background: var(--card); border: 1px solid var(--line); border-left: 4px solid var(--muted); border-radius: 10px; padding: 16px 18px; margin-bottom: 14px; overflow-wrap: anywhere; }
Expand Down Expand Up @@ -137,6 +153,24 @@ const CLIENT_JS = `
}
function chip(cls, text) { return el('span', 'chip ' + cls, text); }
function fmtSec(ms) { return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms'; }
function safeHref(u) {
if (typeof u !== 'string' || !u) return null;
try {
var p = new URL(u, location.href);
return (p.protocol === 'http:' || p.protocol === 'https:') ? u : null;
} catch (e) { return null; }
}
function linkOrText(url, label) {
var h = safeHref(url);
if (h) {
var a = el('a', null, label);
a.href = h; a.target = '_blank'; a.rel = 'noreferrer';
return a;
}
var s = el('span', 'blocked-url', label);
s.title = 'Non-HTTP(S) link is not clickable in this report.';
return s;
}

var findings = (D.findings || []).slice().sort(function (a, b) {
if (a.status !== b.status) return a.status === 'verified' ? -1 : 1;
Expand Down Expand Up @@ -244,9 +278,7 @@ const CLIENT_JS = `
details.appendChild(el('summary', null, 'Affected pages'));
c.members.forEach(function (m) {
var row = el('div', 'member-row');
var a = el('a', null, m.url);
a.href = m.url; a.target = '_blank'; a.rel = 'noreferrer';
row.appendChild(a);
row.appendChild(linkOrText(m.url, m.url));
row.appendChild(el('span', null, ' [' + m.status + (m.stability ? '/' + m.stability : '') + '] repro ' + m.repro.successes + '/' + m.repro.attempts + (m.generatedTest ? ' · ' + m.generatedTest : '')));
details.appendChild(row);
});
Expand Down Expand Up @@ -386,9 +418,7 @@ const CLIENT_JS = `
meta.appendChild(el('span', null, '')).appendChild(el('code', null, f.id));
var page = el('span');
page.appendChild(el('span', null, 'page: '));
var a = el('a', null, f.url);
a.href = f.url; a.target = '_blank'; a.rel = 'noreferrer';
page.appendChild(a);
page.appendChild(linkOrText(f.url, f.url));
meta.appendChild(page);
var rep = el('span');
rep.appendChild(el('span', null, 'reproduced: '));
Expand All @@ -413,9 +443,8 @@ const CLIENT_JS = `
card.appendChild(el('div', 'sect-label', 'Reproduction steps (recorded during the scan)'));
var ol = el('ol', 'steps');
var li0 = el('li', null, '');
var a0 = el('a', null, 'Open ' + (f.plan && f.plan.url ? f.plan.url : f.url));
a0.href = (f.plan && f.plan.url) || f.url; a0.target = '_blank'; a0.rel = 'noreferrer';
li0.appendChild(a0);
var openUrl = (f.plan && f.plan.url) || f.url;
li0.appendChild(linkOrText(openUrl, 'Open ' + openUrl));
ol.appendChild(li0);
f.steps.forEach(function (s) { ol.appendChild(el('li', null, s.description + (s.selector ? ' — ' + s.selector : ''))); });
ol.appendChild(el('li', null, 'Observe the fault described above.'));
Expand Down Expand Up @@ -445,8 +474,10 @@ const CLIENT_JS = `
var arts = [];
(f.traces || []).forEach(function (t) {
var row = el('span');
var link = el('a', null, 'browser trace (playwright show-trace)');
link.href = encodeURI(t); link.download = t.split('/').pop();
var link = el('a', null, 'browser trace (local · playwright show-trace)');
link.href = encodeURI(t);
link.download = t.split('/').pop();
link.title = 'Private local evidence. Trace files record raw network traffic and are not included in hosted or public copies of this report — open them from your own scan output.';
row.appendChild(link);
row.appendChild(el('span', null, ' — ' + t));
arts.push(row);
Expand Down Expand Up @@ -484,9 +515,7 @@ const CLIENT_JS = `
(D.pages || []).forEach(function (p) {
var tr = el('tr');
var urlTd = el('td', 'url');
var a = el('a', null, p.url);
a.href = p.url; a.target = '_blank'; a.rel = 'noreferrer';
urlTd.appendChild(a);
urlTd.appendChild(linkOrText(p.url, p.url));
tr.appendChild(urlTd);
var status = el('td');
if (p.error) { status.appendChild(el('span', 'bad', 'network error')); status.title = p.error; }
Expand Down Expand Up @@ -596,6 +625,7 @@ const CLIENT_JS = `
/** Renders the self-contained offline HTML report. No external resources. */
export function renderHtml(result: ScanResult, compare?: CompareResult): string {
const target = result.target?.entry ?? '';
const targetHref = httpHref(target);
const typeOptions = ([
'page-load-failure',
'js-exception',
Expand Down Expand Up @@ -623,7 +653,7 @@ export function renderHtml(result: ScanResult, compare?: CompareResult): string
${LOGO_SVG}
<div class="brand">ReproCase<small>v${VERSION}</small></div>
<div class="topmeta">
<div>target: <a href="${escapeAttr(target)}" rel="noreferrer">${escapeHtml(target)}</a></div>
<div>target: ${targetHref ? `<a href="${escapeAttr(targetHref)}" rel="noreferrer">${escapeHtml(target)}</a>` : escapeHtml(target)}</div>
<div>finished: ${escapeHtml(result.finishedAt ?? '')} · allowed domains: ${escapeHtml((result.target?.allowedDomains ?? []).join(', ') || 'localhost')}</div>
</div>
</div>
Expand Down
56 changes: 56 additions & 0 deletions tests/integration/report-ux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,62 @@ describe('HTML report UX (real browser audit)', () => {
await page.close();
}, 120_000);

it('blocks non-HTTP(S) URLs from becoming clickable links (v0.3.2 security)', async () => {
// A crafted / shared / third-party result.json is a real input to the offline
// report a user opens; its page/plan/member/entry URLs must never be emitted as
// javascript:/data:/file: anchors (self-XSS on click).
const evilFinding = makeFinding({
url: 'javascript:alert(document.domain)',
pages: ['javascript:alert(document.domain)'],
plan: { kind: 'js-error', url: 'javascript:alert(document.domain)' }
});
const member = makeFinding({
url: 'javascript:alert(member)',
evidence: { message: 'Boom: shared', stack: 'Error: Boom: shared\n at save (http://127.0.0.1:4640/app.js:1:1)' }
});
const member2 = makeFinding({
url: 'javascript:alert(member2)',
evidence: { message: 'Boom: shared', stack: 'Error: Boom: shared\n at save (http://127.0.0.1:4640/app.js:1:1)' }
});
const result = { ...makeResult([evilFinding]) } as ScanResult;
result.findings = [evilFinding, member, member2];
result.clusters = aggregateFindings([evilFinding, member, member2]);
// Keep one legitimate http page so we can prove good links still render.
result.pages = result.pages.concat([
{ url: 'data:text/html,<script>alert(1)</script>', depth: 0, status: 200, actions: 0, jsErrors: 0, resourceErrors: 0, overflow: false, durationMs: 1 }
]);
result.target = { entry: 'javascript:alert(topbar)', allowedDomains: ['x'] };
result.summary = { verified: 1, suspected: 0, bySeverity: { critical: 0, high: 3, medium: 0, low: 0 }, byType: { 'js-exception': 3 } };

const file = path.join(path.dirname(htmlFile), 'unsafe-urls.html');
fs.writeFileSync(file, renderHtml(result), 'utf8');

const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
let dialogSaw = '';
page.on('dialog', (d) => { dialogSaw = d.message(); d.dismiss().catch(() => undefined); });
await page.goto('file:///' + file.replace(/\\/g, '/'));
await page.waitForTimeout(250);

const state = await page.evaluate(() => {
const hrefs = [...document.querySelectorAll('a[href]')].map((a) => (a.getAttribute('href') || '').trim().toLowerCase());
return {
bad: hrefs.filter((h) => h.startsWith('javascript:') || h.startsWith('data:') || h.startsWith('file:')),
legit: hrefs.filter((h) => h.startsWith('http:') || h.startsWith('https:')).length
};
});
expect(state.bad, 'no javascript:/data:/file: anchors may be clickable').toEqual([]);
expect(state.legit, 'legitimate http(s) page links must still render').toBeGreaterThan(0);

// Clicking where a blocked URL would have been must not execute or navigate.
await page.click('#clusters details summary').catch(() => undefined);
await page.waitForTimeout(150);
expect(dialogSaw, 'no alert() from a blocked javascript: link').toBe('');
expect(pageErrors).toEqual([]);
await page.close();
}, 120_000);

it('stability filter narrows findings (v0.3.0)', async () => {
const mixed = makeResult([
makeFinding({ title: 'stable one', evidence: { message: 'a' }, stability: 'stable' }),
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,31 @@ describe('report accuracy & consistency (v0.3.1)', () => {
expect(html).toContain('🐛');
});
});

describe('report URL safety (v0.3.2)', () => {
it('never renders the target entry as a live non-HTTP(S) anchor in the header', () => {
const html = renderHtml(
makeResult({ target: { entry: 'javascript:alert(1)', allowedDomains: ['x'] } })
);
// The topbar is server-rendered: it must not emit href="javascript:...".
expect(html).not.toMatch(/href="javascript:/i);
expect(html).not.toMatch(/href="data:/i);
// The value is still shown (escaped) as plain text, not as a clickable link.
expect(html).toContain('javascript:alert(1)');
});

it('keeps a legitimate HTTP(S) target as a clickable header link', () => {
const html = renderHtml(
makeResult({ target: { entry: 'https://example.com/app', allowedDomains: ['example.com'] } })
);
expect(html).toContain('<a href="https://example.com/app"');
});

it('labels trace links as private local evidence, not a public download', () => {
const withTrace = makeFinding({ traces: ['artifacts/traces/trace-a.zip'] });
const html = renderHtml(makeResult({ findings: [withTrace] }));
// v0.3.2 UX: the trace affordance must set expectations that hosted/public copies omit it.
expect(html).toMatch(/playwright show-trace/);
expect(html.toLowerCase()).toContain('not included in hosted');
});
});
Loading