wraps it in a child ), then descend into
+ # children until the item's nested list begins.
+ parts = [node['text']] if node['text'] else []
+ for child in node['children']:
+ if child['tag'] in ('ol', 'ul'):
+ break
+ parts.append(_text_before_nested_list(child))
+ return ' '.join(p for p in parts if p)
+
+
+def first_item_text(ol):
+ for child in ol['children']:
+ if child['tag'] == 'li':
+ return normalize(_text_before_nested_list(child))
+ return ''
+
+
+def sibling_ol_runs(node):
+ """Yield lists of >=2
siblings not separated by a heading."""
+ run = []
+ for child in node['children']:
+ if child['tag'] == 'ol':
+ run.append(child)
+ elif child['tag'] in HEADINGS:
+ if len(run) >= 2:
+ yield run
+ run = []
+ if len(run) >= 2:
+ yield run
+ for child in node['children']:
+ yield from sibling_ol_runs(child)
+
+
+def find_breaks(md_path, html_path):
+ """Return [(source_ordinal, item_text), ...] for confirmed numbering breaks."""
+ ordinals = source_ordinals(md_path)
+ tree = Tree()
+ with open(html_path, encoding='utf-8') as fh:
+ tree.feed(fh.read())
+ content = find_content(tree.root) or tree.root
+ breaks = []
+ for run in sibling_ol_runs(content):
+ for ol in run[1:]: # the first fragment may legitimately start at 1
+ text = first_item_text(ol)
+ if not text:
+ continue
+ nums = ordinals.get(text)
+ if nums and min(nums) > 1: # authored >1 everywhere -> must not be a list start
+ breaks.append((min(nums), text))
+ return breaks
+
+
+def html_for(md_relpath, site):
+ stem = md_relpath[:-3] if md_relpath.endswith('.md') else md_relpath
+ page_dir = os.path.dirname(stem) if os.path.basename(stem) == 'index' else stem
+ return os.path.join(site, page_dir, 'index.html')
+
+
+def collect(docs, site):
+ findings = {}
+ for md in sorted(glob.glob(os.path.join(docs, '**', '*.md'), recursive=True)):
+ rel = os.path.relpath(md, docs)
+ html = html_for(rel, site)
+ if not os.path.exists(html):
+ continue
+ breaks = find_breaks(md, html)
+ if breaks:
+ findings[rel] = breaks
+ return findings
+
+
+def as_baseline_keys(findings):
+ return sorted({f"{page}::{num}::{text}" for page, breaks in findings.items() for num, text in breaks})
+
+
+def main(argv):
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('docs', nargs='?', default='docs')
+ parser.add_argument('site', nargs='?', default='site')
+ parser.add_argument('--baseline')
+ parser.add_argument('--update-baseline')
+ args = parser.parse_args(argv[1:])
+
+ if not os.path.isdir(args.site):
+ print(f"error: rendered site not found at '{args.site}'. Run `mkdocs build` first.", file=sys.stderr)
+ return 2
+
+ findings = collect(args.docs, args.site)
+
+ if args.update_baseline:
+ with open(args.update_baseline, 'w', encoding='utf-8') as fh:
+ json.dump(as_baseline_keys(findings), fh, indent=2)
+ fh.write('\n')
+ print(f"wrote baseline with {len(as_baseline_keys(findings))} entr(y/ies) to {args.update_baseline}")
+ return 0
+
+ baseline = set()
+ if args.baseline and os.path.exists(args.baseline):
+ with open(args.baseline, encoding='utf-8') as fh:
+ baseline = set(json.load(fh))
+
+ new_count = 0
+ for page in sorted(findings):
+ rows = [(num, text) for num, text in findings[page] if f"{page}::{num}::{text}" not in baseline]
+ if not rows:
+ continue
+ new_count += len(rows)
+ print(f"\n{page}")
+ for num, text in rows:
+ print(f" numbering break: author wrote #{num} but it renders as a restarted '1.' -> {text!r}")
+
+ baselined = sum(len(b) for b in findings.values()) - new_count
+ print(f"\n=== {new_count} new numbering break(s); {baselined} baselined; {len(findings)} affected page(s) ===")
+ if new_count:
+ print("Fix by indenting the item's continuation content to 4 spaces so the list "
+ "stays intact, or (if intentional) restart the source numbering at 1.", file=sys.stderr)
+ return 1 if new_count else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv))
diff --git a/scripts/list-numbering-baseline.json b/scripts/list-numbering-baseline.json
new file mode 100644
index 000000000..90b6fc3e5
--- /dev/null
+++ b/scripts/list-numbering-baseline.json
@@ -0,0 +1,7 @@
+[
+ "2-sensors-deployment/adapters/types/sophos.md::5::now you have all the pieces for your ada",
+ "2-sensors-deployment/endpoint-agent/windows/installation.md::3::run the script with your installation ke",
+ "5-integrations/extensions/third-party/plaso.md::2::launch an mft dump in the limacharlie du",
+ "5-integrations/tutorials/hayabusa-bigquery.md::2::specific event types hayabusaevent",
+ "5-integrations/tutorials/velociraptor-bigquery.md::2::specific event types velociraptorcollect"
+]
diff --git a/scripts/publish-release-note.py b/scripts/publish-release-note.py
index ebd0a755d..ab01a92ea 100644
--- a/scripts/publish-release-note.py
+++ b/scripts/publish-release-note.py
@@ -16,15 +16,45 @@
"""
import argparse
+import html
import os
import re
import sys
from datetime import datetime
+from urllib.parse import urlparse
DOCS_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "10-release-notes")
MKDOCS_YML = os.path.join(os.path.dirname(__file__), "..", "mkdocs.yml")
+# Allowlisted hosts for the release URL. A URL is accepted only when its host is
+# one of these exactly or a subdomain of one (e.g. docs.limacharlie.io). The
+# dotted-boundary check ("." + domain) prevents look-alike hosts such as
+# "evilgithub.com" or "limacharlie.io.attacker.example" from slipping through an
+# endswith() match.
+ALLOWED_URL_HOSTS = ("github.com", "limacharlie.io")
+
+# Upper bound on the release-note body. Release notes are short; this simply
+# caps how much untrusted, machine-fed content we will ever write into the docs.
+MAX_BODY_LEN = 50000
+
+# URL schemes that may appear in a Markdown link or image destination in the
+# release URL or body. Anything else (javascript:, data:, vbscript:, file:, ...)
+# is rejected: those render to a live href/src and would be a stored one-click
+# XSS on the docs site. Scheme-less destinations (relative paths, "#anchors",
+# "example.com/x") have no scheme and are always allowed.
+ALLOWED_LINK_SCHEMES = ("http", "https", "mailto")
+
+# Matches a leading URL scheme ("javascript:", "https:", ...) on a destination.
+_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.\-]*):")
+
+# Inline link/image destination: the text right after "](", up to the first
+# whitespace, ">", or ")". Handles an optional leading "<".
+_INLINE_DEST_RE = re.compile(r"\]\(\s*\s*([^)\s>]+)")
+
+# Reference-style link definition at the start of a line: "[label]: dest".
+_REF_DEST_RE = re.compile(r"(?m)^[ \t]{0,3}\[[^\]]+\]:\s*\s*([^\s>]+)")
+
def parse_date(date_str: str) -> datetime:
"""Parse ISO 8601 or YYYY-MM-DD date string."""
@@ -44,7 +74,7 @@ def ensure_monthly_file(dt: datetime) -> str:
if not os.path.exists(filepath):
month_label = dt.strftime("%B %Y")
with open(filepath, "w") as f:
- f.write(f"# Release Notes — {month_label}\n")
+ f.write(f"# Release Notes - {month_label}\n")
return filepath
@@ -129,15 +159,95 @@ def update_mkdocs_nav(dt: datetime) -> None:
def validate_inputs(component: str, version: str) -> None:
- """Validate component and version to prevent path traversal or injection."""
- if not re.match(r'^[\w][\w.-]*$', component):
+ """Validate component and version to prevent path traversal or injection.
+
+ Uses ``re.fullmatch`` so the whole string must match: ``re.match`` with a
+ trailing ``$`` would also accept a single trailing newline, letting a caller
+ smuggle a line break into the generated heading.
+ """
+ if not re.fullmatch(r'[\w][\w.-]*', component):
print(f"Invalid component name: {component}", file=sys.stderr)
sys.exit(1)
- if not re.match(r'^v?[\d]+[\d.]*[\w.-]*$', version):
+ if not re.fullmatch(r'v?[\d]+[\d.]*[\w.-]*', version):
print(f"Invalid version: {version}", file=sys.stderr)
sys.exit(1)
+def validate_url(url: str) -> None:
+ """Validate the optional release URL before it is embedded in the docs.
+
+ An empty URL is allowed (the field is optional). A non-empty URL must use the
+ https scheme and point at an allowlisted host (see ALLOWED_URL_HOSTS);
+ anything else is rejected so a caller cannot inject a link to an arbitrary
+ (e.g. javascript:, http:, or attacker-controlled) destination.
+ """
+ if not url:
+ return
+ parsed = urlparse(url)
+ if parsed.scheme != "https":
+ print(f"Invalid URL scheme (must be https): {url}", file=sys.stderr)
+ sys.exit(1)
+ host = (parsed.hostname or "").lower()
+ if not any(host == d or host.endswith("." + d) for d in ALLOWED_URL_HOSTS):
+ print(f"URL host not in allowlist ({', '.join(ALLOWED_URL_HOSTS)}): {url}", file=sys.stderr)
+ sys.exit(1)
+ # urlparse().hostname stops at the first "/", "?" or "#", so the host
+ # allowlist alone does not vet the rest of the string - and the raw URL is
+ # embedded verbatim inside a Markdown link destination ("[GitHub Release](URL)").
+ # A ")" or whitespace would close/break that destination and let a caller
+ # inject further Markdown (e.g. a javascript: link), which passes the host
+ # check. None of these characters appear in a legitimate release URL, so
+ # reject them.
+ forbidden = set(' \t\r\n"`\\()<>')
+ if any(c in forbidden or ord(c) < 0x20 for c in url):
+ print(f"URL contains forbidden characters: {url}", file=sys.stderr)
+ sys.exit(1)
+
+
+def reject_dangerous_link_schemes(body: str) -> None:
+ """Reject the publish if a Markdown link/image destination uses a bad scheme.
+
+ ``html.escape`` neutralizes raw HTML and autolinks, but Markdown inline links
+ (``[x](javascript:...)``) and reference definitions (``[x]: javascript:...``)
+ still render to a live ```` regardless of HTML escaping, so a
+ ``javascript:`` or ``data:`` destination would be a stored one-click XSS on
+ the docs site. A legitimate, machine-generated release note never uses those
+ schemes, so we fail closed rather than trying to rewrite the body.
+ """
+ for pattern in (_INLINE_DEST_RE, _REF_DEST_RE):
+ for match in pattern.finditer(body):
+ dest = match.group(1)
+ scheme = _SCHEME_RE.match(dest)
+ if scheme and scheme.group(1).lower() not in ALLOWED_LINK_SCHEMES:
+ print(f"Disallowed URL scheme in release body link: {dest}", file=sys.stderr)
+ sys.exit(1)
+
+
+def sanitize_body(body: str) -> str:
+ """Bound and neutralize the release-note body before it is written to docs.
+
+ The body is machine-fed via repository_dispatch and rendered by MkDocs with
+ md_in_html enabled. Two classes of active content must be defused:
+
+ 1. Raw HTML (e.g.