dom

Seeded browser defects — innerHTML sinks, postMessage with no origin check, listeners that outlive the widget.

What this is

20 deliberate defects, each carrying a marker naming the rule in standards/ it violates. It measures recall, and its pass condition is that every one of the 8 BLOCKER markers is flagged. The rule id in each marker means the scorer grades attribution too: a reviewer that finds the bug but cites the wrong rule counts as caught, and separately as misattributed.

How to onboard it

Nothing is installed and nothing here is ever merged. The corpus is used by opening a throwaway pull request on a pilot repository already onboarded for this stack, adding this directory and seeded/clean together. Label it redline-exempt so the readiness gate does not block a pull request nobody will merge, wait for the automated review to finish, then score it.

seed-canary.yml does exactly this on a schedule and closes the pull request afterwards, including when the run fails.

How to use it

terminal
$ GH_TOKEN=... npx redlinegate metrics score-seeds --repo <org>/<repo> --pr <n>
SeedSeverityRule it violatesDefect
1BLOCKERcore/hardcoded-secretsanalytics write key committed in source
2BLOCKERdom/innerhtml-sinkmarkup built by concatenation around external values
3BLOCKERdom/url-input-into-sinklocation.hash written straight into the document
4BLOCKERdom/open-redirectnavigation target taken from a query param with no allow-list
5BLOCKERcore/sensitive-data-in-client-storagesession token parked in localStorage
6BLOCKERcore/customer-data-in-logsmsisdn written to the console
8BLOCKERdom/postmessage-no-origin-checkmessage consumed without checking the sender's origin
9BLOCKERdom/postmessage-wildcard-targetreply broadcast to whatever document holds the frame
1HIGHdom/remove-listener-fresh-functionremoves a function that was never added
2HIGHdom/on-property-clobbers-handlerreplaces the host page's error handler
3HIGHdom/fetch-get-with-bodyGET with a body throws before sending
4HIGHdom/unchecked-query-resultthrows on every page without the form
5HIGHdom/document-cookie-stringunencoded value can inject attributes
7HIGHdom/unparsed-json-boundarydata attribute parsed with no try/catch and no shape check
10HIGHdom/listener-never-removedwindow listener on a teardownable widget with no removal path
11HIGHdom/layout-thrashlayout read and style write interleaved in a loop
12HIGHdom/scroll-resize-unthrottledscroll handler doing network work on every event, not passive
13HIGHdom/fetch-no-abortsupersedable request started with no AbortController — the stale response wins
14HIGHdom/timer-never-clearedinterval retained for the life of the page
15SUGGESTIONdom/prefer-event-delegationone listener per row where a delegated one would do

Expected output

A score from scripts/score-seeds.mjs: BLOCKER recall, false positives, and rule attribution. The canary appends it to data/seed-scores.jsonl and fails the run if BLOCKER recall drops below 1.0 or the clean corpus attracts a false positive. Of the 20 defects here, 8 BLOCKER, 11 HIGH and 1 SUGGESTION — only the BLOCKER count is a pass condition.

How to edit it

Add a defect by adding the code and one marker line above it. There is no separate expectations file to keep in sync — scripts/score-seeds.mjs parses the markers straight out of the source:

marker format
SEED <n> [BLOCKER|HIGH|SUGGESTION] (<stack>/<rule-slug>) <short description>

scripts/validate.mjs fails CI if a seed cites a rule that does not exist, or claims a severity higher than that rule carries in the standard — so a marker cannot quietly drift away from the rule it is testing.

The full file

seeded/dom/anti-slop.js · 21 lines · 1.0 KB
// DO NOT MERGE — Redline validation seed (anti-slop rules).
export class Widget {
  init() { window.addEventListener('resize', this.onResize.bind(this)); }
  // SEED 1 [HIGH] (dom/remove-listener-fresh-function) removes a function that was never added
  destroy() { window.removeEventListener('resize', this.onResize.bind(this)); }
}

