feat(privacy): surface the customer-data scan above the hardware report

- Move the "Customer data" panel to the top of the data section (above the
  chart iframe); header + customer guess always visible, findings table
  collapsed by default and expandable.
- Add a chart top-notice (above Board/CPUs) summarizing the scan via the
  viewer's standard NoticeTitle/NoticeBody - interim until chart custom panels.
- Allowlist ieisystem.com (IEI = Inspur brand infrastructure).
- Add chart-custom-panels-spec.md: a reusable, versioned contract proposal for
  host-supplied panels across every app embedding reanimator/chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-09-02 16:19:57 +03:00
co-authored by Claude Sonnet 5
parent 4a4910f207
commit fb0b0e3c55
6 changed files with 369 additions and 33 deletions
+69 -1
View File
@@ -85,7 +85,13 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
return
}
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
opts := chartviewer.RenderOptions{}
if nt, nb := privacyNotice(result.PrivacyScan); nt != "" {
opts.NoticeTitle = nt
opts.NoticeBody = nb
}
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, opts)
if err != nil {
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
return
@@ -95,6 +101,68 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(rewriteChartStaticPaths(html))
}
// privacyNotice renders the customer-data scan summary as the chart viewer's
// top notice panel (above the Board/CPUs sections). Uses the viewer's standard
// NoticeTitle/NoticeBody mechanism - no chart changes. Full findings stay in the
// "Customer data" panel and GET /api/privacy-scan.
func privacyNotice(scan *models.PrivacyScan) (title, body string) {
if scan == nil || (scan.Summary.Total == 0 && len(scan.Customers) == 0) {
return "", ""
}
title = "Customer data detected"
if len(scan.Customers) > 0 {
title += " - likely " + scan.Customers[0].Domain
}
var b strings.Builder
fmt.Fprintf(&b, "%d finding(s)", scan.Summary.Total)
sev := make([]string, 0, 3)
if scan.Summary.High > 0 {
sev = append(sev, fmt.Sprintf("%d high", scan.Summary.High))
}
if scan.Summary.Medium > 0 {
sev = append(sev, fmt.Sprintf("%d medium", scan.Summary.Medium))
}
if scan.Summary.Low > 0 {
sev = append(sev, fmt.Sprintf("%d low", scan.Summary.Low))
}
if len(sev) > 0 {
fmt.Fprintf(&b, " (%s)", strings.Join(sev, ", "))
}
fmt.Fprintf(&b, " across %d file(s).", scan.FilesScanned)
if cats := topCategories(scan.Summary.ByCategory, 4); len(cats) > 0 {
fmt.Fprintf(&b, " Categories: %s.", strings.Join(cats, ", "))
}
b.WriteString(` Review the "Customer data" panel and sanitize this dump before sharing it.`)
return title, b.String()
}
func topCategories(byCategory map[string]int, n int) []string {
type kv struct {
k string
v int
}
items := make([]kv, 0, len(byCategory))
for k, v := range byCategory {
items = append(items, kv{k, v})
}
sort.Slice(items, func(i, j int) bool {
if items[i].v != items[j].v {
return items[i].v > items[j].v
}
return items[i].k < items[j].k
})
out := make([]string, 0, n)
for i, it := range items {
if i >= n {
break
}
out = append(out, it.k)
}
return out
}
func currentReanimatorSnapshotBytes(result *models.AnalysisResult) ([]byte, error) {
reanimatorData, err := exporter.ConvertToReanimator(result)
if err != nil {