Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction

This commit is contained in:
2026-08-26 15:08:53 -04:00
parent 4e38303326
commit 0738d71525
37 changed files with 1953 additions and 129 deletions

View File

@@ -27,12 +27,29 @@ function escapeHtml (text) {
.replace(/'/g, ''')
}
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
// carrying entities: a real query string like ?a=1&b=2 reaches here as
// ?a=1&b=2. Escaping that again for the attribute produced & and a
// link that opened the wrong record - the ServiceNow and SharePoint links that
// carry several parameters were all broken this way. Decode first, then let the
// single escape on output put back exactly one level.
function decodeEntities (text) {
return String(text)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&') // last, so &amp;lt; does not become <
}
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
// an author could otherwise smuggle script past the escaping above.
function safeHref (url) {
const trimmed = String(url || '').trim()
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
if (trimmed.startsWith('/')) return trimmed // in-app link
// In-app link. NOT '//host', which looks like a path and is read by the
// browser as protocol-relative - an off-site link wearing a local costume.
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
return null
}
@@ -50,13 +67,18 @@ export function renderInline (text) {
// [label](url). The URL pattern allows ONE level of balanced parentheses so
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
// and a naive [^)]+ silently truncates it into a broken link.
// Finished links are stashed like code spans, so the emphasis passes below
// cannot reach inside an href. Without that, a URL containing *characters*
// has <em> injected into the middle of the attribute and the link breaks.
const links = []
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
const href = safeHref(url)
const href = safeHref(decodeEntities(url))
if (!href) return label
const external = /^https?:/i.test(href)
return '<a href="' + escapeHtml(href) + '"' +
links.push('<a href="' + escapeHtml(href) + '"' +
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
'>' + label + '</a>'
'>' + label + '</a>')
return '\u0000LINK' + (links.length - 1) + '\u0000'
})
// **bold** then *italic*, longest marker first so **x** is not read as
@@ -66,6 +88,8 @@ export function renderInline (text) {
html = html.replace(/\n/g, '<br>')
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
'<code>' + codeSpans[Number(index)] + '</code>')