From ef071659e75e72446a9773775686042354df9299 Mon Sep 17 00:00:00 2001 From: geyutu6755 Date: Sun, 8 Nov 2026 10:52:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(v0.7.0):=20real-project=20accuracy=20f?= =?UTF-8?q?ixes=20=E2=80=94=20aggregate=20resource=20failures=20by=20URL,?= =?UTF-8?q?=20skip=20fetch/XHR=20business=20statuses,=20explore=20safe=20f?= =?UTF-8?q?orm=20buttons,=20noscript=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/findings.ts | 6 ++- src/report/html.ts | 1 + src/scanner.ts | 7 +++ src/util/selectors.ts | 14 +++++- tests/fixtures/api-noise/img/.keep | 0 tests/fixtures/api-noise/index.html | 19 ++++++++ tests/integration/api-noise.test.ts | 50 ++++++++++++++++++++ tests/integration/exploration-policy.test.ts | 23 +++++++++ tests/unit/findings.test.ts | 12 +++++ tests/unit/report.test.ts | 7 +++ 10 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/api-noise/img/.keep create mode 100644 tests/fixtures/api-noise/index.html create mode 100644 tests/integration/api-noise.test.ts diff --git a/src/findings.ts b/src/findings.ts index 6d9b66c..f21ec04 100644 --- a/src/findings.ts +++ b/src/findings.ts @@ -46,7 +46,11 @@ export function fingerprintOf(obs: ObservationInput, redactQuery: boolean): stri case 'page-load-failure': return ['page-load-failure', normalizeUrl(obs.targetUrl ?? obs.url), String(e.statusCode ?? norm(e.failureText))].join('|'); case 'resource-load-failure': - return ['resource-load-failure', normalizeUrl(e.resourceUrl ?? ''), String(e.statusCode ?? norm(e.failureText))].join('|'); + // Key by resource URL only: a real browser can observe the same missing + // resource with different failure shapes (HTTP 404 on one request, + // net::ERR_ABORTED on a duplicate). Same root cause -> one finding + // (v0.7.0 accuracy fix); the title keeps the first-observed detail. + return ['resource-load-failure', normalizeUrl(e.resourceUrl ?? '')].join('|'); case 'broken-internal-link': return ['broken-internal-link', normalizeUrl(obs.targetUrl ?? '')].join('|'); case 'horizontal-overflow': diff --git a/src/report/html.ts b/src/report/html.ts index 7a9500c..9a2d88a 100644 --- a/src/report/html.ts +++ b/src/report/html.ts @@ -745,6 +745,7 @@ export function renderHtml(result: ScanResult, compare?: CompareResult): string + diff --git a/src/scanner.ts b/src/scanner.ts index b8b7669..e9cfd21 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -426,6 +426,13 @@ export class Scanner { } } if (res.status() < 400) return; + // XHR/fetch responses are application-level business states: REST lookups + // legitimately return 404/410/etc. and pages handle them (friendly errors, + // empty states). Reporting them as resource failures produced false + // positives on real sites (v0.7.0 accuracy fix). Network-level failures on + // those requests are still captured by the requestfailed listener above. + const rt = res.request().resourceType(); + if (rt === 'xhr' || rt === 'fetch') return; const u = res.url(); if (FAVICON_RE.test(new URL(u, page.url()).pathname)) return; if (!this.isFirstParty(u)) return; diff --git a/src/util/selectors.ts b/src/util/selectors.ts index 6615184..3976522 100644 --- a/src/util/selectors.ts +++ b/src/util/selectors.ts @@ -202,8 +202,18 @@ export function collectCandidatesInPage(opts: CandidateOptions): CandidateCollec const style = getComputedStyle(node); if (style.visibility === 'hidden' || style.display === 'none') continue; if (node.hasAttribute('disabled') || node.getAttribute('aria-disabled') === 'true') continue; - // Never touch form controls: submitting or mutating forms is out of scope. - if (node.closest('form')) continue; + // Never touch controls that submit or mutate a form (submit buttons, + // inputs, selects, textareas): form submission is out of scope by design. + // + + + + + `); + const out = await page.evaluate(collectCandidatesInPage, { max: 20, allowSelectors: [], denySelectors: [] }); + await page.close(); + const chosen = out.candidates.map((c) => c.selector); + // Safe non-submitting button inside a form is explorable. + expect(chosen, 'safe type=button inside a form must be a candidate').toContain('#form-safe'); + // Hard-dangerous keyword keeps its protection regardless of location. + expect(chosen).not.toContain('#form-del'); + expect(out.skipped.find((s) => s.selector === '#form-del')?.reason).toBe('forbidden'); + // Submitting controls (explicit submit and default-type button) stay untouched. + expect(chosen).not.toContain('#form-submit'); + expect(chosen).not.toContain('#form-default'); + }, 60_000); + it('skips a soft keyword by default and offers no candidate bypass', async () => { const out = await collect([], []); const chosen = out.candidates.map((c) => c.selector); diff --git a/tests/unit/findings.test.ts b/tests/unit/findings.test.ts index 5d97f5b..fc95616 100644 --- a/tests/unit/findings.test.ts +++ b/tests/unit/findings.test.ts @@ -64,6 +64,18 @@ describe('FindingCollector dedupe', () => { expect(a.finding.repro.observedCount).toBe(2); }); + it('merges resource failures with the same URL but different failure modes (404 vs abort)', () => { + // A real browser can observe the same missing resource twice with different + // failure shapes (HTTP 404 on one request, net::ERR_ABORTED on a duplicate + // request). Same root cause -> one finding, not two. + const c = new FindingCollector(true); + const a = c.observe(obs({ type: 'resource-load-failure', url: 'http://127.0.0.1:4610/p1.html', evidence: { resourceUrl: 'http://127.0.0.1:4610/app.js', statusCode: 404 } })); + const b = c.observe(obs({ type: 'resource-load-failure', url: 'http://127.0.0.1:4610/p2.html', evidence: { resourceUrl: 'http://127.0.0.1:4610/app.js', failureText: 'net::ERR_ABORTED' } })); + expect(b.created, 'same resource URL must dedupe regardless of failure mode').toBe(false); + expect(a.finding.repro.observedCount).toBe(2); + expect(c.list()).toHaveLength(1); + }); + it('assigns stable ids derived from the fingerprint', () => { const c1 = new FindingCollector(true); const c2 = new FindingCollector(true); diff --git a/tests/unit/report.test.ts b/tests/unit/report.test.ts index 14b7a2a..ce6391d 100644 --- a/tests/unit/report.test.ts +++ b/tests/unit/report.test.ts @@ -153,6 +153,13 @@ describe('HTML report', () => { expect(html).toContain(''); }); + it('provides a noscript fallback pointing at result.json/issues.md', () => { + const html = renderHtml(makeResult()); + expect(html).toContain('