// SEED 2 [HIGH] (dom/on-property-clobbers-handler) replaces the host page's error handler
window.onerror = (msg) => navigator.sendBeacon('/err', msg);

export async function search(q) {
  // SEED 3 [HIGH] (dom/fetch-get-with-body) GET with a body throws before sending
  return fetch('/api/search', { body: JSON.stringify({ q }) });
}

// SEED 4 [HIGH] (dom/unchecked-query-result) throws on every page without the form
document.getElementById('newsletter').addEventListener('submit', () => {});

// SEED 5 [HIGH] (dom/document-cookie-string) unencoded value can inject attributes
document.cookie = `lang=${new URLSearchParams(location.search).get('lang')}; path=/`;
seeded/dom/seeded-violations.js · 69 lines · 3.0 KB
// DO NOT MERGE — Redline validation seed.
// Every `SEED n [SEVERITY] (rule-id)` marker must be flagged at that severity or higher,
// citing that rule id. Score with scripts/score-seeds.mjs.

// SEED 1 [BLOCKER] (core/hardcoded-secrets) analytics write key committed in source
const ANALYTICS_WRITE_KEY = 'wk_live_acme_7d41e0b2c9';

const root = document.querySelector('#account-panel');

export function render(account) {
  // SEED 2 [BLOCKER] (dom/innerhtml-sink) markup built by concatenation around external values
  root.innerHTML = `<a href="${account.returnUrl}">${account.displayName}</a>`;

  // SEED 3 [BLOCKER] (dom/url-input-into-sink) location.hash written straight into the document
  root.insertAdjacentHTML('beforeend', `<p>${decodeURIComponent(location.hash.slice(1))}</p>`);

  // SEED 4 [BLOCKER] (dom/open-redirect) navigation target taken from a query param with no allow-list
  const next = new URLSearchParams(location.search).get('next');
  document.querySelector('#continue').addEventListener('click', () => {
    location.href = next;
  });

  // SEED 5 [BLOCKER] (core/sensitive-data-in-client-storage) session token parked in localStorage
  localStorage.setItem('session_token', account.token);

  // SEED 6 [BLOCKER] (core/customer-data-in-logs) msisdn written to the console
  console.log('rendered panel for', account.msisdn, ANALYTICS_WRITE_KEY);

  // SEED 7 [HIGH] (dom/unparsed-json-boundary) data attribute parsed with no try/catch and no shape check
  const prefs = JSON.parse(root.dataset.preferences);

  return prefs;
}

// SEED 8 [BLOCKER] (dom/postmessage-no-origin-check) message consumed without checking the sender's origin
window.addEventListener('message', (event) => {
  render(event.data.account);

  // SEED 9 [BLOCKER] (dom/postmessage-wildcard-target) reply broadcast to whatever document holds the frame
  event.source.postMessage({ ok: true }, '*');
});

// SEED 10 [HIGH] (dom/listener-never-removed) window listener on a teardownable widget with no removal path
window.addEventListener('resize', () => {
  // SEED 11 [HIGH] (dom/layout-thrash) layout read and style write interleaved in a loop
  for (const row of document.querySelectorAll('.row')) {
    const h = row.getBoundingClientRect().height;
    row.style.height = `${h + 2}px`;
  }
});

// SEED 12 [HIGH] (dom/scroll-resize-unthrottled) scroll handler doing network work on every event, not passive
window.addEventListener('scroll', () => {
  fetch('/api/telemetry/scroll', { method: 'POST' });
});

export function search(term) {
  // SEED 13 [HIGH] (dom/fetch-no-abort) supersedable request started with no AbortController — the stale response wins
  return fetch(`/api/search?q=${term}`).then((r) => r.json());
}

// SEED 14 [HIGH] (dom/timer-never-cleared) interval retained for the life of the page
setInterval(() => fetch('/api/heartbeat'), 5000);

// SEED 15 [SUGGESTION] (dom/prefer-event-delegation) one listener per row where a delegated one would do
for (const row of document.querySelectorAll('.row')) {
  row.addEventListener('click', () => row.classList.add('selected'));
}