' — element-body position of the top-ranked cycle's heading; the injected
+is parsed as live markup and executes on page load with no interaction. No CSP exists anywhere in the generated HTML;
+minifyHtml defaults to false and minify-html is a minifier, not a sanitizer.
+
+### Step 9 — `report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java:1042-1048 and 1067-1069`
+
+Secondary sinks: the method-signature cell escapes only '<'/'>' (MetricsVisitorLogic.buildMethodSignature:727
+concatenates method.getSimpleName () raw, so backtick method names carry the payload), and the Duplicate Partners cell
+renders DisharmonyDetector's partner string (sigA + ' ↔ ' + simpleB + '.' + sigB, :635) raw apart from a ';'-to-
+substitution — the same signature that is partially escaped in its own cell arrives unescaped here. renderPackageEdge (:
+725-748) likewise renders package vertex names raw.
+
+## Fix / patch notes
+
+diff --git a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java
+b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java ---
+a/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java +++
+b/report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java @@ -772,7 +772,7 @@ private String[]
+getRankedCycleSummaryData (RankedCycle rankedCycle) { return new String[] { // "Cycle Name", "Priority", "Class Count",
+"Relationship Count"
+
+- getClassName(rankedCycle.getCycleName()),
+
++ escapeHtmlLabel(getClassName(rankedCycle.getCycleName())),
+ rankedCycle.getPriority().toString(),
+ String.valueOf(rankedCycle.getCycleNodes().size()),
+ String.valueOf(rankedCycle.getEdgeSet().size())
+
+@@ -790,7 +790,7 @@ stringBuilder .append ("
\n");
+
+@@ -730,15 +730,15 @@ String startVertex = vertexes[0].trim (); String start; if (packagesToRemove.contains
+(startVertex)) {
+
+- start = startVertex + "*";
+
++ start = escapeHtmlLabel(startVertex) + "*";
+ } else {
+
+- start = startVertex;
+
++ start = escapeHtmlLabel(startVertex);
+ }
+
+ String endVertex = vertexes[1].trim (); String end; if (packagesToRemove.contains (endVertex)) {
+
+- end = endVertex + "*";
+
++ end = escapeHtmlLabel(endVertex) + "*";
+ } else {
+
+- end = endVertex;
+
++ end = escapeHtmlLabel(endVertex);
+ } @@ -1042,7 +1042,7 @@ }
+
+- sb.append(drawTableCell(sig != null ? sig.replace("<", "<").replace(">", ">") : ""));
+
++ sb.append(drawTableCell(sig != null ? escapeHtmlLabel(sig) : ""));
+
+@@ -1065,7 +1065,8 @@ } sb.append (drawTableCell (
+
+- rd.getDuplicationPartners() != null ? duplicationPartners.replace(";", " ") : ""));
+
++ rd.getDuplicationPartners() != null ? escapeHtmlLabel(duplicationPartners).replace(";", " ")
++ : ""));
+
+@@ -765,7 +765,8 @@ static String escapeHtmlLabel (String label) {
+
+- return label.replace("&", "&").replace("<", "<").replace(">", ">");
+
++ return label.replace("&", "&").replace("<", "<").replace(">", ">")
++ .replace("\"", """).replace("'", "'");
+ }
+
+## References
+
+- https://cwe.mitre.org/data/definitions/79.html
+- https://cwe.mitre.org/data/definitions/80.html
+- https://owasp.org/www-community/attacks/xss/
+- https://kotlinlang.org/docs/reference/grammar.html#escapedIdentifier
+
+---
+
+_Rendered from original VulnHunter / VulnForge `report.yaml` by OpenVuln._
+
+## [high] HtmlReport graph maps (class map + cycle maps: generateGraphButtons/buildClassGraphDot|buildClassCycleDot → hyperlinkClassForDot) embed git remote.origin.url raw inside a ` HTML breakout — all zero-interaction), with the realistically achievable top
+impact measured per consumption surface (EXP-R6-E2): on an authenticated web origin that publishes the report (the
+Jenkins-HTML-Publisher / CI-artifact-viewer / internal-dashboard pattern) the payload acts as every viewer and
+exfiltrates the origin's protected data with each viewer's session (proven; HttpOnly does not help); on the documented
+default local `file://` view the impact is bounded to redirect/phishing plus report-content beacons (local-file reads
+blocked by the browser, no cookie surface — proven); on the README step-summary CI flow the sink's artifact is not even
+generated (simple report) and the surface sanitizes anyway. Not code execution in the I:X sense — the ceiling is session
+hijack on hosting origins, conditional on a victim-side publishing step the tool does not perform.
+
+## Code anchors
+
+| File | Line | Function |
+|-----------------------------------------------------------------------------------------------|-----:|-----------------------------|
+| `../change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java` | 85 | `getOriginUrl` |
+| `../change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java` | 88 | `getRepoUrl` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java` | 360 | `generateReport` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java` | 369 | `generateReport` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java` | 512 | `generateGraphButtons` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java` | 564 | `buildClassGraphDot` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java` | 631 | `hyperlinkClassForDot` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java` | 923 | `renderClassCycleVisuals` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java` | 972 | `renderPackageGraphVisuals` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java` | 391 | `printHead` |
+| `../report/src/main/java/org/hjug/refactorfirst/report/ReportWriter.java` | 14 | `writeReportToDisk` |
+| `../refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstHtmlReport.java` | 60 | `execute` |
+| `../cli/src/main/java/org/hjug/refactorfirst/ReportCommand.java` | 96 | `call` |
+
+## Background
+
+RefactorFirst is a developer/CI-side static-analysis tool (Maven plugin, CLI) that mines a project's git history with
+JGit, ranks design "disharmonies" by change-proneness vs. effort, and emits an interactive HTML report
+(`target/site/refactor-first-report.html`). The HTML report's Class Map / Cycle Map / Package Map sections render the
+codebase's dependency graphs client-side: the Graphviz DOT text for each graph is embedded into the page inside a
+JavaScript template literal, `const _dot = ` + DOT + `;` within a plain `
+
+This is a classic (non-module, dependency-free) script block: the `const` initializer executes at page load, with no
+click and no CDN dependency. Three independent breakout classes make `remote.origin.url` execute attacker-controlled
+code:
+
+1. **`${…}` interpolation** — an origin URL of `https://evil/x${}y` becomes a live substitution expression when
+ the template literal is evaluated during the
+ `const` initialization; the surrounding statement stays syntactically valid, so the whole script parses and the
+ payload runs (arbitrary expression: cookie read/exfil,
+ `fetch`, DOM manipulation).
+2. **Backtick breakout** — `https://evil/x` + backtick + `++` + backtick + `y`
+ closes the literal and turns the payload into an operand of string concatenation — still a valid expression, executed
+ during initializer evaluation.
+3. **`` HTML breakout** — an origin URL containing `` terminates the `` payload truncates both graph script elements at `URL="` (Chrome reports
+`SyntaxError: Unexpected end of input`), leaves the consts unassigned, and turns the payload into live markup — 28
+`img[onerror]` elements firing 28 zero-interaction dialogs in real Chrome on a plain file:// open (and the same count on
+a loopback-hosted http:// origin, where the payload reads `document.domain`). A benign-URL control fixture produces a
+clean report with zero alerts/dialogs/injected markup. The no-disharmony branch ("Congratulations…" report) still
+carries the payload in `classGraph_dot` and executes it.
+
+Relationship to sibling finding: this is the same root cause (unvalidated
+`remote.origin.url` reaching report sinks) as BUG-R2-S2-A1-H1, whose report documents this DOT sink as its "Sink 4"; the
+present finding carries the JS/DOT-sink chain (ADV-R2-S2-A2) with the full template-literal analysis. The two should
+share one POC harness (one malicious repo exercises A1's `href` sinks and this DOT sink simultaneously); consolidation
+is routed to decide. Real-scenario impact was assessed separately for each (exp/ of each finding): the impact ceiling is
+identical (session-class on authenticated hosting origins, conditional; bounded on the local view; nothing on the
+step-summary surface), so both are calibrated to CVSS 7.4 / EV 7 / P1 / I:S; the differentiators are coverage (this
+sink: full report + site artifact only, the sibling: both report types incl. empty repos) and payload profile (this
+sink: unconstrained JS grammar with zero DOM footprint; the sibling: unquoted- attribute grammar with injected
+elements). A sink-isolated `${…}` payload proved this finding independently sufficient for the full impact class
+(exp/exp.md, EXP-R6-E2).
+
+## Attack
+
+The attacker is the provider of the codebase being analyzed. Delivery paths that put a hostile `remote.origin.url` into
+the victim's `.git/config`: (1) distribute the project as an archive that includes a pre-built `.git` directory (vendor
+drop, file share, email attachment) — `.git/config` is a plain attacker-authored file with no git-client sanitization;
+(2) induce the victim to clone from an attacker-operated remote whose URL string itself carries the payload (git stores
+the clone URL verbatim; the attacker controls the server's routing so the clone succeeds); (3) a superproject whose
+tracked
+`.gitmodules` supplies the submodule origin URL — after `git submodule update --init`
+the submodule's `.git/modules//config` carries it verbatim, and JGit's
+`findGitDir` follows the `.git` pointer file when the report is run inside the submodule directory. The victim then runs
+the tool's normal workflow — `mvn
+org.hjug.refactorfirst.plugin:refactor-first-maven-plugin:htmlReport` (the README headline flow) or the `mvn site`
+integration (both verified dynamically to emit and execute the sink; the CLI's HTML default is not a realistic vector —
+no tested CLI jar can start, pre-existing picocli bug; `simpleHtmlReport` emits no graph blocks at all) — and opens the
+generated report, which the tool itself advertises ("View the report at target/site/refactor-first-report.html"). The
+attacker's JavaScript executes the moment the page loads, once per rendered graph vertex with a source-file mapping (8
+sites on a 10-class fixture; scales with repo size), and for the `${…}`/backtick shapes with ZERO DOM footprint — no
+injected elements, no attribute changes, no extra console errors, graphs keep rendering (measured vs a benign control) —
+making this the stealthiest injection channel of the report family. What the payload achieves is set by which surface
+renders the artifact (all measured, EXP-R6-E2): on the default local `file://` view — redirect/phishing in the trusted
+report context plus report-content beacons to attacker infrastructure, with local-file reads blocked by the browser and
+no cookie surface; on an authenticated web origin that publishes the report (the Jenkins-HTML-Publisher /
+CI-artifact-viewer / internal-dashboard pattern for Maven HTML reports, incl. published `mvn site` artifacts) — full
+session-class compromise: the payload's same-origin fetch rides every viewer's session (HttpOnly notwithstanding),
+exfiltrates the origin's protected data plus non-HttpOnly cookies, and propagates to every viewer of the shared
+artifact; on session-less web origins (raw-CDN hosting like the project's own rawcdn.githack.com sample) — arbitrary
+rendering/redirect for every viewer plus direct viewer-IP/UA beacons; on the README step-summary CI flow — nothing: that
+flow's simpleHtmlReport artifact contains no graph blocks, and the step-summary surface sanitizes script content anyway.
+The attacker also fully controls the repository content, so the two cross-referencing classes that place a
+`URL="…"` attribute into the class-map DOT are guaranteed present, and the payload-carrying `const` block is
+emitted regardless of graph size (the 4000-node threshold only gates the SVG image).
+
+### Payload
+
+The payload is a single line in the analyzed repository's `.git/config`:
+`[remote "origin"] url = `. Working shapes (all avoid the `git@` prefix so the cosmetic rewrites leave them
+intact; none contains `.git` or `gitlab`, so only the harmless `/blob//` suffix is appended behind the payload):
+(a) template-literal interpolation: `https://evil/x${alert(document.domain)}y` — the substitution executes when
+`const classGraph_dot = …` is evaluated at page load; (b) backtick breakout: `https://evil/x` + `` ` `` +
+`+alert(document.domain)+` + `` ` ``
+
++ `y` — payload becomes a concatenation operand, still executed at load; (c) script-block breakout: `https://evil/xy` — the `` sequence ends the script element at HTML parse time and the injected
+ `` fires without any click. Shape (a)
+ additionally requires nothing beyond two cross-referencing Java classes in the repo so that at least one graph vertex
+ renders with its `URL="…"` attribute.
+
+## Data flow
+
+### Step 1 — `refactor-first-maven-plugin/src/main/java/org/hjug/mavenreport/RefactorFirstHtmlReport.java:60-77`
+
+Victim runs the htmlReport mojo on the analyzed project (baseDir = project basedir) — the entry point that generates the
+FULL report carrying this sink; the mvn site integration (RefactorFirstMavenReport.java:71-86, output via
+mainSink.rawText — unescaped, verified dynamically) renders the same code, and the CLI's HTML default would too but no
+tested CLI jar can start (pre-existing picocli bug). The simpleHtmlReport goal funnels into SimpleHtmlReport, whose
+graph-render overrides return empty strings — it emits no DOT blocks and is not a carrier of this sink (verified: 0 _dot
+blocks in its artifact).
+
+### Step 2 — `change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java:84-85`
+
+getOriginUrl () returns gitRepository.getConfig ().getString ("remote", "origin", "url") — the raw, free-form
+remote.origin.url value from the analyzed repository's .git/config (JGit returns it verbatim; no validation).
+
+### Step 3 — `change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java:88-108`
+
+getRepoUrl () applies only cosmetic transforms — git@→https:// rewrite (only for git@-prefixed strings), global .git
+substring removal, /blob|/-/blob|/src// suffix append. Backtick, ${, ", <, >, / are untouched; no scheme
+allow-list, no character validation, no encoding, no escaping.
+
+### Step 4 — `report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java:360,417-421`
+
+generateReport () obtains the raw repoUrl via getRepoUrl (projectBaseDir) and passes the unmodified string into
+renderClassGraphVisuals (:369 no-disharmony branch, :381), renderPackageGraphVisuals (:390) and renderCycles (:410).
+
+### Step 5 — `report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java:561-584,941-958,995-1016`
+
+buildClassGraphDot/buildClassCycleDot/buildPackageGraphDot wrap the whole DOT text as a JavaScript template literal
+expression: dot.append ("`strict digraph G {\n") (:564/:947/:998) … dot.append("}`;") (:582/:957/:1015).
+
+### Step 6 — `report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java:623-631`
+
+Sink — hyperlinkClassForDot () returns URL="" target="_blank" per rendered vertex (call site :604 in
+renderClassVertices): repoUrl is concatenated raw; the path half is Path.toUri () percent-encoded
+(AbstractDependencyVisitor.java:78,98,107), so repoUrl is the only raw component inside the literal.
+
+### Step 7 — `report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java:512-521,489,923,972`
+
+generateGraphButtons () emits — a classic, dependency-free script
+block whose const initializer (the template literal) is evaluated at page load. The block is emitted unconditionally for
+the class map (:489), cycle maps (:923) and package map (:972); the dotGraphThreshold=4000 checks (:502/:928/:985) gate
+only the separate vizdom SVG image, never this block (POC-verified: all fixture graphs below threshold and rendered).
+The repoUrl payload lands in the class-map and cycle-map blocks (hyperlinkClassForDot URL attributes); the package-map
+block carries no URL attribute (renderPackageVertices) and is not a payload carrier (POC-verified). ${…} in repoUrl
+executes as a substitution; a backtick closes the literal into a concatenation; a sequence terminates the
+script element at HTML parse time and the following markup executes as live HTML.
+
+### Step 8 — `report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java:107-112`
+
+Optional minifyHtml post-pass (default false in the Maven mojo field initializer and the CLI) — a semantics-preserving
+minifier, not an escaper; string/template-literal content survives it.
+
+### Step 9 — `report/src/main/java/org/hjug/refactorfirst/report/ReportWriter.java:14-42`
+
+writeReportToDisk () writes the HTML verbatim to target/site/refactor-first-report.html; the report has no
+Content-Security-Policy (HtmlReport.printHead :391-404 emits only CDN script/link tags), so the injected JavaScript
+executes when the victim opens the file.
+
+## Fix / patch notes
+
+diff --git a/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java
+b/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java ---
+a/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java +++
+b/change-proneness-ranker/src/main/java/org/hjug/git/GitLogReader.java @@ -95,6 +95,13 @@ public class GitLogReader
+implements AutoCloseable { if (originUrl == null) { return ""; }
+
++ // Only well-formed web URLs may be embedded in generated reports as
++ // source-link prefixes; anything else (javascript:, data:, file:, ...)
++ // is rejected outright.
++ if (!originUrl.startsWith("https://") && !originUrl.startsWith("http://")) {
++ return "";
++ }
+
+ repoUrl = originUrl.replace(".git", "");
+
+@@ -105,6 +112,11 @@ public class GitLogReader implements AutoCloseable { } else { repoUrl = repoUrl + "/blob/" +
+getCurrentCommitHash () + "/"; }
+
+- return repoUrl;
+
++ // Keep only RFC 3986 URL characters: drops every character that is
++ // markup- or JS-significant outside a URL (space, ", <, >, `, {, }, \),
++ // so the value cannot break out of a ' was claimed to terminate the generateGraphButtons script element at the HTML
+layer, but such a name never reaches the report — rewrite-kotlin's attributed FQN construction rewrites every '/' to '.'
+(and inner '.' to '$', wrapping the segment in backticks), so the payload class yields FQN `csa.` + backtick +
+`x<.script>` + backtick, whose last-dot-derived package contains a backtick
+and can never be a declared Kotlin package; removeClassesNotInCodebase drops the vertex, no cycle forms, nothing renders
+(verified end-to-end: zero payload occurrences, no Cycle Map section). Structurally, after '/'→'.' mangling,
+getClassName ()'s last-'.' split can never leave a '/' in the rendered cycle name, so the script-element breakout is
+unreachable via cycle names on this snapshot (the cycle-map script block remains breakable by '' through the
+git remote URL baked into the DOT vertices — that vector is BUG-R2-S2-A2-H2's scope). The pre-analysis's parse-blocking
+argument was verified correct for the pure-JS positions: for quote- and markup-bearing payloads the cycle-map classic
+script (const _dot = ...) and module script (parser.parse (_dot) / getElementById ("") / svgPanZoom ('#
+svg')) are JS SyntaxErrors while every other inline block (including the class-map script, where the payload sits inside
+a template-literal string) parses — and the onclick pure-JS variant is additionally execution-blocked because showPopup
+receives garbage popupIds — yet neither property constrains the HTML tokenizer, which is where all confirmed breakouts
+occur. There is no Content-Security-Policy in the generated report, the report loads all chart/graph libraries from
+public CDNs (designed for online viewing), minifyHtml defaults to false, and analyzeCycles defaults to true on both CLI
+and Maven — so the vulnerable section renders in the default configuration for any repository containing a crafted
+Kotlin cycle.
+
+## Attack
+
+Attacker = author of a public repository or PR contributor; victim = a developer or CI pipeline that runs RefactorFirst
+on the repository and anyone who views the generated report — the tool's documented workflow (delivery verified
+end-to-end from the victim's side for both a direct clone and a fork-PR checkout of an OSS project). Steps: (1) attacker
+commits the crafted .kt file (two payload-named classes referencing each other); (2) victim clones or checks out the PR
+and runs a default analysis — CLI `refactorfirst -b .` (default -t HTML), the README's primary Maven command
+`mvn org.hjug.refactorfirst.plugin:…:htmlReport`, or `mvn site` — producing target/site/refactor-first-report.html whose
+Cycle Map section embeds the payload in the popup-button onclick attributes, the graph div id, the popup div ids, the
+`const _dot` script block and the popup-button element bodies (19 payload occurrences in the measured fixture
+report); (3) any viewer opens the report in a browser (its designed mode — all chart/graph libraries load from CDNs):
+the zero-interaction variant's injected (live markup in the popup-button bodies) executes automatically at
+page load with no interaction — measured in the impact assessment: first attacker beacon 61-75ms after page load, before
+domContentLoaded — while the hover-gated variant A fires onmouseover on the popup buttons and the always-visible
+cycle-map div. For hosted copies (CI artifact servers / Jenkins HTML Publisher / mvn-site deployments /
+rawcdn-githack-style publishing — the project itself publishes a sample report this way) the payload is stored XSS
+against every viewer of that origin: measured on a simulated authenticated hosting origin, two independent viewers were
+each compromised at page load (cookie theft plus same-origin exfiltration of the origin's private data including their
+HttpOnly session ids); for local file:// viewing the script still executes in the weaker file:// origin but with bounded
+impact (no cookie surface, no same-origin data — measured). The report file is persistent, so the payload fires on every
+future view. The README's $GITHUB_STEP_SUMMARY flow is NOT an execution surface for this finding: it produces the simple
+report (no cycle-map visuals — measured) and GitHub's user-content sanitizer strips every script/handler shape even from
+the full report (measured against the documented allowlist).
+
+### Payload
+
+A Kotlin source file in the analyzed repository declaring, in two different packages, two mutually-referencing classes
+named with the same backticked payload identifier, so the two form a dependency cycle and either vertex can become the
+cycle name. Variant A (attribute breakout, hover-gated): class `q" onmouseover="alert(1)" x="` with a mirror in a second
+package. Zero-interaction variant (the impact-governing shape, measured end-to-end in the impact assessment): class
+`x` and a mirror — quote-free so it stays inert in every attribute-context sink and renders live
+markup in the popup-button element bodies; arbitrary JavaScript is deliverable despite the pipeline's character
+constraints by smuggling dots/slashes/colons/question-marks/quotes as named character references (. / :
+? ' = — decoded by the HTML parser in element bodies and unquoted attribute values) and
+base64-encoding the payload body (eval (atob ('…')) — measured executing in Chromium). Payload identifiers
+must avoid backtick/newline (Kotlin grammar), '.',
+'$', '/' and '?' — rewrite-kotlin 8.90.4 rewrites '/'→'.' and inner '.'→'$', strips '?', and drops '/'-bearing vertices
+entirely, and getClassName () strips everything up to the last '.'; the finding's originally documented
+`x` shape violates these constraints (contains '/' and '.') and does
+not survive the pipeline (dynamically verified) — the script-element breakout must not be claimed through the
+cycle-name path.
+
+## Data flow
+
+### Step 1 —
+
+`codebase-graph-builder/src/main/java/org/hjug/graphbuilder/graphbuilder/KotlinSourceFileGraphBuilder.java:67-101`
+
+Source: unconditional walk of the analyzed repository for *.kt/*.kts, each parsed with rewrite-kotlin's KotlinParser
+(partial trees visited even on parse errors). A Kotlin backticked class name may contain ", <, >, / and spaces (anything
+but backtick/newline) — attacker (repo author) fully controls it.
+
+### Step 2 —
+
+`org/openrewrite/kotlin/internal/KotlinTreeParserVisitor.java:3826-3850 (pinned rewrite-kotlin 8.90.4 source) + org/openrewrite/kotlin/KotlinTypeSignatureBuilder.kt:712-728`
+
+Identifier/FQN construction: createIdentifier strips only the leading/trailing backtick and stores the inner text
+verbatim as the J.Identifier simple name; the attributed JavaType FQN is built via convertClassIdToFqn which only
+rewrites '.'→'$' and '/'→'.'. Quotes, angle brackets and spaces pass into jcd.getSimpleName () and
+type.getFullyQualifiedName () verbatim. Dynamically probed (production parser config, poc/probe-*.log): the quote
+payload attributes to FQN cqa.q" onmouseover="alert (1)" x=" and the markup payload to
+cza.x — both verbatim; a '/'-bearing name attributes to csa.
+`x<.script>` (backtick-wrapped), whose last-dot-derived package contains a
+backtick and can never be a declared package, so the vertex is dropped in the next step — the '/'→'.' rewrite therefore
+constrains cycle-name payloads to be slash-free.
+
+### Step 3 —
+
+`codebase-graph-builder/src/main/java/org/hjug/graphbuilder/visitor/KotlinDependencyVisitor.java:99-118 + GraphDependencyCollector.java:48-63,144-146`
+
+Vertex registration: registerClassVertex (raw FQN) / addClassDependency (raw FQN, dep FQN) add the payload string to the
+JGraphT class graph with no character validation (contrast: the Java visitor's un-attributed fallback
+enforces [A-Za-z_$][A-Za-z0-9_$]*). Kotlin↔Kotlin edges proven by KotlinGraphBuilderTest; the vertex survives
+finalizeDto's removeClassesNotInCodebase because its package is declared.
+
+### Step 4 —
+
+`graph-algorithms/src/main/java/org/hjug/dsm/CircularReferenceChecker.java:47-84 + cost-benefit-calculator/src/main/java/org/hjug/cbc/CycleRanker.java:60-107`
+
+Cycle detection: getCycles keys each unique cycle by one of its vertices (2-vertex/2-edge cycles pass the vertexCount>
+1 && edgeCount>1 gate); identifyRankedCycles copies that key into RankedCycle.cycleName. Naming BOTH cycle classes with
+the payload (different packages) defeats the HashMap-order choice of key vertex; rawPriority=vertexSet.size () ordering
+plus renderCycles' limit (1) renders the attacker's (only) cycle.
+
+### Step 5 —
+
+`report/src/main/java/org/hjug/refactorfirst/report/SimpleHtmlReport.java:483-487,783-798 + report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java:916-920`
+
+renderCycles (limit 1) → renderSingleCycle → renderClassCycleVisuals, where the sole sanitization is getClassName (...)
+.replace ("$","_") — inert for a payload without '.' and '$'. analyzeCycles defaults to true (CLI ReportCommand.java:
+38-41, Maven RefactorFirstHtmlReport.java:29-30); renderClassCycleVisuals is implemented only in the default HtmlReport.
+
+### Step 6 — `report/src/main/java/org/hjug/refactorfirst/report/HtmlReport.java:1070-1082`
+
+Sink (attribute context, primary): generate2DPopup/generateForce3DPopup emit onclick="showPopup ('popup-',
+'graph-container-', _dot )" on visible