From f6962134d9bc27e848a02e4e1d0aafd850498819 Mon Sep 17 00:00:00 2001
From: ai-anant
Date: Mon, 27 Jul 2026 17:06:58 +0530
Subject: [PATCH 01/19] =?UTF-8?q?feat:=20add=20Risk=20Portfolio=20dashboar?=
=?UTF-8?q?d=20(insights3)=20=E2=80=94=20domain-segmented=20due-diligence?=
=?UTF-8?q?=20view?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design philosophy: three-column risk-domain breakdown (Security / Operational /
Compliance) each with score bar, key stats, and traffic-light badge. Overall
grade header with plain-English verdict.
- Security Posture: vulns, CVE dwell, malware, dep confusion
- Operational Health: EOL, version drift, repo activity, SBOM coverage
- License & Compliance: copyleft risk, unknown licenses, pinned actions
- Each domain scored 0-100 with color-coded progress bar
- Plain-English overall verdict tailored to risk level
---
insights3.html | 182 ++++++++++++++++++++++++++++++++++++++
js/insights3-page.js | 203 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 385 insertions(+)
create mode 100644 insights3.html
create mode 100644 js/insights3-page.js
diff --git a/insights3.html b/insights3.html
new file mode 100644
index 0000000..151170a
--- /dev/null
+++ b/insights3.html
@@ -0,0 +1,182 @@
+
+
+
+
+
+ SBOM Play — Risk Portfolio
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+ Risk Portfolio
+ Risk-domain segmentation for due-diligence reviews
+
+
+
+ Loading...
+
+
+
+
+
No analysis data found.
Run a scan first.
+
+
+
Assessing risk portfolio…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/insights3-page.js b/js/insights3-page.js
new file mode 100644
index 0000000..6d99244
--- /dev/null
+++ b/js/insights3-page.js
@@ -0,0 +1,203 @@
+/**
+ * Risk Portfolio (insights3-page.js) — domain-segmented risk view.
+ * Three columns: Security, Operational, Compliance.
+ * Each has a score bar, key stats, and traffic-light badge.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('Portfolio: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+
+ const name = selector.value;
+ let data;
+ if (!name || name === '') {
+ data = await storageManager.getCombinedData();
+ } else {
+ data = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!data || !data.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ renderPortfolio(Agg.buildInsights(data.data));
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+ await loadAnalysesList();
+
+ /* ------------------------------------------------------------------ */
+ /* Helpers */
+ /* ------------------------------------------------------------------ */
+ function badge(val, good, warn) {
+ if (val <= good) return ' Good ';
+ if (val <= warn) return ' Moderate ';
+ return ' Needs Attention ';
+ }
+
+ function scoreBar(pct, color) {
+ return ``;
+ }
+
+ function statRow(label, value, colorClass) {
+ return `${esc(label)} ${value}
`;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Render */
+ /* ------------------------------------------------------------------ */
+ function renderPortfolio(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const drift = ins.driftStats;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+ const sc = ins.supplyChain;
+ const dwell = ins.vulnAgeStats.directDwellMedian;
+ const hyg = ins.repoHygiene;
+
+ /* ---- Security domain ---- */
+ const secScore = Math.max(0, 100 -
+ (ch.critical * 20 + ch.high * 8 +
+ (sc.malwareCount * 25) +
+ (dwell !== null && dwell > 90 ? 15 : dwell !== null && dwell > 30 ? 8 : 0)));
+ const secColor = secScore >= 70 ? 'var(--color-green)' : secScore >= 45 ? 'var(--color-yellow)' : 'var(--color-red)';
+
+ let secHtml = `
+
+
Security Posture
+ ${scoreBar(secScore, secColor)}
+ ${statRow('Crit + High Vulns', ch.total, ch.total > 0 ? 'text-danger fw-bold' : '')}
+ ${statRow('CVE Dwell (median)', dwell !== null ? dwell + ' days' : '--')}
+ ${statRow('Malware Advisories', sc.malwareCount, sc.malwareCount > 0 ? 'text-danger fw-bold' : '')}
+ ${statRow('Dep Confusion Risks', sc.depConfusionCount, sc.depConfusionCount > 0 ? 'text-warning' : '')}
+
${badge(100 - secScore, 30, 55)}
+
`;
+
+ /* ---- Operational domain ---- */
+ const staleRepos = hyg.activityBuckets['> 1 year'] || 0;
+ const opScore = Math.max(0, 100 -
+ (eol.eolCount * 3) +
+ (drift.buckets.major.total * 2) +
+ (staleRepos * 5) +
+ (hyg.noSbom * 3));
+ const opScoreClamped = Math.max(0, Math.min(100, opScore));
+ const opColor = opScoreClamped >= 70 ? 'var(--color-green)' : opScoreClamped >= 45 ? 'var(--color-yellow)' : 'var(--color-red)';
+
+ let opHtml = `
+
+
Operational Health
+ ${scoreBar(opScoreClamped, opColor)}
+ ${statRow('Repositories Analyzed', ins.totalRepos)}
+ ${statRow('With SBOM', ins.reposWithSbom)}
+ ${statRow('EOL Components', eol.eolCount, eol.eolCount > 0 ? 'text-danger fw-bold' : '')}
+ ${statRow('Major Drift', drift.buckets.major.total, drift.buckets.major.total > 0 ? 'text-warning' : '')}
+ ${statRow('Repos Inactive >1yr', staleRepos, staleRepos > 0 ? 'text-warning' : '')}
+
${badge(100 - opScoreClamped, 30, 55)}
+
`;
+
+ /* ---- Compliance domain ---- */
+ const compScore = Math.max(0, 100 - (lic.highRisk * 8) - (sc.unpinnedActions * 2));
+ const compColor = compScore >= 70 ? 'var(--color-green)' : compScore >= 45 ? 'var(--color-yellow)' : 'var(--color-red)';
+
+ let compHtml = `
+
+
License & Compliance
+ ${scoreBar(compScore, compColor)}
+ ${statRow('High-Risk Licenses', lic.highRisk, lic.highRisk > 0 ? 'text-warning fw-bold' : '')}
+ ${statRow('Copyleft (direct)', lic.copyleft.direct, lic.copyleft.direct > 0 ? 'text-warning' : '')}
+ ${statRow('Copyleft (transitive)', lic.copyleft.transitive)}
+ ${statRow('Unknown Licenses', lic.unknown.total, lic.unknown.total > 0 ? 'text-muted' : '')}
+ ${statRow('Unpinned GH Actions', sc.unpinnedActions, sc.unpinnedActions > 0 ? 'text-warning' : '')}
+
${badge(100 - compScore, 30, 55)}
+
`;
+
+ /* ---- Overall verdict ---- */
+ const overallScore = td.score100;
+ const verdictClass = overallScore >= 75 ? 'text-success' : overallScore >= 55 ? 'text-warning' : 'text-danger';
+ const verdictText = overallScore >= 75 ? 'Low risk profile — standard monitoring advised.'
+ : overallScore >= 55 ? 'Moderate risk — review flagged items and set remediation plan.'
+ : 'Elevated risk — active remediation recommended before proceeding.';
+
+ const legendHtml = `
+ Good
+ Moderate
+ Needs Attention
+
`;
+
+ const verdictHtml = `
+
${esc(verdictText)}
+
Tech-Health Score ${overallScore}/100 (Grade ${td.grade})
+
`;
+
+ safe(content, `
+
+ ${legendHtml}
+
+
${secHtml}
+
${opHtml}
+
${compHtml}
+
+ ${verdictHtml}
+ `);
+ }
+
+})();
From c3d30ec5ecb3fec07b4a943fada43949b5bfa3d4 Mon Sep 17 00:00:00 2001
From: ai-anant
Date: Mon, 27 Jul 2026 17:07:47 +0530
Subject: [PATCH 02/19] =?UTF-8?q?feat:=20add=20Org=20Report=20Card=20dashb?=
=?UTF-8?q?oard=20(insights4)=20=E2=80=94=20school-report=20format=20for?=
=?UTF-8?q?=20execs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design philosophy: universal report-card metaphor. Overall GPA + 6 subjects
with letter grades, status badges (Excellent/Good/Fair/Poor), and plain-English
comments. Everyone understands a report card.
- Vulnerability Management
- Dependency Freshness
- License Compliance
- Code Activity
- Supply Chain Security
- Maintenance Hygiene
- Overall GPA computed from subject grades
---
insights4.html | 199 ++++++++++++++++++++++++++++++++++
js/insights4-page.js | 252 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 451 insertions(+)
create mode 100644 insights4.html
create mode 100644 js/insights4-page.js
diff --git a/insights4.html b/insights4.html
new file mode 100644
index 0000000..839b5fc
--- /dev/null
+++ b/insights4.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+ SBOM Play — Org Report Card
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
No analysis data found.
Run a scan first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/insights4-page.js b/js/insights4-page.js
new file mode 100644
index 0000000..ae9a15d
--- /dev/null
+++ b/js/insights4-page.js
@@ -0,0 +1,252 @@
+/**
+ * Org Report Card (insights4-page.js) — school-report format.
+ * Overall GPA + subject-level letter grades with plain-English comments.
+ * Everyone understands a report card.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+ const dateEl = document.getElementById('reportDate');
+ const scopeEl = document.getElementById('reportScope');
+
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('ReportCard: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+
+ const name = selector.value;
+ let data;
+ if (!name || name === '') {
+ data = await storageManager.getCombinedData();
+ } else {
+ data = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!data || !data.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ const scope = !name || name === '' ? 'All Analyses (combined)' : name;
+ scopeEl.textContent = scope;
+ dateEl.textContent = 'Generated ' + new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+
+ renderReportCard(Agg.buildInsights(data.data));
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+ document.getElementById('reportDate').textContent = 'Generated ' + new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ await loadAnalysesList();
+
+ /* ------------------------------------------------------------------ */
+ /* Subject grade logic */
+ /* ------------------------------------------------------------------ */
+ function subjectGrade(score100) {
+ if (score100 >= 90) return { grade: 'A', status: 'Excellent', statusClass: 'excellent' };
+ if (score100 >= 75) return { grade: 'B', status: 'Good', statusClass: 'good' };
+ if (score100 >= 55) return { grade: 'C', status: 'Fair', statusClass: 'fair' };
+ if (score100 >= 35) return { grade: 'D', status: 'Poor', statusClass: 'poor' };
+ return { grade: 'F', status: 'Needs Work', statusClass: 'poor' };
+ }
+
+ function computeSubjectGrades(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const drift = ins.driftStats;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+ const sc = ins.supplyChain;
+
+ /* Vulnerability Management */
+ const vulnScore = Math.max(0, 100 - (ch.critical * 20 + ch.high * 8));
+ const vg = subjectGrade(vulnScore);
+
+ /* Version Freshness (drift) */
+ const driftPct = drift.total > 0 ? (drift.covered / drift.total) : 1;
+ const driftGrade = Math.max(0, 100 -
+ (drift.buckets.major.total * 4) -
+ (drift.buckets.minor.total) +
+ Math.round(driftPct * 10));
+ const dg = subjectGrade(Math.min(100, driftGrade));
+
+ /* License Compliance */
+ const licCoverage = lic.permissive.total + lic.copyleft.total + lic.unknown.total;
+ const licScore = licCoverage > 0
+ ? Math.max(0, 100 - (lic.highRisk / licCoverage) * 80)
+ : 100;
+ const lg = subjectGrade(licScore);
+
+ /* Code Activity (hygiene) */
+ const hyg = ins.repoHygiene;
+ const activeRatio = ins.totalRepos > 0
+ ? (hyg.activityBuckets['Last 30 days'] + hyg.activityBuckets['30-90 days'] + hyg.activityBuckets['90-180 days']) / ins.totalRepos
+ : 0;
+ const activityScore = Math.round(activeRatio * 100);
+ const ag = subjectGrade(activityScore);
+
+ /* Supply Chain Security */
+ const scScore = Math.max(0, 100 -
+ (sc.malwareCount * 30) -
+ (sc.depConfusionCount * 10) -
+ (eol.eolCount * 3) -
+ (sc.unpinnedActions * 2));
+ const sg = subjectGrade(scScore);
+
+ /* Maintenance Hygiene (EOL + age + drift) */
+ const oldDeps = ins.ageStats.buckets.filter(b => b.maxMonths >= 24).reduce((s, b) => s + b.total, 0);
+ const maintScore = ins.totalDeps > 0
+ ? Math.max(0, 100 - ((oldDeps / ins.totalDeps) * 50) - ((drift.buckets.major.total / (ins.totalDeps || 1)) * 30))
+ : 100;
+ const mg = subjectGrade(maintScore);
+
+ return {
+ vulnerability: { ...vg, icon: 'fas fa-shield-alt', label: 'Vulnerability Management', comment: vulnComment(ch, ins.vulnAgeStats.directDwellMedian) },
+ freshness: { ...dg, icon: 'fas fa-sync-alt', label: 'Dependency Freshness', comment: driftComment(drift) },
+ license: { ...lg, icon: 'fas fa-balance-scale', label: 'License Compliance', comment: licenseComment(lic) },
+ activity: { ...ag, icon: 'fas fa-code-branch', label: 'Code Activity', comment: activityComment(hyg, ins.totalRepos) },
+ supplyChain: { ...sg, icon: 'fas fa-shield-virus', label: 'Supply Chain Security', comment: supplyChainComment(sc, eol) },
+ maintenance: { ...mg, icon: 'fas fa-tools', label: 'Maintenance Hygiene', comment: maintenanceComment(oldDeps, ins.totalDeps) }
+ };
+ }
+
+ /* Comments */
+ function vulnComment(ch, dwell) {
+ if (ch.total === 0) return 'No critical or high vulnerabilities.';
+ let c = `${ch.total} critical/high vulns`;
+ if (dwell !== null) c += `, median dwell ${dwell} days`;
+ return c + '.';
+ }
+ function driftComment(drift) {
+ if (drift.total === 0) return 'No dependency data.';
+ if (drift.buckets.major.total === 0) return 'All dependencies at current versions.';
+ return `${drift.buckets.major.total} major updates behind — ${drift.coveragePct}% coverage.`;
+ }
+ function licenseComment(lic) {
+ const total = lic.permissive.total + lic.copyleft.total + lic.unknown.total;
+ if (total === 0) return 'No license data.';
+ if (lic.highRisk === 0) return `All ${total} dependencies have permissive licenses.`;
+ return `${lic.highRisk} high-risk license(s) among ${total} dependencies.`;
+ }
+ function activityComment(hyg, total) {
+ const active = hyg.activityBuckets['Last 30 days'] + hyg.activityBuckets['30-90 days'] + hyg.activityBuckets['90-180 days'];
+ const pct = total > 0 ? Math.round(active / total * 100) : 0;
+ if (total === 0) return 'No repositories.';
+ return `${pct}% of repos pushed within 6 months (${active}/${total}).`;
+ }
+ function supplyChainComment(sc, eol) {
+ const issues = [];
+ if (sc.malwareCount > 0) issues.push(`${sc.malwareCount} malware`);
+ if (sc.depConfusionCount > 0) issues.push(`${sc.depConfusionCount} dep confusion`);
+ if (eol.eolCount > 0) issues.push(`${eol.eolCount} EOL`);
+ if (sc.unpinnedActions > 0) issues.push(`${sc.unpinnedActions} unpinned actions`);
+ if (issues.length === 0) return 'No supply chain red flags.';
+ return issues.join(', ') + '.';
+ }
+ function maintenanceComment(oldDeps, total) {
+ if (total === 0) return 'No dependencies.';
+ const pct = Math.round(oldDeps / total * 100);
+ if (oldDeps === 0) return 'All dependencies are recent (< 2 years old).';
+ return `${oldDeps} deps (${pct}%) are over 2 years old.`;
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Render */
+ /* ------------------------------------------------------------------ */
+ function renderReportCard(ins) {
+ const td = ins.techDebt;
+ const subjects = computeSubjectGrades(ins);
+
+ /* GPA */
+ const gpaMap = { A: 4, B: 3, C: 2, D: 1, F: 0 };
+ const points = Object.values(subjects).map(s => gpaMap[s.grade] || 0);
+ const gpa = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(1);
+
+ const gpaHtml = `
+
+
${td.grade}
+
+
${td.score100}/100
+
Tech-Health Score · GPA ${gpa}
+
+
`;
+
+ /* Table */
+ const rows = Object.values(subjects).map(s => `
+
+ ${esc(s.label)}
+ ${s.grade}
+ ${esc(s.status)}
+
+ `).join('');
+
+ const tableHtml = `
+
+
+
+ Subject
+ Grade
+ Status
+ Notes
+
+
+ ${rows}
+
`;
+
+ /* Legend */
+ const legendHtml = `
+ `;
+
+ safe(content, gpaHtml + tableHtml + legendHtml);
+ }
+
+})();
From 61030986d6fd208dbda915f83e0c0f2c2b562994 Mon Sep 17 00:00:00 2001
From: ai-anant
Date: Mon, 27 Jul 2026 17:08:27 +0530
Subject: [PATCH 03/19] =?UTF-8?q?feat:=20add=20One-Pager=20Snapshot=20dash?=
=?UTF-8?q?board=20(insights5)=20=E2=80=94=20single-viewport=20exec=20summ?=
=?UTF-8?q?ary?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design philosophy: absolute minimal — one screen, 6 tiles, one verdict line.
No scrolling, no tables, no charts. Designed to fit on a laptop screen or
slide within a deck.
- Giant grade letter with health score and one-line verdict
- 6 metric tiles in 3x2 grid: repos, C+H vulns, EOL, licenses, drift, CVE dwell
- Color-coded left-border indicator on each tile (green/yellow/red)
- 'Top Concerns' tag cloud shown only when issues exist
- 'All clear' message when nothing needs attention
---
insights5.html | 199 +++++++++++++++++++++++++++++++++++++++++++
js/insights5-page.js | 154 +++++++++++++++++++++++++++++++++
2 files changed, 353 insertions(+)
create mode 100644 insights5.html
create mode 100644 js/insights5-page.js
diff --git a/insights5.html b/insights5.html
new file mode 100644
index 0000000..3895f65
--- /dev/null
+++ b/insights5.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+ SBOM Play — Snapshot
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
No analysis data found.
Run a scan first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/insights5-page.js b/js/insights5-page.js
new file mode 100644
index 0000000..dc42967
--- /dev/null
+++ b/js/insights5-page.js
@@ -0,0 +1,154 @@
+/**
+ * Snapshot (insights5-page.js) — single-viewport executive summary.
+ * One glance, everything fits on screen. 6 metric tiles + verdict + concerns.
+ * The most minimal of the four variants.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('Snapshot: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+
+ const name = selector.value;
+ let data;
+ if (!name || name === '') {
+ data = await storageManager.getCombinedData();
+ } else {
+ data = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!data || !data.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ renderSnapshot(Agg.buildInsights(data.data));
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+ await loadAnalysesList();
+
+ /* ------------------------------------------------------------------ */
+ /* Render */
+ /* ------------------------------------------------------------------ */
+ function renderSnapshot(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const drift = ins.driftStats;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+ const sc = ins.supplyChain;
+ const hyg = ins.repoHygiene;
+ const dwell = ins.vulnAgeStats.directDwellMedian;
+
+ /* ---- Verdict line ---- */
+ const verdictColor = td.score100 >= 75 ? 'success'
+ : td.score100 >= 55 ? 'warning'
+ : 'danger';
+ const verdictText = td.score100 >= 75 ? 'Org is in good shape'
+ : td.score100 >= 55 ? 'Moderate concerns'
+ : 'Needs attention';
+
+ const verdictHtml = `
+
+
${td.grade}
+
Health Score ${td.score100}/100
+
${verdictText}
+
`;
+
+ /* ---- 6 metric tiles ---- */
+ function metric(value, label, icon, status) {
+ return `
+
+
${value}
+
${label}
+
`;
+ }
+
+ const vulnStatus = ch.total === 0 ? 'good' : (ch.critical > 0 ? 'bad' : 'warn');
+ const eolStatus = eol.eolCount === 0 ? 'good' : (eol.eolCount > 10 ? 'bad' : 'warn');
+ const licStatus = lic.highRisk === 0 ? 'good' : 'warn';
+ const driftStatus = drift.buckets.major.total === 0 ? 'good' : (drift.buckets.major.total > 20 ? 'bad' : 'warn');
+ const hygStatus = ins.reposWithoutSbom > ins.totalRepos / 2 ? 'warn' : 'good';
+ const dwellStatus = dwell === null ? 'neutral' : (dwell > 90 ? 'bad' : (dwell > 30 ? 'warn' : 'good'));
+
+ const metricsHtml = `
+
+ ${metric(ins.totalRepos, 'Repositories', 'fas fa-database', hygStatus)}
+ ${metric(ch.total, 'C+H Vulns', 'fas fa-shield-alt', vulnStatus)}
+ ${metric(eol.eolCount, 'EOL Components', 'fas fa-hourglass-end', eolStatus)}
+ ${metric(lic.highRisk, 'High-Risk Licenses', 'fas fa-balance-scale', licStatus)}
+ ${metric(drift.buckets.major.total, 'Major Drift', 'fas fa-code-branch', driftStatus)}
+ ${metric(dwell !== null ? dwell + 'd' : '--', 'CVE Dwell (median)', 'fas fa-clock', dwellStatus)}
+
`;
+
+ /* ---- Concerns line ---- */
+ const concerns = [];
+ if (ch.critical > 0) concerns.push(`${ch.critical} crit vulns`);
+ if (sc.malwareCount > 0) concerns.push(`${sc.malwareCount} malware`);
+ if (eol.eolCount > 5) concerns.push(`${eol.eolCount} EOL`);
+ if (lic.highRisk > 3) concerns.push(`${lic.highRisk} high-risk licenses`);
+ if (sc.depConfusionCount > 0) concerns.push(`${sc.depConfusionCount} dep confusion`);
+ if (dwell !== null && dwell > 90) concerns.push(`CVE dwell ${dwell}d`);
+
+ const concernsHtml = concerns.length > 0
+ ? `
+
Top Concerns
+
${concerns.map(c => `${esc(c)} `).join('')}
+
`
+ : `
+
No major concerns identified
+
`;
+
+ safe(content, verdictHtml + metricsHtml + concernsHtml);
+ }
+
+})();
From ae93b9fdbffdd957849bf6283fa2985bddfda786 Mon Sep 17 00:00:00 2001
From: ai-anant
Date: Mon, 27 Jul 2026 17:12:18 +0530
Subject: [PATCH 04/19] =?UTF-8?q?feat:=20add=20AI=20Insights=20dashboard?=
=?UTF-8?q?=20(insights-ai)=20=E2=80=94=20Chrome=20Gemini=20Nano-powered?=
=?UTF-8?q?=20executive=20analysis?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Uses Chrome's built-in Prompt API (window.ai.languageModel) to generate
natural-language executive briefs from SBOM data. Four analysis modes:
- Executive Summary — 2-3 paragraph CISO-facing overview
- Risk Deep-Dive — prioritized risk identification with severity context
- Recommendations — actionable, data-backed remediation steps
- M&A Due Diligence — acquisition-target assessment with deal recommendation
Design:
- Compact KPI strip always visible
- 4 mode-selector pills trigger on-device AI generation
- Streaming output with typing cursor
- Graceful fallback when AI API unavailable
- All processing on-device — zero data leaves the browser
- Nav links added across all insight pages for easy access
---
insights-ai.html | 207 +++++++++++++++++++++
insights.html | 1 +
insights2.html | 185 +++++++++++++++++++
insights3.html | 366 ++++++++++++++++++------------------
insights4.html | 400 ++++++++++++++++++++--------------------
insights5.html | 400 ++++++++++++++++++++--------------------
js/insights-ai.js | 428 +++++++++++++++++++++++++++++++++++++++++++
js/insights2-page.js | 197 ++++++++++++++++++++
8 files changed, 1604 insertions(+), 580 deletions(-)
create mode 100644 insights-ai.html
create mode 100644 insights2.html
create mode 100644 js/insights-ai.js
create mode 100644 js/insights2-page.js
diff --git a/insights-ai.html b/insights-ai.html
new file mode 100644
index 0000000..dd2127b
--- /dev/null
+++ b/insights-ai.html
@@ -0,0 +1,207 @@
+
+
+
+
+
+ SBOM Play — AI Insights
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+
+
+
+ Checking AI availability...
+
+
+
+
+
+ Loading...
+
+
+
+
+
No analysis data found.
Run a scan first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/insights.html b/insights.html
index 28d5956..e7ea5f5 100644
--- a/insights.html
+++ b/insights.html
@@ -36,6 +36,7 @@
Audit
Findings
Insights
+ AI
Deps
Repos
Authors
diff --git a/insights2.html b/insights2.html
new file mode 100644
index 0000000..78d1330
--- /dev/null
+++ b/insights2.html
@@ -0,0 +1,185 @@
+ 1|
+ 2|
+ 3|
+ 4|
+ 5|
+ 6| SBOM Play — Executive Pulse
+ 7|
+ 8|
+ 9|
+ 10|
+ 11|
+ 12|
+ 13|
+ 14|
+ 118|
+ 119|
+ 120| Skip to content
+ 121|
+ 122|
+ 123|
SBOM Play
+ 124|
+ 125|
+ 126|
+ 127|
+ 128|
+ 147|
+ 148|
+ 149|
+ 150|
+ 151| Executive Pulse
+ 152| At-a-glance health assessment for decision-makers
+ 153|
+ 154|
+ 155|
+ 156| Loading...
+ 157|
+ 158|
+ 159|
+ 160|
+ 161|
No analysis data found.
Run a scan first.
+ 162|
+ 163|
+ 164|
+ 165| Loading...
Assessing org health…
+ 166|
+ 167|
+ 168|
+ 171|
+ 172|
+ 173|
+ 174|
+ 175|
+ 176|
+ 177|
+ 178|
+ 179|
+ 180|
+ 181|
+ 182|
\ No newline at end of file
diff --git a/insights3.html b/insights3.html
index 151170a..51e504e 100644
--- a/insights3.html
+++ b/insights3.html
@@ -1,182 +1,184 @@
-
-
-
-
-
- SBOM Play — Risk Portfolio
-
-
-
-
-
-
-
-
-
-
- Skip to content
-
-
-
-
-
- Risk Portfolio
- Risk-domain segmentation for due-diligence reviews
-
-
-
- Loading...
-
-
-
-
-
No analysis data found.
Run a scan first.
-
-
-
Assessing risk portfolio…
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ 1|
+ 2|
+ 3|
+ 4|
+ 5|
+ 6| SBOM Play — Risk Portfolio
+ 7|
+ 8|
+ 9|
+ 10|
+ 11|
+ 12|
+ 13|
+ 14|
+ 119|
+ 120|
+ 121| Skip to content
+ 122|
+ 123|
+ 124|
SBOM Play
+ 125|
+ 126|
+ 127|
+ 128|
+ 129|
+ 148|
+ 149|
+ 150|
+ 151|
+ 152| Risk Portfolio
+ 153| Risk-domain segmentation for due-diligence reviews
+ 154|
+ 155|
+ 156|
+ 157| Loading...
+ 158|
+ 159|
+ 160|
+ 161|
+ 162|
No analysis data found.
Run a scan first.
+ 163|
+ 164|
+ 165|
Assessing risk portfolio…
+ 166|
+ 167|
+ 168|
+ 169|
+ 172|
+ 173|
+ 174|
+ 175|
+ 176|
+ 177|
+ 178|
+ 179|
+ 180|
+ 181|
+ 182|
+ 183|
\ No newline at end of file
diff --git a/insights4.html b/insights4.html
index 839b5fc..df91ef9 100644
--- a/insights4.html
+++ b/insights4.html
@@ -1,199 +1,201 @@
-
-
-
-
-
- SBOM Play — Org Report Card
-
-
-
-
-
-
-
-
-
-
- Skip to content
-
-
-
-
-
-
-
-
-
- Loading...
-
-
-
-
-
No analysis data found.
Run a scan first.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ 1|
+ 2|
+ 3|
+ 4|
+ 5|
+ 6| SBOM Play — Org Report Card
+ 7|
+ 8|
+ 9|
+ 10|
+ 11|
+ 12|
+ 13|
+ 14|
+ 133|
+ 134|
+ 135| Skip to content
+ 136|
+ 137|
+ 138|
SBOM Play
+ 139|
+ 140|
+ 141|
+ 142|
+ 143|
+ 162|
+ 163|
+ 164|
+ 165|
+ 166|
+ 171|
+ 172|
+ 173|
+ 174| Loading...
+ 175|
+ 176|
+ 177|
+ 178|
+ 179|
No analysis data found.
Run a scan first.
+ 180|
+ 181|
+ 182|
+ 183|
+ 184|
+ 185|
+ 186|
+ 189|
+ 190|
+ 191|
+ 192|
+ 193|
+ 194|
+ 195|
+ 196|
+ 197|
+ 198|
+ 199|
+ 200|
\ No newline at end of file
diff --git a/insights5.html b/insights5.html
index 3895f65..437ae8d 100644
--- a/insights5.html
+++ b/insights5.html
@@ -1,199 +1,201 @@
-
-
-
-
-
- SBOM Play — Snapshot
-
-
-
-
-
-
-
-
-
-
- Skip to content
-
-
-
-
-
-
-
- Loading...
-
-
-
-
-
No analysis data found.
Run a scan first.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ 1|
+ 2|
+ 3|
+ 4|
+ 5|
+ 6| SBOM Play — Snapshot
+ 7|
+ 8|
+ 9|
+ 10|
+ 11|
+ 12|
+ 13|
+ 14|
+ 142|
+ 143|
+ 144| Skip to content
+ 145|
+ 146|
+ 147|
SBOM Play
+ 148|
+ 149|
+ 150|
+ 151|
+ 152|
+ 168|
+ 169|
+ 170|
+ 171|
+ 172|
+ 173|
+ 174| Loading...
+ 175|
+ 176|
+ 177|
+ 178|
+ 179|
No analysis data found.
Run a scan first.
+ 180|
+ 181|
+ 182|
+ 183|
+ 184|
+ 185|
+ 186|
+ 189|
+ 190|
+ 191|
+ 192|
+ 193|
+ 194|
+ 195|
+ 196|
+ 197|
+ 198|
+ 199|
+ 200|
\ No newline at end of file
diff --git a/js/insights-ai.js b/js/insights-ai.js
new file mode 100644
index 0000000..0023725
--- /dev/null
+++ b/js/insights-ai.js
@@ -0,0 +1,428 @@
+/**
+ * AI Insights (insights-ai.js) — natural-language executive analysis
+ * using Chrome's built-in Gemini Nano (Prompt API).
+ *
+ * All AI processing happens on-device — no data ever leaves the browser.
+ * Falls back gracefully if the API is not available.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+ const statusDot = document.getElementById('aiStatusDot');
+ const statusTxt = document.getElementById('aiStatusText');
+
+ /* ---- State ---- */
+ let currentInsights = null;
+ let aiCapabilities = null; // null = unavailable
+ let abortController = null;
+
+ /* ---- Check AI availability ---- */
+ async function checkAi() {
+ try {
+ if (window.ai && window.ai.languageModel) {
+ const caps = await window.ai.languageModel.capabilities();
+ aiCapabilities = caps;
+ if (caps.available === 'readily') {
+ statusDot.className = 'dot ready';
+ statusTxt.textContent = 'Gemini Nano ready';
+ return true;
+ } else if (caps.available === 'after-download') {
+ statusDot.className = 'dot downloading';
+ statusTxt.textContent = 'Gemini Nano — download needed (click generate to start)';
+ return true;
+ } else {
+ statusDot.className = 'dot unavailable';
+ statusTxt.textContent = 'Gemini Nano not available on this browser';
+ return false;
+ }
+ } else {
+ statusDot.className = 'dot unavailable';
+ statusTxt.textContent = 'Chrome Prompt API not found';
+ return false;
+ }
+ } catch (e) {
+ console.warn('AI check failed:', e);
+ statusDot.className = 'dot unavailable';
+ statusTxt.textContent = 'AI unavailable';
+ return false;
+ }
+ }
+
+ /* ---- Bootstrap analysis ---- */
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('AI: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+
+ const name = selector.value;
+ let data;
+ if (!name || name === '') {
+ data = await storageManager.getCombinedData();
+ } else {
+ data = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!data || !data.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ currentInsights = Agg.buildInsights(data.data);
+ renderMetrics(currentInsights);
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+ await checkAi();
+ await loadAnalysesList();
+
+ /* ---- Build compact data for AI ---- */
+ function buildAiContext(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const dr = ins.driftStats;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+ const sc = ins.supplyChain;
+ const hyg = ins.repoHygiene;
+ const va = ins.vulnAgeStats;
+ const dw = va.directDwellMedian;
+ const age = ins.ageStats;
+
+ const activeRepos = (hyg.activityBuckets['Last 30 days'] || 0)
+ + (hyg.activityBuckets['30-90 days'] || 0)
+ + (hyg.activityBuckets['90-180 days'] || 0);
+
+ return {
+ overview: {
+ repos: ins.totalRepos,
+ reposWithSbom: ins.reposWithSbom,
+ deps: ins.totalDeps,
+ directDeps: ins.directCount,
+ transitiveDeps: ins.transitiveCount,
+ languages: ins.languageStats.slice(0, 5).map(l => l.language)
+ },
+ security: {
+ critVulns: ch.critical,
+ highVulns: ch.high,
+ totalVulns: ch.total,
+ cveDwellDays: dw,
+ malwareAlerts: sc.malwareCount,
+ depConfusion: sc.depConfusionCount
+ },
+ maintenance: {
+ techDebtGrade: td.grade,
+ techDebtScore: td.score100,
+ eolCount: eol.eolCount,
+ eosCount: eol.eosCount,
+ majorDrift: dr.buckets.major.total,
+ driftCoverage: dr.coveragePct,
+ oldDepsPct: age.coveragePct > 0
+ ? Math.round(age.buckets.filter(b => b.maxMonths >= 24).reduce((s, b) => s + b.total, 0) / ins.totalDeps * 100)
+ : 0
+ },
+ compliance: {
+ highRiskLicenses: lic.highRisk,
+ copyleftDirect: lic.copyleft.direct,
+ copyleftTransitive: lic.copyleft.transitive,
+ unknownLicenses: lic.unknown.total,
+ unpinnedActions: sc.unpinnedActions
+ },
+ hygiene: {
+ activeRepos,
+ archivedRepos: hyg.archived,
+ noSbomRepos: hyg.noSbom,
+ avgGrade: Object.entries(hyg.gradeDistribution)
+ .reduce((s, [g, c]) => s + ({ A: 4, B: 3, C: 2, D: 1, F: 0 }[g] || 0) * c, 0)
+ / (ins.totalRepos || 1)
+ }
+ };
+ }
+
+ /* ---- Format prompt ---- */
+ function formatDataForPrompt(ctx) {
+ return JSON.stringify(ctx, null, 1);
+ }
+
+ /* ---- AI generation ---- */
+ const MODES = {
+ executive: {
+ label: 'Executive Summary',
+ icon: 'fas fa-file-lines',
+ system: 'You are an expert cybersecurity advisor writing a concise executive brief for a CISO. Use clear, plain language. Avoid jargon. Be direct about risks but constructive in tone. Maximum 250 words.',
+ prompt: (data) => `Analyze this SBOM org health data and write a 2-3 paragraph executive summary.
+
+DATA:
+${data}
+
+Cover: 1) Overall health assessment 2) Top 2-3 risks that need attention 3) One sentence of encouragement if things look good or a call to action if they don't.`
+ },
+ risks: {
+ label: 'Risk Deep-Dive',
+ icon: 'fas fa-exclamation-triangle',
+ system: 'You are a risk analyst specializing in software supply-chain security. Be specific, reference the numbers, and prioritize by severity. Maximum 200 words.',
+ prompt: (data) => `Analyze these SBOM risk indicators and provide a focused risk assessment.
+
+DATA:
+${data}
+
+Identify: 1) The single biggest risk and why 2) 2-3 secondary risks ranked by severity 3) Which risks are urgent vs which can be monitored. Be specific about counts and thresholds.`
+ },
+ recommendations: {
+ label: 'Recommendations',
+ icon: 'fas fa-list-check',
+ system: 'You are a remediation advisor. Give actionable, prioritized recommendations. Each recommendation must be specific and reference the data. Maximum 250 words.',
+ prompt: (data) => `Based on this SBOM org health data, provide prioritized remediation recommendations.
+
+DATA:
+${data}
+
+Give 3-5 recommendations ordered by impact. For each: what to do, why (reference the data), and the expected outcome. Use bullet points for readability.`
+ },
+ ma: {
+ label: 'M&A Due Diligence',
+ icon: 'fas fa-handshake',
+ system: 'You are an M&A technical due diligence analyst. Assess the target organization\'s software supply-chain health for an acquisition context. Be balanced — note both strengths and liabilities. Maximum 250 words.',
+ prompt: (data) => `Assess this organization's software supply-chain posture from an M&A due diligence perspective.
+
+DATA:
+${data}
+
+Cover: 1) Overall risk rating (Low/Medium/High) for the acquisition context 2) Key liabilities being inherited (be specific) 3) Strengths that reduce risk 4) Estimated remediation effort (minimal/moderate/significant) 5) Deal recommendation — proceed as-is, proceed with conditions, or flag for deeper review.`
+ }
+ };
+
+ async function generateAi(modeKey) {
+ if (!currentInsights || !aiCapabilities) return;
+
+ // Abort previous
+ if (abortController) {
+ abortController.abort();
+ abortController = null;
+ }
+
+ const mode = MODES[modeKey];
+ if (!mode) return;
+
+ const output = document.getElementById('aiOutput');
+ const btns = document.querySelectorAll('.ai-mode-btn');
+
+ // Disable buttons, show loading
+ btns.forEach(b => b.disabled = true);
+ document.querySelectorAll('.ai-mode-btn').forEach(b => {
+ b.classList.toggle('active', b.dataset.mode === modeKey);
+ });
+
+ // Show skeleton
+ safe(output, ``);
+
+ try {
+ // Need to download? trigger first
+ if (aiCapabilities.available === 'after-download') {
+ safe(output, `
+
+
Downloading Gemini Nano model… (one-time, may take a minute)
+
`);
+ }
+
+ const session = await window.ai.languageModel.create({
+ systemPrompt: mode.system,
+ temperature: 0.3,
+ topK: 20
+ });
+
+ const ctx = buildAiContext(currentInsights);
+ const data = formatDataForPrompt(ctx);
+ const userPrompt = mode.prompt(data);
+
+ // Use streaming for progressive display
+ const stream = await session.promptStreaming(userPrompt);
+
+ // Mark as no longer empty
+ output.classList.remove('empty');
+
+ let fullText = '';
+ const responseDiv = document.createElement('div');
+ responseDiv.className = 'ai-response';
+ safe(output, '');
+ output.appendChild(responseDiv);
+
+ // Add mode label
+ const modeLabel = document.createElement('h4');
+ modeLabel.innerHTML = ` ${esc(mode.label)}`;
+ responseDiv.appendChild(modeLabel);
+
+ const contentDiv = document.createElement('div');
+ contentDiv.id = 'aiResponseContent';
+ responseDiv.appendChild(contentDiv);
+
+ for await (const chunk of stream) {
+ fullText = chunk;
+ // Basic markdown-like rendering
+ contentDiv.innerHTML = renderAiText(fullText) + ' ';
+ }
+
+ // Final render (no cursor)
+ contentDiv.innerHTML = renderAiText(fullText);
+
+ session.destroy();
+ } catch (err) {
+ if (err.name === 'AbortError' || err.message?.includes('abort')) {
+ safe(output, ` Generation cancelled.
`);
+ } else {
+ console.error('AI generation failed:', err);
+ safe(output, `
+
+
AI analysis failed: ${esc(err.message || 'Unknown error')}
+
Retry
+
`);
+ }
+ } finally {
+ btns.forEach(b => b.disabled = false);
+ }
+ }
+
+ /* ---- Simple markdown-like renderer ---- */
+ function renderAiText(text) {
+ if (!text) return '';
+ let html = esc(text);
+
+ // Headings: ### text or ## text
+ html = html.replace(/^### (.+)$/gm, '$1 ');
+ html = html.replace(/^## (.+)$/gm, '$1 ');
+
+ // Bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
+
+ // Bullet lists
+ html = html.replace(/^[\*\-] (.+)$/gm, '$1 ');
+ html = html.replace(/(.*<\/li>\n?)+/g, '');
+
+ // Numbered lists
+ html = html.replace(/^\d+\.\s+(.+)$/gm, ' $1 ');
+
+ // Paragraphs — double newlines
+ html = html.replace(/\n\n+/g, '
');
+ html = html.replace(/^(.+)$/gm, (m) => {
+ if (m.startsWith('<') || m.startsWith('')) return m;
+ if (m.trim() === '') return m;
+ // Already wrapped?
+ if (m.includes('
')) return m;
+ return m;
+ });
+
+ // Wrap loose text in
+ if (!html.startsWith('<')) {
+ html = '
' + html + '
';
+ }
+ html = html.replace(/<\/p>\s*/g, '
');
+
+ return html;
+ }
+
+ /* ---- Render metrics + AI controls ---- */
+ function renderMetrics(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const eol = ins.eolStats;
+
+ const gradeColor = td.score100 >= 75 ? 'var(--color-green)' : td.score100 >= 55 ? 'var(--color-yellow)' : 'var(--color-red)';
+
+ const kpiHtml = `
+
`;
+
+ const aiAvailable = aiCapabilities && aiCapabilities.available !== 'no';
+
+ const modeBtns = Object.entries(MODES).map(([key, m]) =>
+ `
+ ${esc(m.label)}
+ `
+ ).join('');
+
+ const controlsHtml = aiAvailable
+ ? `${modeBtns}
`
+ : `
+
+ AI analysis requires Chrome 128+ with chrome://flags/#prompt-api-for-gemini-nano enabled.
+ The deterministic dashboards (Pulse, Portfolio, Report, Snapshot) work without AI.
+
`;
+
+ const outputHtml = `
+
+
+
Select an analysis type above to generate AI insights
+
+
`;
+
+ const detectNotice = aiAvailable
+ ? ` Analysis runs entirely on-device via Gemini Nano — zero data leaves your browser.
`
+ : '';
+
+ safe(content, kpiHtml + controlsHtml + outputHtml + detectNotice);
+
+ // Wire up custom events
+ const output = document.getElementById('aiOutput');
+ output.addEventListener('generate', (e) => {
+ generateAi(e.detail.mode);
+ });
+ output.addEventListener('retry', () => {
+ const active = document.querySelector('.ai-mode-btn.active');
+ if (active) generateAi(active.dataset.mode);
+ });
+ }
+
+})();
diff --git a/js/insights2-page.js b/js/insights2-page.js
new file mode 100644
index 0000000..29ed804
--- /dev/null
+++ b/js/insights2-page.js
@@ -0,0 +1,197 @@
+/**
+ * Executive Pulse (insights2-page.js) — CISO / M&A one-glance dashboard.
+ * Design: giant health grade + 6 vital-sign cards + risk bullets.
+ * No charts, no tables — pure signal.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+
+ /* ------------------------------------------------------------------ */
+ /* Bootstrap */
+ /* ------------------------------------------------------------------ */
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('Pulse: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+
+ const name = selector.value;
+ let data;
+ if (!name || name === '') {
+ data = await storageManager.getCombinedData();
+ } else {
+ data = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!data || !data.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ renderPulse(Agg.buildInsights(data.data));
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+ await loadAnalysesList();
+
+ /* ------------------------------------------------------------------ */
+ /* Render */
+ /* ------------------------------------------------------------------ */
+ function renderPulse(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const drift = ins.driftStats;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+ const sc = ins.supplyChain;
+ const dwell = ins.vulnAgeStats.directDwellMedian;
+
+ /* ---- hero grade ---- */
+ const gradeClass = 'grade-' + td.grade.toLowerCase();
+ const gradeSummary = td.score100 >= 75 ? 'Healthy'
+ : td.score100 >= 55 ? 'Fair'
+ : td.score100 >= 35 ? 'Concerning'
+ : 'At Risk';
+
+ const heroHtml = `
+
+
${td.grade}
+
${gradeSummary}
+
Health Score ${td.score100}/100
+
`;
+
+ /* ---- vital signs ---- */
+ const vulnColor = ch.total === 0 ? 'epulse-status-good' : (ch.critical > 0 ? 'epulse-status-bad' : 'epulse-status-warn');
+ const eolColor = eol.eolCount === 0 ? 'epulse-status-good' : 'epulse-status-bad';
+ const licenseColor = lic.highRisk === 0 ? 'epulse-status-good' : 'epulse-status-warn';
+ const driftColor = drift.buckets.major.total === 0 ? 'epulse-status-good' : 'epulse-status-warn';
+ const dwellColor = dwell === null ? 'epulse-status-muted' : (dwell > 90 ? 'epulse-status-bad' : (dwell > 30 ? 'epulse-status-warn' : 'epulse-status-good'));
+ const reposColor = ins.reposWithoutSbom > ins.totalRepos / 2 ? 'epulse-status-warn' : 'epulse-status-good';
+
+ function vital(icon, value, label, colorClass, sub) {
+ return `
+
+
${value}
+
${label}
+ ${sub ? `
${sub}
` : ''}
+
`;
+ }
+
+ const vitalHtml = `
+
+ ${vital('fas fa-shield-alt', ch.total, 'Crit + High Vulns', vulnColor, ch.critical > 0 ? `${ch.critical} crit, ${ch.high} high` : 'None')}
+ ${vital('fas fa-boxes', ins.totalDeps, 'Dependencies', 'epulse-status-muted', `${ins.directCount} direct, ${ins.transitiveCount} transitive`)}
+ ${vital('fas fa-hourglass-end', eol.eolCount, 'EOL Components', eolColor, `${eol.eosCount} end-of-support`)}
+ ${vital('fas fa-balance-scale', lic.highRisk, 'High-Risk Licenses', licenseColor, `${lic.copyleft.total} copyleft`)}
+ ${vital('fas fa-code-branch', drift.coveragePct + '%', 'Version Drift Coverage', driftColor, `${drift.buckets.major.total} major lagging`)}
+ ${vital('fas fa-clock', dwell !== null ? dwell + 'd' : '--', 'CVE Dwell (median)', dwellColor, 'direct-dep vulns')}
+
+
+ ${vital('fas fa-database', ins.totalRepos, 'Repositories Analyzed', reposColor, `${ins.reposWithSbom} with SBOM`)}
+ ${vital('fas fa-heartbeat', td.grade, 'Tech-Health Grade', `epulse-status-${td.score100 >= 55 ? 'good' : 'bad'}`, `score ${td.score100}/100`)}
+
`;
+
+ /* ---- "Needs Attention" risks ---- */
+ const risks = [];
+ if (ch.total > 0) {
+ risks.push({ icon: 'fas fa-bug text-danger', title: `${ch.total} critical/high vulnerabilities found`, detail: `${ch.critical} critical, ${ch.high} high — prioritize patching` });
+ }
+ if (eol.eolCount > 0) {
+ risks.push({ icon: 'fas fa-hourglass-end text-warning', title: `${eol.eolCount} components past end-of-life`, detail: `${eol.eosCount} also end-of-support — upgrade or replace` });
+ }
+ if (lic.highRisk > 0) {
+ risks.push({ icon: 'fas fa-gavel text-warning', title: `${lic.highRisk} high-risk license obligations`, detail: `${lic.copyleft.direct} direct, ${lic.copyleft.transitive} transitive — legal review recommended` });
+ }
+ if (sc.malwareCount > 0) {
+ risks.push({ icon: 'fas fa-skull-crossbones text-danger', title: `${sc.malwareCount} malware alerts`, detail: 'Malicious packages detected — investigate immediately' });
+ }
+ if (sc.depConfusionCount > 0) {
+ risks.push({ icon: 'fas fa-question-circle text-warning', title: `${sc.depConfusionCount} dependency confusion risks`, detail: 'Packages not found on registry — potential supply-chain attack vector' });
+ }
+ if (sc.unpinnedActions > 0) {
+ risks.push({ icon: 'fas fa-code-branch text-warning', title: `${sc.unpinnedActions} unpinned GitHub Actions`, detail: 'Mutable refs in CI pipeline — pin to immutable commit SHAs' });
+ }
+ if (drift.buckets.major.total > 20) {
+ risks.push({ icon: 'fas fa-arrow-up text-warning', title: `${drift.buckets.major.total} dependencies with major updates available`, detail: 'Version drift indicates stale dependencies — schedule update cycle' });
+ }
+ if (dwell !== null && dwell > 90) {
+ risks.push({ icon: 'fas fa-clock text-danger', title: `CVE dwell time at ${dwell} days median`, detail: 'Critical/high vulns sit open too long — accelerate patch SLA' });
+ }
+
+ /* ---- "On Track" highlights ---- */
+ const clears = [];
+ if (ch.total === 0) clears.push('No critical or high vulnerabilities');
+ if (eol.eolCount === 0) clears.push('No end-of-life components');
+ if (lic.highRisk === 0) clears.push('No high-risk license obligations');
+ if (sc.malwareCount === 0) clears.push('No malware advisories');
+ if (sc.depConfusionCount === 0) clears.push('No dependency confusion risks');
+ if (drift.buckets.major.total === 0 && drift.coveragePct >= 80) clears.push('Dependencies are up-to-date');
+ if (dwell !== null && dwell <= 30) clears.push('Vulnerabilities patched promptly (≤30 days dwell)');
+ if (ins.totalRepos > 0 && ins.reposWithoutSbom === 0) clears.push('All repositories have SBOMs');
+
+ const riskHtml = risks.length > 0 ? `
+ Needs Attention
+
+ ${risks.map(r => `
+
+
+
${esc(r.title)} ${esc(r.detail)}
+
+ `).join('')}
+
` : '';
+
+ const clearHtml = clears.length > 0 ? `
+ On Track
+
+ ${clears.map(c => `
${esc(c)}
`).join('')}
+
` : '';
+
+ safe(content, heroHtml + vitalHtml + riskHtml + clearHtml);
+ }
+
+})();
From ca14265a6e1b1059f1b0f17cd1d01864cdbca5ea Mon Sep 17 00:00:00 2001
From: ai-anant
Date: Mon, 27 Jul 2026 17:24:41 +0530
Subject: [PATCH 05/19] =?UTF-8?q?feat:=20add=20AI=20Chat=20dashboard=20(in?=
=?UTF-8?q?sights-chat)=20=E2=80=94=20conversational=20SBOM=20analyst?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Multi-turn chat interface powered by Chrome's Gemini Nano (Prompt API).
Users can ask free-form questions about their SBOM analysis data and get
natural-language answers grounded in the actual metrics.
Key features:
- Persistent AI session across multiple turns (conversation history)
- Streaming responses with live cursor
- 8 suggested questions as quick-start chips
- Compact KPI strip always visible for reference
- 'New conversation' button to reset context
- Session auto-recreation on error
- Warning at 25+ exchanges to start fresh
- Gemini Nano availability detection with graceful fallback
- Syncs analysis selector with other insight pages
- All processing on-device — zero data leaves the browser
---
insights-ai.html | 1 +
insights-chat.html | 319 +++++++++++++++++++++++++
insights.html | 1 +
insights3.html | 3 +-
insights4.html | 3 +-
insights5.html | 3 +-
js/insights-chat.js | 570 ++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 897 insertions(+), 3 deletions(-)
create mode 100644 insights-chat.html
create mode 100644 js/insights-chat.js
diff --git a/insights-ai.html b/insights-ai.html
index dd2127b..6780a8c 100644
--- a/insights-ai.html
+++ b/insights-ai.html
@@ -155,6 +155,7 @@
Report
Snapshot
AI
+ Chat
Deps
Repos
Settings
diff --git a/insights-chat.html b/insights-chat.html
new file mode 100644
index 0000000..cb04547
--- /dev/null
+++ b/insights-chat.html
@@ -0,0 +1,319 @@
+
+
+
+
+
+ SBOM Play — AI Chat
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
No analysis data found.
Run a scan first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/insights.html b/insights.html
index e7ea5f5..6ae3648 100644
--- a/insights.html
+++ b/insights.html
@@ -37,6 +37,7 @@
Findings
Insights
AI
+ Chat
Deps
Repos
Authors
diff --git a/insights3.html b/insights3.html
index 51e504e..0cf2c0a 100644
--- a/insights3.html
+++ b/insights3.html
@@ -139,7 +139,8 @@
139| Report
140| Snapshot
AI
- 141| Deps
+ 141| Chat
+ Deps
142| Repos
143| Authors
144| Settings
diff --git a/insights4.html b/insights4.html
index df91ef9..aba193a 100644
--- a/insights4.html
+++ b/insights4.html
@@ -153,7 +153,8 @@
153| Report
154| Snapshot
AI
- 155| Deps
+ 155| Chat
+ Deps
156| Repos
157| Authors
158| Settings
diff --git a/insights5.html b/insights5.html
index 437ae8d..0b3e37e 100644
--- a/insights5.html
+++ b/insights5.html
@@ -160,7 +160,8 @@
160| Report
161| Snapshot
AI
- 162| Deps
+ 162| Chat
+ Deps
163| Repos
164| Settings
165| About
diff --git a/js/insights-chat.js b/js/insights-chat.js
new file mode 100644
index 0000000..fa6f487
--- /dev/null
+++ b/js/insights-chat.js
@@ -0,0 +1,570 @@
+/**
+ * AI Chat (insights-chat.js) — conversational interface with Gemini Nano.
+ * Multi-turn chat where the AI has full access to your SBOM analysis data.
+ * All processing on-device — no data leaves your browser.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+
+ /* ---- State ---- */
+ let currentInsights = null;
+ let aiSession = null;
+ let aiAvailable = false;
+ let aiDownloading = false;
+ let isGenerating = false;
+ let messageCount = 0;
+ const MAX_MESSAGES = 30;
+
+ /* ---- Allow navigation to other insight pages ---- */
+ const NAV_LINKS = {
+ 'Pulse': 'insights2.html',
+ 'Portfolio': 'insights3.html',
+ 'Report Card': 'insights4.html',
+ 'Snapshot': 'insights5.html',
+ 'AI Reports': 'insights-ai.html'
+ };
+
+ /* ---- Bootstrap ---- */
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('Chat: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+ destroySession();
+
+ const name = selector.value;
+ let data;
+ if (!name || name === '') {
+ data = await storageManager.getCombinedData();
+ } else {
+ data = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!data || !data.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ currentInsights = Agg.buildInsights(data.data);
+ await initChat();
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+
+ /* ---- AI Availability ---- */
+ async function checkAi() {
+ try {
+ if (window.ai && window.ai.languageModel) {
+ const caps = await window.ai.languageModel.capabilities();
+ if (caps.available === 'readily') {
+ aiAvailable = true;
+ return 'ready';
+ } else if (caps.available === 'after-download') {
+ aiAvailable = true;
+ aiDownloading = true;
+ return 'download';
+ } else {
+ aiAvailable = false;
+ return 'unavailable';
+ }
+ }
+ aiAvailable = false;
+ return 'unavailable';
+ } catch (e) {
+ console.warn('Chat AI check:', e);
+ aiAvailable = false;
+ return 'unavailable';
+ }
+ }
+
+ /* ---- Build context data for AI ---- */
+ function buildAiContext(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const dr = ins.driftStats;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+ const sc = ins.supplyChain;
+ const hyg = ins.repoHygiene;
+ const va = ins.vulnAgeStats;
+ const dw = va.directDwellMedian;
+ const age = ins.ageStats;
+
+ const activeRepos = (hyg.activityBuckets['Last 30 days'] || 0)
+ + (hyg.activityBuckets['30-90 days'] || 0)
+ + (hyg.activityBuckets['90-180 days'] || 0);
+
+ const oldDepsPct = ins.totalDeps > 0
+ ? Math.round(age.buckets.filter(b => b.maxMonths >= 24).reduce((s, b) => s + b.total, 0) / ins.totalDeps * 100)
+ : 0;
+
+ const avgGrade = ins.totalRepos > 0
+ ? Object.entries(hyg.gradeDistribution)
+ .reduce((s, [g, c]) => s + ({ A: 4, B: 3, C: 2, D: 1, F: 0 }[g] || 0) * c, 0) / ins.totalRepos
+ : 0;
+
+ const topLangs = ins.languageStats.slice(0, 5).map(l => `${l.language} (${l.count} refs)`).join(', ');
+
+ // Build a clean, readable text summary for the AI
+ return `ORGANIZATION SBOM ANALYSIS SUMMARY
+=====================================
+
+OVERVIEW
+- Repositories analyzed: ${ins.totalRepos} (${ins.reposWithSbom} with SBOMs, ${ins.totalRepos - ins.reposWithSbom} without)
+- Total dependencies: ${ins.totalDeps} (${ins.directCount} direct, ${ins.transitiveCount} transitive)
+- Top languages: ${topLangs || 'N/A'}
+
+SECURITY
+- Critical vulnerabilities: ${ch.critical}
+- High vulnerabilities: ${ch.high}
+- Total critical+high: ${ch.total}
+- Direct-dep CVE dwell time (median): ${dw !== null ? dw + ' days' : 'N/A'}
+- Malware advisories: ${sc.malwareCount}
+- Dependency confusion risks: ${sc.depConfusionCount}
+
+MAINTENANCE & FRESHNESS
+- Tech-Health Grade: ${td.grade} (score ${td.score100}/100)
+- EOL components: ${eol.eolCount}
+- End-of-support components: ${eol.eosCount}
+- Dependencies with major updates available: ${dr.buckets.major.total}
+- Version drift coverage: ${dr.coveragePct}%
+- Dependencies over 2 years old: ${oldDepsPct}%
+
+LICENSE COMPLIANCE
+- High-risk / copyleft licenses: ${lic.highRisk} (${lic.copyleft.direct} direct, ${lic.copyleft.transitive} transitive)
+- Unknown licenses: ${lic.unknown.total}
+- Unpinned GitHub Actions: ${sc.unpinnedActions}
+
+REPOSITORY HYGIENE
+- Active repos (pushed within 6mo): ${activeRepos}
+- Archived repos: ${hyg.archived}
+- Average SBOM grade (A=4..F=0): ${avgGrade.toFixed(1)}
+- SBOM quality distribution: A=${hyg.gradeDistribution.A || 0} B=${hyg.gradeDistribution.B || 0} C=${hyg.gradeDistribution.C || 0} D=${hyg.gradeDistribution.D || 0} F=${hyg.gradeDistribution.F || 0}
+
+TECH-DEBT COMPOSITION
+- Overall health score: ${td.score100}/100 (Grade ${td.grade})
+- Component weights: Version Drift 30%, Vulnerabilities 30%, Package Age 15%, License Risk 10%, EOL Risk 10%, Repo Hygiene 5%`;
+ }
+
+ /* ---- Session management ---- */
+ async function createSession() {
+ destroySession();
+
+ if (!currentInsights || !aiAvailable) return null;
+
+ const contextData = buildAiContext(currentInsights);
+
+ const systemPrompt = `You are a supply-chain security analyst assistant. You have been given SBOM (Software Bill of Materials) analysis data for an organization. Your role is to help executives, CISO, and M&A professionals understand this data.
+
+RULES:
+1. Answer based ONLY on the data provided below. If asked about something not in the data, say so honestly.
+2. Use plain, clear language suitable for non-technical executives.
+3. Reference specific numbers from the data when relevant (e.g., "There are 12 critical vulnerabilities").
+4. Be concise — aim for 2-3 paragraphs unless asked for detail.
+5. When giving recommendations, be specific and actionable.
+6. If the data shows no issues in an area, say so positively.
+7. Do not make up statistics or claims not supported by the data.
+
+HERE IS THE ORGANIZATION'S SBOM ANALYSIS DATA:
+
+${contextData}
+
+Start each response by addressing what was asked. If someone asks "how are we doing?", give an overall assessment. If they ask about a specific area, focus on that area using the data.`;
+
+ try {
+ // If download needed, trigger it
+ if (aiDownloading) {
+ aiSession = 'downloading'; // placeholder
+ }
+
+ aiSession = await window.ai.languageModel.create({
+ systemPrompt: systemPrompt,
+ temperature: 0.3,
+ topK: 20
+ });
+ messageCount = 0;
+ return aiSession;
+ } catch (err) {
+ console.error('Session creation failed:', err);
+ aiSession = null;
+ return null;
+ }
+ }
+
+ function destroySession() {
+ if (aiSession && typeof aiSession !== 'string') {
+ try { aiSession.destroy(); } catch (e) { /* ignore */ }
+ }
+ aiSession = null;
+ messageCount = 0;
+ }
+
+ /* ---- Chat UI ---- */
+ async function initChat() {
+ const aiStatus = await checkAi();
+
+ let statusHtml = '';
+ if (aiStatus === 'ready') {
+ statusHtml = ` Gemini Nano ready`;
+ } else if (aiStatus === 'download') {
+ statusHtml = ` Gemini Nano available (download on first message)`;
+ } else {
+ statusHtml = ` Gemini Nano not available`;
+ }
+
+ const metrics = currentInsights ? buildMetricsHtml(currentInsights) : '';
+
+ const unavailableHtml = !aiAvailable ? `
+
+
+
AI Chat Unavailable
+
Chrome's built-in Gemini Nano is not available on this browser. You need Chrome 128+ with chrome://flags/#prompt-api-for-gemini-nano enabled.
+
+
` : '';
+
+ const suggestedQuestions = [
+ 'How is our org doing overall?',
+ 'What are our biggest security risks?',
+ 'Which vulnerabilities should we fix first?',
+ 'Give me an M&A due diligence summary',
+ 'How is our license compliance?',
+ 'What needs immediate attention?',
+ 'Are there any supply chain red flags?',
+ 'Which repos have the worst hygiene?'
+ ];
+
+ const chatHtml = `
+
+ ${statusHtml}
+
+
+ ${selector.innerHTML}
+
+
+ New
+
+
+ ${metrics}
+ ${unavailableHtml}
+
+
+
+
+
Ask about your SBOM data
+
Try one of the suggested questions below, or type your own.
+
+
+
+ ${suggestedQuestions.map(q =>
+ `${esc(q)} `
+ ).join('')}
+
+
+
+
+
+
AI responses based on loaded SBOM analysis data · on-device processing
+
`;
+
+ safe(content, chatHtml);
+ wireChatEvents();
+ }
+
+ /* ---- Compact metrics row ---- */
+ function buildMetricsHtml(ins) {
+ const td = ins.techDebt;
+ const ch = ins.critHigh;
+ const eol = ins.eolStats;
+ const lic = ins.licenseStats;
+
+ return `
+
+
+
+
+ ${lic.highRisk}
High-Risk Lic
+
+ `;
+ }
+
+ /* ---- Wire events ---- */
+ function wireChatEvents() {
+ const input = document.getElementById('chatInput');
+ const sendBtn = document.getElementById('chatSend');
+ const newBtn = document.getElementById('btnNewChat');
+ const msgArea = document.getElementById('chatMessages');
+
+ // Secondary selector
+ const sel2 = document.getElementById('analysisSelector2');
+ if (sel2) {
+ sel2.addEventListener('change', () => {
+ // Sync main selector
+ for (const opt of selector.options) {
+ if (opt.value === sel2.value) {
+ selector.value = sel2.value;
+ break;
+ }
+ }
+ loadAnalysis();
+ });
+ }
+
+ if (sendBtn) {
+ sendBtn.addEventListener('click', () => sendMessage());
+ }
+ if (input) {
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ });
+ }
+ if (newBtn) {
+ newBtn.addEventListener('click', () => startNewChat());
+ }
+
+ // Suggested questions
+ document.querySelectorAll('.chat-suggestion').forEach(el => {
+ el.addEventListener('click', () => {
+ const q = el.dataset.question;
+ if (q && input) {
+ input.value = q;
+ sendMessage();
+ }
+ });
+ });
+ }
+
+ /* ---- Send message ---- */
+ async function sendMessage() {
+ const input = document.getElementById('chatInput');
+ const sendBtn = document.getElementById('chatSend');
+ const msgArea = document.getElementById('chatMessages');
+ const empty = document.getElementById('chatEmpty');
+ const text = input?.value.trim();
+
+ if (!text || isGenerating || !aiAvailable) return;
+
+ // Reset suggest selection highlight
+ document.querySelectorAll('.chat-suggestion').forEach(el => el.style.opacity = '0.4');
+
+ // Ensure session exists
+ if (!aiSession || aiSession === 'downloading') {
+ // Show waiting
+ addSystemMessage('Initializing AI session...');
+ const session = await createSession();
+ if (!session) {
+ addSystemMessage('Failed to create AI session. Please try again.');
+ return;
+ }
+ // Remove the system message
+ const sysMsg = document.getElementById('sys-init-msg');
+ if (sysMsg) sysMsg.remove();
+ }
+
+ input.value = '';
+ input.disabled = true;
+ sendBtn.disabled = true;
+ isGenerating = true;
+
+ if (empty) empty.style.display = 'none';
+
+ // Add user message
+ addUserMessage(text);
+
+ // Add AI placeholder
+ const msgDiv = document.createElement('div');
+ msgDiv.className = 'chat-msg assistant';
+ msgDiv.innerHTML = `
+
+
`;
+ msgArea.appendChild(msgDiv);
+ scrollToBottom();
+
+ const bubble = document.getElementById('aiResponse');
+
+ try {
+ const stream = await aiSession.promptStreaming(text);
+ let fullText = '';
+ for await (const chunk of stream) {
+ fullText = chunk;
+ bubble.innerHTML = renderChatText(fullText) + ' ';
+ scrollToBottom();
+ }
+ bubble.innerHTML = renderChatText(fullText);
+
+ messageCount++;
+
+ // Warn if approaching limit
+ if (messageCount >= MAX_MESSAGES - 5) {
+ addSystemMessage(`Conversation getting long (${messageCount} exchanges). Consider starting a new conversation for best quality.`);
+ }
+ } catch (err) {
+ console.error('Chat error:', err);
+ if (err.name === 'AbortError' || err.message?.includes('abort')) {
+ bubble.innerHTML = 'Generation cancelled. ';
+ } else {
+ bubble.innerHTML = ` Error: ${esc(err.message || 'Unknown')} `;
+ // Try to re-create session on error
+ destroySession();
+ }
+ }
+
+ input.disabled = false;
+ sendBtn.disabled = false;
+ isGenerating = false;
+ input.focus();
+ scrollToBottom();
+
+ // Restore suggestion opacity
+ document.querySelectorAll('.chat-suggestion').forEach(el => el.style.opacity = '');
+ }
+
+ /* ---- Start new conversation ---- */
+ async function startNewChat() {
+ if (isGenerating) return;
+
+ const msgArea = document.getElementById('chatMessages');
+ const empty = document.getElementById('chatEmpty');
+
+ // Clear messages
+ if (msgArea) {
+ while (msgArea.firstChild) msgArea.removeChild(msgArea.firstChild);
+ if (empty) {
+ empty.style.display = 'flex';
+ msgArea.appendChild(empty);
+ }
+ }
+
+ destroySession();
+
+ // Re-create session in background
+ if (aiAvailable) {
+ addSystemMessage('Starting new conversation...');
+ const session = await createSession();
+ if (session) {
+ const sysMsg = document.getElementById('sys-init-msg');
+ if (sysMsg) sysMsg.remove();
+ }
+ }
+
+ document.getElementById('chatInput')?.focus();
+ }
+
+ /* ---- Message helpers ---- */
+ function addUserMessage(text) {
+ const msgArea = document.getElementById('chatMessages');
+ const div = document.createElement('div');
+ div.className = 'chat-msg user';
+ div.innerHTML = `
${esc(text)}
`;
+ msgArea.appendChild(div);
+ scrollToBottom();
+ }
+
+ function addSystemMessage(text) {
+ const msgArea = document.getElementById('chatMessages');
+ const div = document.createElement('div');
+ div.className = 'chat-msg system';
+ div.id = text.includes('Initializing') ? 'sys-init-msg' : '';
+ div.innerHTML = `
${text}
`;
+ msgArea.appendChild(div);
+ scrollToBottom();
+ }
+
+ function scrollToBottom() {
+ const msgArea = document.getElementById('chatMessages');
+ if (msgArea) {
+ msgArea.scrollTop = msgArea.scrollHeight;
+ }
+ }
+
+ /* ---- Text renderer ---- */
+ function renderChatText(text) {
+ if (!text) return '';
+ let html = esc(text);
+
+ // Bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
+
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '$1');
+
+ // Bullet lists
+ html = html.replace(/^[\*\-] (.+)$/gm, '$1 ');
+ html = html.replace(/(.*<\/li>\n?)+/g, '');
+
+ // Numbered lists
+ html = html.replace(/^\d+\.\s+(.+)$/gm, ' $1 ');
+
+ // Paragraphs
+ html = html.replace(/\n\n+/g, '');
+ html = html.replace(/^(.+)$/gm, (m) => {
+ if (m.startsWith('<') || m.startsWith('') || m.trim() === '') return m;
+ if (m.includes('')) return m;
+ return m;
+ });
+
+ if (!html.startsWith('<')) {
+ html = '' + html + '
';
+ }
+ html = html.replace(/<\/p>\s*/g, '
');
+
+ return html;
+ }
+
+ /* ---- Start ---- */
+ await loadAnalysesList();
+
+})();
From 56cad87c385efc236beee21da21ece142791e9ba Mon Sep 17 00:00:00 2001
From: ai-anant
Date: Mon, 27 Jul 2026 17:35:33 +0530
Subject: [PATCH 06/19] =?UTF-8?q?feat:=20add=20AI=20Agent=20(insights-agen?=
=?UTF-8?q?t)=20=E2=80=94=20Gemini=20Nano=20queries=20SBOM=20data=20via=20?=
=?UTF-8?q?tool-use?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Paradigm shift from pre-digested context to on-demand data access. The AI
decides what data it needs and calls query_sbom() as a registered tool,
like an analyst querying a database.
Architecture:
- Registers query_sbom() as a Prompt API tool with 12 data types
- Agent loop: AI calls function → JS executes → returns results → AI continues
- 12 queryable categories: overview, vulnerabilities, dependencies,
repositories, licenses, eol, versionDrift, malware, techDebt,
supplyChain, perRepo, hygiene, vulnAge
- Each query returns structured JSON with human-readable summary
- Tool calls shown in UI as log entries for transparency
- Max 6 tool-use turns per response to prevent infinite loops
- Graceful fallback if tools not supported in current Chrome
- Streams final answer with typing cursor
---
insights-agent.html | 117 +++++++
insights-ai.html | 1 +
insights-chat.html | 1 +
insights.html | 1 +
insights3.html | 1 +
insights4.html | 1 +
insights5.html | 1 +
js/insights-agent.js | 795 +++++++++++++++++++++++++++++++++++++++++++
8 files changed, 918 insertions(+)
create mode 100644 insights-agent.html
create mode 100644 js/insights-agent.js
diff --git a/insights-agent.html b/insights-agent.html
new file mode 100644
index 0000000..e174ca1
--- /dev/null
+++ b/insights-agent.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+ SBOM Play — AI Agent
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+ No analysis data found.
Run a scan first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/insights-ai.html b/insights-ai.html
index 6780a8c..db453ec 100644
--- a/insights-ai.html
+++ b/insights-ai.html
@@ -156,6 +156,7 @@
Snapshot
AI
Chat
+ Agent
Deps
Repos
Settings
diff --git a/insights-chat.html b/insights-chat.html
index cb04547..37acaab 100644
--- a/insights-chat.html
+++ b/insights-chat.html
@@ -286,6 +286,7 @@
Snapshot
AI
Chat
+ Agent
Deps
Repos
Settings
diff --git a/insights.html b/insights.html
index 6ae3648..6b15145 100644
--- a/insights.html
+++ b/insights.html
@@ -38,6 +38,7 @@
Insights
AI
Chat
+ Agent
Deps
Repos
Authors
diff --git a/insights3.html b/insights3.html
index 0cf2c0a..29f64c4 100644
--- a/insights3.html
+++ b/insights3.html
@@ -140,6 +140,7 @@
140| Snapshot
AI
141| Chat
+ Agent
Deps
142| Repos
143| Authors
diff --git a/insights4.html b/insights4.html
index aba193a..1e411b0 100644
--- a/insights4.html
+++ b/insights4.html
@@ -154,6 +154,7 @@
154| Snapshot
AI
155| Chat
+ Agent
Deps
156| Repos
157| Authors
diff --git a/insights5.html b/insights5.html
index 0b3e37e..d61a0c9 100644
--- a/insights5.html
+++ b/insights5.html
@@ -161,6 +161,7 @@
161| Snapshot
AI
162| Chat
+ Agent
Deps
163| Repos
164| Settings
diff --git a/js/insights-agent.js b/js/insights-agent.js
new file mode 100644
index 0000000..b018344
--- /dev/null
+++ b/js/insights-agent.js
@@ -0,0 +1,795 @@
+/**
+ * AI Agent (insights-agent.js) — tool-use pattern.
+ * Gemini Nano queries SBOM data on-demand via registered functions,
+ * like an analyst running database queries. All on-device.
+ *
+ * Architecture:
+ * User question → AI decides what data it needs → calls query_sbom()
+ * → JS executes query against in-memory data → returns results
+ * → AI synthesizes answer → User sees final response
+ *
+ * The AI requests data as needed rather than having everything dumped upfront.
+ */
+(async function () {
+ 'use strict';
+
+ const storageManager = window.storageManager || new StorageManager();
+ if (!storageManager.initialized) await storageManager.init();
+
+ const esc = window.escapeHtml || (s => String(s));
+ const safe = window.safeSetHTML || ((el, h) => { el.innerHTML = h; });
+ const Agg = window.InsightsAggregator;
+
+ const selector = document.getElementById('analysisSelector');
+ const content = document.getElementById('content');
+ const loading = document.getElementById('loading');
+ const noData = document.getElementById('noDataMessage');
+
+ /* ---- State ---- */
+ let currentData = null; // raw data.data from IndexedDB
+ let currentIns = null; // InsightsAggregator.buildInsights()
+ let aiSession = null;
+ let aiAvailable = false;
+ let toolsSupport = false;
+ let isGenerating = false;
+
+ /* ================================================================== */
+ /* FUNCTION DECLARATIONS — exposed to the AI as callable tools */
+ /* ================================================================== */
+
+ /**
+ * Each function: (args) => { data: ..., summary: "..." }
+ * The AI calls these via query_sbom({type, filter, limit})
+ */
+ const DATA_QUERIES = {
+ /** High-level org overview */
+ overview() {
+ if (!currentIns) return { data: null, summary: 'No data loaded.' };
+ const ins = currentIns;
+ return {
+ summary: `${ins.totalRepos} repos (${ins.reposWithSbom} with SBOMs), ${ins.totalDeps} total deps (${ins.directCount} direct, ${ins.transitiveCount} transitive)`,
+ data: {
+ totalRepos: ins.totalRepos,
+ reposWithSbom: ins.reposWithSbom,
+ reposWithoutSbom: ins.totalRepos - ins.reposWithSbom,
+ totalDeps: ins.totalDeps,
+ directDeps: ins.directCount,
+ transitiveDeps: ins.transitiveCount,
+ topLanguages: ins.languageStats.slice(0, 8).map(l => ({ language: l.language, refs: l.count }))
+ }
+ };
+ },
+
+ /** Vulnerabilities with optional severity filter */
+ vulnerabilities(args) {
+ if (!currentData) return { data: null, summary: 'No vulnerability data.' };
+ const va = currentData.vulnerabilityAnalysis;
+ if (!va) return { data: null, summary: 'No vulnerability analysis found.' };
+ const filter = (args?.filter || '').toLowerCase();
+ const limit = args?.limit || 50;
+ let deps = va.vulnerableDependencies || [];
+ let totalCrit = va.criticalVulnerabilities || 0;
+ let totalHigh = va.highVulnerabilities || 0;
+ let totalMed = va.mediumVulnerabilities || 0;
+
+ // Build expanded list with vuln details
+ let items = [];
+ for (const vDep of deps) {
+ for (const v of (vDep.vulnerabilities || [])) {
+ const sev = (v.severity || '').toUpperCase();
+ if (filter && !sev.includes(filter) && !(v.id || '').toLowerCase().includes(filter)) continue;
+ items.push({
+ package: vDep.name,
+ version: vDep.version,
+ vulnId: v.id || 'N/A',
+ severity: sev,
+ published: v.published || null,
+ source: v.source || 'OSV',
+ type: v.kind || 'vulnerability'
+ });
+ }
+ }
+
+ // Sort: critical first, then high
+ const order = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, MODERATE: 3, LOW: 4 };
+ items.sort((a, b) => (order[a.severity] ?? 99) - (order[b.severity] ?? 99));
+
+ const summary = `${totalCrit} critical, ${totalHigh} high, ${totalMed} medium vulnerabilities across ${deps.length} packages`;
+ return { summary, data: { totalCrit, totalHigh, totalMed, totalPackages: deps.length, items: items.slice(0, limit) } };
+ },
+
+ /** Dependencies search */
+ dependencies(args) {
+ if (!currentData) return { data: null, summary: 'No dependency data.' };
+ const allDeps = currentData.allDependencies || [];
+ const search = (args?.search || '').toLowerCase();
+ const limit = args?.limit || 20;
+ let filtered = allDeps;
+ if (search) filtered = allDeps.filter(d => (d.name || '').toLowerCase().includes(search));
+
+ const items = filtered.slice(0, limit).map(d => ({
+ name: d.name,
+ version: d.version,
+ type: d.type || 'unknown',
+ license: d.licenseFull || d.license || 'unknown',
+ hasVuln: !!(d.vulnerability || (currentData.vulnerabilityAnalysis?.vulnerableDependencies || []).some(vd => vd.name === d.name))
+ }));
+
+ return {
+ summary: `${allDeps.length} total dependencies${search ? `, ${filtered.length} matching "${search}"` : ''}`,
+ data: { total: allDeps.length, matched: filtered.length, items }
+ };
+ },
+
+ /** Repositories list */
+ repositories(args) {
+ if (!currentData) return { data: null, summary: 'No repository data.' };
+ const repos = currentData.allRepositories || [];
+ const filter = (args?.filter || '').toLowerCase();
+ let filtered = repos;
+ if (filter === 'archived') filtered = repos.filter(r => r.archived);
+ else if (filter === 'active') filtered = repos.filter(r => !r.archived);
+ else if (filter === 'no-sbom') filtered = repos.filter(r => !r.qualityAssessment);
+
+ const items = filtered.map(r => ({
+ name: r.name,
+ owner: r.owner,
+ archived: !!r.archived,
+ totalDeps: r.totalDependencies || r.dependencies?.length || 0,
+ grade: r.qualityAssessment?.grade || null,
+ pushedAt: r.pushedAt || null
+ }));
+
+ return {
+ summary: `${repos.length} repos (${items.length} after filter)`,
+ data: { total: repos.length, filtered: items.length, items }
+ };
+ },
+
+ /** License breakdown */
+ licenses() {
+ if (!currentIns) return { data: null, summary: 'No license data.' };
+ const l = currentIns.licenseStats;
+ return {
+ summary: `${l.highRisk} high-risk, ${l.permissive.total} permissive, ${l.unknown.total} unknown`,
+ data: {
+ highRisk: l.highRisk,
+ copyleft: l.copyleft,
+ permissive: l.permissive,
+ unknown: l.unknown,
+ conflicts: l.conflicts?.length || 0
+ }
+ };
+ },
+
+ /** EOL/EOS components */
+ eol(args) {
+ if (!currentIns) return { data: null, summary: 'No EOL data.' };
+ const eol = currentIns.eolStats;
+ const limit = args?.limit || 30;
+ const items = eol.eolList.slice(0, limit).map(e => ({
+ name: e.name,
+ version: e.version,
+ isEol: e.isEol,
+ isEos: e.isEos,
+ source: e.source,
+ isDirect: e.isDirect
+ }));
+ return {
+ summary: `${eol.eolCount} EOL, ${eol.eosCount} EOS components`,
+ data: { eolCount: eol.eolCount, eosCount: eol.eosCount, items }
+ };
+ },
+
+ /** Version drift */
+ versionDrift(args) {
+ if (!currentIns) return { data: null, summary: 'No drift data.' };
+ const drift = currentIns.driftStats;
+ const type = (args?.type || '').toLowerCase();
+ const limit = args?.limit || 20;
+ let items = drift.lagging;
+ if (type === 'major') items = items.filter(i => i.type === 'major');
+ else if (type === 'minor') items = items.filter(i => i.type === 'minor');
+
+ return {
+ summary: `${drift.buckets.major.total} major, ${drift.buckets.minor.total} minor lagging (${drift.coveragePct}% coverage)`,
+ data: {
+ major: drift.buckets.major,
+ minor: drift.buckets.minor,
+ current: drift.buckets.current,
+ coveragePct: drift.coveragePct,
+ items: items.slice(0, limit)
+ }
+ };
+ },
+
+ /** Malware alerts */
+ malware() {
+ if (!currentIns) return { data: null, summary: 'No malware data.' };
+ const sc = currentIns.supplyChain;
+ return {
+ summary: `${sc.malwareCount} malware advisories, ${sc.depConfusionCount} dependency confusion risks`,
+ data: { malwareCount: sc.malwareCount, depConfusionCount: sc.depConfusionCount }
+ };
+ },
+
+ /** Tech debt breakdown */
+ techDebt() {
+ if (!currentIns) return { data: null, summary: 'No tech debt data.' };
+ const td = currentIns.techDebt;
+ return {
+ summary: `Grade ${td.grade}, score ${td.score100}/100`,
+ data: {
+ grade: td.grade,
+ score100: td.score100,
+ components: Object.entries(td.components).map(([k, v]) => ({ name: k, debt: v.debt, weight: v.weight }))
+ }
+ };
+ },
+
+ /** Supply chain risks */
+ supplyChain() {
+ if (!currentIns) return { data: null, summary: 'No supply chain data.' };
+ const sc = currentIns.supplyChain;
+ return {
+ summary: `${sc.malwareCount} malware, ${sc.depConfusionCount} dep confusion, ${sc.unpinnedActions} unpinned actions, ${sc.deadRepos} dead repos`,
+ data: {
+ malwareCount: sc.malwareCount,
+ depConfusionCount: sc.depConfusionCount,
+ unpinnedActions: sc.unpinnedActions,
+ totalActions: sc.totalActions,
+ deadRepos: sc.deadRepos
+ }
+ };
+ },
+
+ /** Per-repository drill-down */
+ perRepo(args) {
+ if (!currentIns) return { data: null, summary: 'No per-repo data.' };
+ const name = (args?.name || '').toLowerCase();
+ let rows = currentIns.perRepo;
+ if (name) rows = rows.filter(r => r.repoKey.toLowerCase().includes(name));
+ const limit = args?.limit || 30;
+ const items = rows.slice(0, limit).map(r => ({
+ repoKey: r.repoKey,
+ directDeps: r.directCount,
+ transitiveDeps: r.transitiveCount,
+ critHighVulns: (r.critDirect + r.highDirect + r.critTransitive + r.highTransitive),
+ majorDrift: r.majorDriftDirect + r.majorDriftTransitive,
+ grade: r.grade,
+ archived: r.archived
+ }));
+ return {
+ summary: `${currentIns.perRepo.length} repos (${items.length} shown)`,
+ data: { total: currentIns.perRepo.length, items }
+ };
+ },
+
+ /** Repo hygiene */
+ hygiene() {
+ if (!currentIns) return { data: null, summary: 'No hygiene data.' };
+ const h = currentIns.repoHygiene;
+ return {
+ summary: `Grade distribution: A=${h.gradeDistribution.A || 0} B=${h.gradeDistribution.B || 0} C=${h.gradeDistribution.C || 0} D=${h.gradeDistribution.D || 0} F=${h.gradeDistribution.F || 0}`,
+ data: {
+ gradeDistribution: h.gradeDistribution,
+ noSbom: h.noSbom,
+ archived: h.archived,
+ activityBuckets: h.activityBuckets
+ }
+ };
+ },
+
+ /** Vuln age analysis */
+ vulnAge() {
+ if (!currentIns) return { data: null, summary: 'No vuln age data.' };
+ const va = currentIns.vulnAgeStats;
+ return {
+ summary: `${va.timeBombs.length} time bombs (C/H vulns >30d old), direct dwell median ${va.directDwellMedian !== null ? va.directDwellMedian + 'd' : 'N/A'}`,
+ data: {
+ timeBombs: va.timeBombs.slice(0, 20),
+ directDwellMedian: va.directDwellMedian,
+ directDwellCount: va.directDwellCount,
+ ageBuckets: va.ageBuckets
+ }
+ };
+ }
+ };
+
+ /* ---- Execute a function call from the AI ---- */
+ function executeQuery(name, args) {
+ const fn = DATA_QUERIES[name];
+ if (!fn) return { error: `Unknown query: ${name}`, summary: '', data: null };
+ try {
+ return fn(args || {});
+ } catch (e) {
+ console.error('Query error:', e);
+ return { error: e.message, summary: '', data: null };
+ }
+ }
+
+ /* ---- Build function declarations for the Prompt API ---- */
+ function buildFunctionDeclarations() {
+ // We expose a SINGLE unified function with a 'type' enum.
+ // This is simpler for the model and more reliable.
+ return [{
+ name: 'query_sbom',
+ description: 'Query SBOM analysis data. Call this to fetch any data you need to answer the user\'s question. Available data types: overview, vulnerabilities, dependencies, repositories, licenses, eol, versionDrift, malware, techDebt, supplyChain, perRepo, hygiene, vulnAge.',
+ parameters: {
+ type: 'object',
+ properties: {
+ type: {
+ type: 'string',
+ enum: ['overview', 'vulnerabilities', 'dependencies', 'repositories', 'licenses', 'eol', 'versionDrift', 'malware', 'techDebt', 'supplyChain', 'perRepo', 'hygiene', 'vulnAge'],
+ description: 'The category of SBOM data to fetch'
+ },
+ filter: {
+ type: 'string',
+ description: 'Optional filter. For vulnerabilities: "critical" or "high". For repositories: "archived", "active", "no-sbom". For versionDrift: "major" or "minor". For dependencies/perRepo: a name to search for.'
+ },
+ limit: {
+ type: 'number',
+ description: 'Maximum items to return (default: 20, max: 100)',
+ default: 20
+ }
+ },
+ required: ['type']
+ }
+ }];
+ }
+
+ /* ---- Check tools support ---- */
+ async function checkToolsSupport() {
+ try {
+ if (window.ai && window.ai.languageModel) {
+ const caps = await window.ai.languageModel.capabilities();
+ if (caps.available === 'no') {
+ aiAvailable = false;
+ toolsSupport = false;
+ return 'unavailable';
+ }
+ aiAvailable = true;
+ // The Prompt API supports tools if we can pass them to create().
+ // Chrome 130+ supports tools. We check by attempting a capability
+ // or just trying. For now, we assume if the model is available,
+ // tools may be supported. We'll detect at session creation.
+ // Check if there's a specific capability flag
+ // @ts-ignore - newer API
+ if (caps.supportsTools !== undefined) {
+ toolsSupport = caps.supportsTools;
+ } else {
+ // Assume true for Chrome 130+; will fail gracefully
+ toolsSupport = true;
+ }
+ return caps.available === 'readily' ? 'ready' : 'download';
+ }
+ aiAvailable = false;
+ return 'unavailable';
+ } catch (e) {
+ console.warn('Tools check:', e);
+ aiAvailable = false;
+ return 'unavailable';
+ }
+ }
+
+ /* ---- Create agent session with tools ---- */
+ async function createAgentSession() {
+ destroySession();
+ if (!currentData || !aiAvailable) return false;
+
+ try {
+ const decls = buildFunctionDeclarations();
+
+ aiSession = await window.ai.languageModel.create({
+ systemPrompt: `You are a supply-chain security analyst AI. You can query SBOM analysis data using the query_sbom() function whenever you need information.
+
+RULES:
+1. Use query_sbom() to fetch data — don't make up numbers.
+2. Call query_sbom() with the appropriate type for what you need.
+3. After receiving data, synthesize a clear answer for the user.
+4. Use plain language suitable for executives and M&A professionals.
+5. If you need more detail, call query_sbom() again with different parameters.
+6. Be concise but thorough — reference specific numbers.`,
+ tools: [{ functionDeclarations: decls }],
+ temperature: 0.2,
+ topK: 15
+ });
+
+ return true;
+ } catch (err) {
+ console.error('Agent session creation failed:', err);
+ // If tools aren't supported, the create() might fail
+ if (err.message && (err.message.includes('tools') || err.message.includes('function'))) {
+ toolsSupport = false;
+ }
+ aiSession = null;
+ return false;
+ }
+ }
+
+ function destroySession() {
+ if (aiSession) {
+ try { aiSession.destroy(); } catch (e) { /* ignore */ }
+ }
+ aiSession = null;
+ }
+
+ /* ================================================================== */
+ /* DATA LOADING */
+ /* ================================================================== */
+
+ async function loadAnalysesList() {
+ try {
+ const info = await storageManager.getStorageInfo();
+ const all = [...info.organizations, ...info.repositories]
+ .filter(e => e.name !== '__ALL__' && e.dependencies > 0);
+ selector.innerHTML = '';
+ if (all.length === 0) {
+ noData.classList.remove('d-none');
+ selector.disabled = true;
+ return;
+ }
+ const opt = document.createElement('option');
+ opt.value = '';
+ const totalDeps = all.reduce((s, e) => s + (e.dependencies || 0), 0);
+ opt.textContent = `All Analyses (${totalDeps} deps)`;
+ selector.appendChild(opt);
+ for (const e of all) {
+ const o = document.createElement('option');
+ o.value = e.name;
+ o.textContent = `${e.name} (${e.dependencies || 0} deps)`;
+ selector.appendChild(o);
+ }
+ selector.disabled = false;
+ await loadAnalysis();
+ } catch (err) {
+ console.error('Agent: load failed', err);
+ selector.disabled = true;
+ noData.classList.remove('d-none');
+ }
+ }
+
+ async function loadAnalysis() {
+ loading.classList.remove('d-none');
+ content.classList.add('d-none');
+ noData.classList.add('d-none');
+ destroySession();
+
+ const name = selector.value;
+ let blob;
+ if (!name || name === '') {
+ blob = await storageManager.getCombinedData();
+ } else {
+ blob = await storageManager.loadAnalysisDataForOrganization(name);
+ }
+
+ if (!blob || !blob.data) {
+ loading.classList.add('d-none');
+ noData.classList.remove('d-none');
+ return;
+ }
+
+ currentData = blob.data;
+ currentIns = Agg.buildInsights(currentData);
+ await initAgent();
+ loading.classList.add('d-none');
+ content.classList.remove('d-none');
+ }
+
+ selector.addEventListener('change', loadAnalysis);
+
+ /* ================================================================== */
+ /* UI */
+ /* ================================================================== */
+
+ async function initAgent() {
+ const aiStatus = await checkToolsSupport();
+
+ let statusHtml = '';
+ if (aiStatus === 'ready') {
+ statusHtml = ` Gemini Nano tools: ${toolsSupport ? '✓' : '✗'} `;
+ } else if (aiStatus === 'download') {
+ statusHtml = ` Gemini Nano (download on first use) tools: ${toolsSupport ? '✓' : '✗'} `;
+ } else {
+ statusHtml = ` Gemini Nano not available`;
+ }
+
+ const metrics = currentIns ? buildMetrics(currentIns) : '';
+ const available = aiAvailable && toolsSupport;
+ const suggested = [
+ 'How is our overall security posture?',
+ 'List all critical vulnerabilities',
+ 'What EOL components are we running?',
+ 'Which repos have the worst SBOM quality?',
+ 'Give me an M&A due diligence summary',
+ 'What supply chain risks exist?',
+ 'How bad is our version drift?',
+ 'Which packages have the most vulns?'
+ ];
+
+ const html = `
+
+ ${statusHtml}
+
+ ${selector.innerHTML}
+ New
+
+ ${metrics}
+ ${!aiAvailable ? `
+
+
+
AI Agent Unavailable
+
Chrome's built-in Gemini Nano is not available on this browser.
+
` : !toolsSupport ? `
+
+
+
Tool-Use Not Supported
+
Your Chrome version doesn't support function calling in the Prompt API. Try the Chat page instead (uses pre-digested context).
+
` : ''}
+
+
+
+
+
Agent ready — data loaded
+
The AI can query 12 data categories. Ask anything about your SBOM analysis.
+
+
+
+ ${suggested.map(q => `${esc(q)} `).join('')}
+
+
+
+
+
+
`;
+
+ safe(content, html);
+ wireEvents();
+ }
+
+ function buildMetrics(ins) {
+ const ch = ins.critHigh;
+ const eol = ins.eolStats;
+ const td = ins.techDebt;
+ return `
+
+
+
+
+ `;
+ }
+
+ function wireEvents() {
+ const input = document.getElementById('agInput');
+ const send = document.getElementById('agSend');
+ const newBtn = document.getElementById('btnNewChat');
+
+ document.getElementById('analysisSelector2')?.addEventListener('change', function() {
+ for (const opt of selector.options) { if (opt.value === this.value) { selector.value = this.value; break; } }
+ loadAnalysis();
+ });
+
+ send?.addEventListener('click', () => sendMessage());
+ input?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
+ newBtn?.addEventListener('click', startNewConversation);
+
+ document.querySelectorAll('.ag-suggestion').forEach(el => {
+ el.addEventListener('click', () => {
+ if (input) { input.value = el.dataset.q; sendMessage(); }
+ });
+ });
+ }
+
+ /* ================================================================== */
+ /* AGENT LOOP */
+ /* ================================================================== */
+
+ async function sendMessage() {
+ const input = document.getElementById('agInput');
+ const send = document.getElementById('agSend');
+ const msgArea = document.getElementById('agMessages');
+ const empty = document.getElementById('agEmpty');
+ const text = input?.value.trim();
+ if (!text || isGenerating || !aiSession) return;
+
+ input.value = '';
+ input.disabled = true;
+ send.disabled = true;
+ isGenerating = true;
+ if (empty) empty.style.display = 'none';
+
+ addUserMsg(text);
+
+ // Show thinking indicator
+ const thinkDiv = addAssistantMsg('');
+ const thinkBubble = thinkDiv.querySelector('.bubble');
+
+ try {
+ // Agent loop: handle AI ↔ function call turns
+ let responseText = '';
+ const MAX_TOOL_TURNS = 6;
+ let turn = 0;
+ let currentPrompt = text;
+
+ while (turn < MAX_TOOL_TURNS) {
+ turn++;
+ thinkBubble.innerHTML = turn === 1
+ ? ' Thinking... '
+ : ` Analyzing data (turn ${turn})... `;
+ scrollBottom();
+
+ const result = await aiSession.prompt(currentPrompt);
+
+ // Check if result is a function call
+ const fc = extractFunctionCall(result);
+ if (!fc) {
+ // Normal text response — we're done
+ responseText = typeof result === 'string' ? result : (result.text || JSON.stringify(result));
+ break;
+ }
+
+ // Log the function call
+ addToolCallMsg(`query_sbom(${fc.args.type}${fc.args.filter ? ', filter: ' + fc.args.filter : ''}${fc.args.limit ? ', limit: ' + fc.args.limit : ''})`);
+
+ // Execute the query
+ thinkBubble.innerHTML = ` Querying ${fc.args.type}... `;
+ scrollBottom();
+
+ const queryResult = executeQuery(fc.args.type, fc.args);
+ const resultStr = JSON.stringify(queryResult);
+
+ // Feed result back — next loop turn
+ currentPrompt = `Here is the data from query_sbom("${fc.args.type}"):\n${resultStr}\n\nUse this data to answer the user's question. If you need more data, call query_sbom() again.`;
+ }
+
+ if (turn >= MAX_TOOL_TURNS) {
+ responseText = 'I\'ve reached the maximum number of queries for this response. Based on what I\'ve gathered so far, here\'s my analysis...';
+ }
+
+ // Stream the final response
+ thinkBubble.innerHTML = ' ';
+ try {
+ const stream = await aiSession.promptStreaming(responseText);
+ let full = '';
+ for await (const chunk of stream) {
+ full = chunk;
+ thinkBubble.innerHTML = renderText(full) + ' ';
+ scrollBottom();
+ }
+ thinkBubble.innerHTML = renderText(full || responseText);
+ } catch (e) {
+ // If streaming fails, just show the text we already have
+ thinkBubble.innerHTML = renderText(responseText);
+ }
+
+ } catch (err) {
+ console.error('Agent error:', err);
+ const bubble = document.querySelector('#agMessages .ag-msg.assistant:last-child .bubble');
+ if (bubble) {
+ bubble.innerHTML = ` ${esc(err.message || 'Agent error')} `;
+ }
+ // Try to recover session
+ if (err.message?.includes('session') || err.message?.includes('context')) {
+ destroySession();
+ const ok = await createAgentSession();
+ if (ok) addSystemMsg('Session re-created. Please try again.');
+ }
+ }
+
+ input.disabled = false;
+ send.disabled = false;
+ isGenerating = false;
+ input?.focus();
+ scrollBottom();
+ }
+
+ /** Extract function call from AI response — handles multiple API shapes */
+ function extractFunctionCall(result) {
+ if (!result) return null;
+
+ // Shape 1: { functionCall: { name, args } }
+ if (result.functionCall && result.functionCall.name) {
+ return { name: result.functionCall.name, args: result.functionCall.args || {} };
+ }
+ // Shape 2: { functionCalls: [...] }
+ if (result.functionCalls && result.functionCalls.length > 0) {
+ const fc = result.functionCalls[0];
+ return { name: fc.name, args: fc.args || {} };
+ }
+ // Shape 3: String containing a function call marker (fallback parsing)
+ if (typeof result === 'string') {
+ // Check if the response is JUST a function call (some API versions return it as text)
+ try {
+ const parsed = JSON.parse(result);
+ if (parsed.functionCall || parsed.functionCalls) {
+ return extractFunctionCall(parsed);
+ }
+ } catch (e) { /* not JSON */ }
+ return null;
+ }
+ return null;
+ }
+
+ async function startNewConversation() {
+ if (isGenerating) return;
+ const msgArea = document.getElementById('agMessages');
+ const empty = document.getElementById('agEmpty');
+ if (msgArea) { while (msgArea.firstChild) msgArea.removeChild(msgArea.firstChild); }
+ if (empty) { empty.style.display = 'flex'; msgArea?.appendChild(empty); }
+ destroySession();
+ if (currentData && aiAvailable && toolsSupport) {
+ await createAgentSession();
+ }
+ document.getElementById('agInput')?.focus();
+ }
+
+ /* ---- Message helpers ---- */
+ function addUserMsg(text) {
+ const area = document.getElementById('agMessages');
+ const div = document.createElement('div');
+ div.className = 'ag-msg user';
+ div.innerHTML = `
${esc(text)}
`;
+ area.appendChild(div);
+ scrollBottom();
+ }
+
+ function addAssistantMsg(text) {
+ const area = document.getElementById('agMessages');
+ const div = document.createElement('div');
+ div.className = 'ag-msg assistant';
+ div.innerHTML = `
${text || ''}
`;
+ area.appendChild(div);
+ scrollBottom();
+ return div;
+ }
+
+ function addToolCallMsg(label) {
+ const area = document.getElementById('agMessages');
+ const div = document.createElement('div');
+ div.className = 'ag-msg tool-call';
+ div.innerHTML = `
QUERY ${esc(label)}
`;
+ area.appendChild(div);
+ scrollBottom();
+ }
+
+ function addSystemMsg(text) {
+ const area = document.getElementById('agMessages');
+ const div = document.createElement('div');
+ div.className = 'ag-msg tool-call';
+ div.innerHTML = `
${text}
`;
+ area.appendChild(div);
+ scrollBottom();
+ }
+
+ function scrollBottom() {
+ const area = document.getElementById('agMessages');
+ if (area) area.scrollTop = area.scrollHeight;
+ }
+
+ /* ---- Text renderer ---- */
+ function renderText(text) {
+ if (!text) return '';
+ let html = esc(text);
+ html = html.replace(/\*\*(.+?)\*\*/g, '$1 ');
+ html = html.replace(/`([^`]+)`/g, '$1');
+ html = html.replace(/^[\*\-] (.+)$/gm, '$1 ');
+ html = html.replace(/(.*<\/li>\n?)+/g, '');
+ html = html.replace(/^\d+\.\s+(.+)$/gm, ' $1 ');
+ html = html.replace(/\n\n+/g, '
');
+ html = html.replace(/^(.+)$/gm, (m) => {
+ if (m.startsWith('<') || m.startsWith('') || m.trim() === '') return m;
+ if (m.includes('')) return m;
+ return m;
+ });
+ if (!html.startsWith('<')) html = '' + html + '
';
+ html = html.replace(/<\/p>\s*/g, '
');
+ return html;
+ }
+
+ /* ---- Start ---- */
+ await loadAnalysesList();
+
+})();
From 2a2b8639bcf01c788cd8f7cd4511a91d6f9534f0 Mon Sep 17 00:00:00 2001
From: Anant Shrivastava
Date: Wed, 29 Jul 2026 20:54:53 +0100
Subject: [PATCH 07/19] initial code and ab testing
---
.github/workflows/deploy-github-pages.yml | 9 +-
.github/workflows/validate-deployment.yml | 15 +
CHANGELOG.md | 9 +-
about.html | 52 ++-
flowchart.md | 31 ++
insights-agent.html | 15 +-
insights-ai.html | 5 +-
insights-chat.html | 5 +-
insights.html | 4 +-
insights2.html | 364 ++++++++++----------
insights3.html | 366 ++++++++++----------
insights4.html | 400 +++++++++++-----------
insights5.html | 400 +++++++++++-----------
js/ai-nano.js | 153 +++++++++
js/insights-agent.js | 38 +-
js/insights-ai.js | 139 ++++----
js/insights-chat.js | 28 +-
17 files changed, 1135 insertions(+), 898 deletions(-)
create mode 100644 js/ai-nano.js
diff --git a/.github/workflows/deploy-github-pages.yml b/.github/workflows/deploy-github-pages.yml
index 35cef13..b070213 100644
--- a/.github/workflows/deploy-github-pages.yml
+++ b/.github/workflows/deploy-github-pages.yml
@@ -58,8 +58,15 @@ jobs:
cp findings.html _site/
cp feeds.html _site/
cp insights.html _site/
+ cp insights2.html _site/
+ cp insights3.html _site/
+ cp insights4.html _site/
+ cp insights5.html _site/
+ cp insights-ai.html _site/
+ cp insights-chat.html _site/
+ cp insights-agent.html _site/
cp demo.html _site/
- echo "✅ Copied 14 HTML files"
+ echo "✅ Copied 21 HTML files"
# Copy JavaScript, CSS, images, and bundled demo SBOM JSON
cp -r js _site/
diff --git a/.github/workflows/validate-deployment.yml b/.github/workflows/validate-deployment.yml
index 13a28ec..a034ece 100644
--- a/.github/workflows/validate-deployment.yml
+++ b/.github/workflows/validate-deployment.yml
@@ -40,6 +40,13 @@ jobs:
"findings.html"
"feeds.html"
"insights.html"
+ "insights2.html"
+ "insights3.html"
+ "insights4.html"
+ "insights5.html"
+ "insights-ai.html"
+ "insights-chat.html"
+ "insights-agent.html"
"demo.html"
)
@@ -149,6 +156,14 @@ jobs:
"js/upload-page.js"
"js/insights-aggregator.js"
"js/insights-page.js"
+ "js/insights2-page.js"
+ "js/insights3-page.js"
+ "js/insights4-page.js"
+ "js/insights5-page.js"
+ "js/insights-ai.js"
+ "js/insights-chat.js"
+ "js/insights-agent.js"
+ "js/ai-nano.js"
"js/demo-page.js"
)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be79c5b..7593bf9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- **Executive dashboard variants (experimental, direct URL)** (`insights2.html`–`insights5.html`, `js/insights2-page.js`–`js/insights5-page.js`): four alternate one-screen views over the same `InsightsAggregator` data — **Executive Pulse** (health grade + vital signs), **Risk Portfolio** (Security / Operational / Compliance columns), **Org Report Card** (letter grades with plain-English comments), and **One-Pager Snapshot** (single-viewport tiles + verdict). Not linked from the main nav; useful for comparing exec-facing layouts before picking a default.
+- **On-device AI over SBOM analysis** (`insights-ai.html`, `insights-chat.html`, `insights-agent.html`, `js/insights-ai.js`, `js/insights-chat.js`, `js/insights-agent.js`, `js/ai-nano.js`): three Chrome **Gemini Nano** (Prompt API) experiences that read stored IndexedDB analysis only — **AI** (one-shot executive report modes), **Chat** (multi-turn with full context in the system prompt), and **Agent** (tool-use loop calling in-memory `query_sbom()` handlers). Linked from the Insights page nav (AI / Chat / Agent). All inference runs in the browser; no SBOM data is sent to a remote model.
+- **Shared Prompt API adapter** (`js/ai-nano.js`): normalizes modern `LanguageModel.availability()` / `LanguageModel.create()` (including `initialPrompts` and download `monitor`) with a legacy `window.ai.languageModel` fallback, and accumulates streaming chunks so UI code works with both delta and cumulative stream semantics.
- **"Unpinnable Actions" finding category on the Findings page** (`findings.html`, `js/findings-page.js`, `about.html`): GitHub Actions findings are now split into two categories so the page's Finding-type filter, the category-stat tiles, and the per-rule section cards separate **workflow-level issues** (things the workflow author can fix in their own repo: `MUTABLE_TAG_REFERENCE`, `PULL_REQUEST_TARGET_CHECKOUT`, `EXCESSIVE_WORKFLOW_PERMISSIONS`, `EXCESSIVE_JOB_PERMISSIONS`, `POTENTIAL_HARDCODED_SECRET`) from **unpinnable patterns** per [Palo Alto's "Unpinnable Actions" research](https://www.paloaltonetworks.com/blog/cloud-security/unpinnable-actions-github-security/) — Docker actions whose `action.yml` references an image by floating tag (`DOCKER_IMPLICIT_LATEST`, `DOCKER_FLOATING_TAG`), Dockerfiles whose `FROM` is unpinned (`DOCKERFILE_FLOATING_BASE_IMAGE`), Dockerfiles that install runtime dependencies without version locks or `curl|bash` remote code without integrity checks (`DOCKER_UNPINNED_DEPENDENCIES`, `DOCKER_REMOTE_CODE_NO_INTEGRITY`), composite actions that `uses:` a child action by tag instead of SHA (`COMPOSITE_NESTED_UNPINNED_ACTION`), composite actions that `run:` unpinned package installs or unverified remote scripts (`COMPOSITE_UNPINNED_DEPENDENCIES`, `COMPOSITE_REMOTE_CODE_NO_INTEGRITY`), JavaScript actions that install unpinned packages or download scripts at runtime (`UNPINNED_PACKAGE_INSTALL`, `REMOTE_CODE_NO_INTEGRITY`), and `INDIRECT_UNPINNABLE_ACTION` for actions that pull any of the above transitively. The Finding-type dropdown on `findings.html` gains a new `Unpinnable Actions` option; `js/findings-page.js` ships a module-scope `UNPINNABLE_ACTION_RULES` Set that does the classification at collection time and routes rows through the same renderer as the GitHub Actions category (they share the `action` / `workflowLocations` / `actionRepository` shape). The category-breakdown row was switched from `row-cols-md-5` to `row-cols-md-3 row-cols-xl-6` to fit the new tile (also tightened the "Dependency Confusion" label to "Dep. Confusion" so it stops wrapping). The matching reference doc — a new "Security Findings the Tool Detects" card on `about.html` — lists every rule id under each category with severity badges and a one-line description.
- **"Findings Re-analysis" debug card** (`debug.html`, `js/settings.js`, `js/github-actions-analyzer.js` newly loaded on `debug.html`): two modes for refreshing the Findings page for a previously-scanned analysis without re-running a full org scan. **Normalize Stored Findings** (`SettingsApp.normalizeFindings`) is a schema-only fix-up — it walks the stored `githubActionsAnalysis.repositories[].findings` and `.findings`, renames any legacy `UNPINNED_ACTION_REFERENCE` rule ids to `MUTABLE_TAG_REFERENCE`, dedups rows keyed by `(rule_id, action, repository, file, line)` (cleans up the duplicates left by the previous double-fire bug), recomputes `findingsByType` from the canonical findings list, stamps `normalizedAt`, and saves back. Zero network calls, completes in seconds. **Re-analyze (re-fetch from GitHub)** (`SettingsApp.reanalyzeFindings`) is the heavy refresh — iterates `allRepositories` (skipping `owner === 'upload'` entries since uploaded SBOMs have no GitHub backing), pre-flights a rough rate-limit check (~20 API calls per repo, warns if estimated calls exceed the unauthenticated remaining quota and no token is set), confirms, then constructs a fresh `GitHubActionsAnalyzer` and re-runs `analyzeRepository(owner, name, 'HEAD')` per repo, aggregating `totalActions` / `uniqueActions` / `findings` / `findingsByType` exactly the way `SBOMProcessor.analyzeGitHubActions` does, replaces the stored `githubActionsAnalysis`, and saves. Both modes share one analysis-selector dropdown (`findingsRedoOrgSelect`) and one progress UI (`findingsRedoProgress`); buttons enable/disable on selection change. `js/github-actions-analyzer.js` was not previously loaded on `debug.html` and is now added so the Re-analyze button has the analyzer class available. Cache-busters bumped on `debug.html` for `settings.js` and the newly added analyzer script.
@@ -24,9 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Chart.js added to CDN allowlist** (`about.html`): `cdn.jsdelivr.net` row now includes Chart.js.
- **Insights Analysis Flow added to flowchart** (`flowchart.md`): documents the InsightsAggregator pipeline and its 10-section rendering.
- **CSS: Chart.js theme variables and Insights styles** (`css/themes.css`, `css/style.css`): `--chart-text-color` / `--chart-grid-color` in dark, light, and root scopes; `.insights-mini-tooltip`, `.insights-scroll-container`, `.insights-chart-wrap`, `.insights-component-bar`, `.insights-mini-bar` classes.
-- **Workflow files updated** (`.github/workflows/deploy-github-pages.yml`, `.github/workflows/validate-deployment.yml`): `insights.html`, `js/insights-aggregator.js`, `js/insights-page.js` added to deployment copy step and validation arrays; **demo** (`demo.html`, `js/demo-page.js`, `data/demo/*.json`) included for the Quick demo page.
+- **Workflow files updated** (`.github/workflows/deploy-github-pages.yml`, `.github/workflows/validate-deployment.yml`): `insights.html`, `js/insights-aggregator.js`, `js/insights-page.js` added to deployment copy step and validation arrays; **demo** (`demo.html`, `js/demo-page.js`, `data/demo/*.json`) included for the Quick demo page; **executive variants and AI pages** (`insights2.html`–`insights5.html`, `insights-ai.html`, `insights-chat.html`, `insights-agent.html`, matching `js/*-page.js`, `js/ai-nano.js`) added to deployment copy and validation.
### Changed
+- **Insights nav** (`insights.html`): adds links to the on-device **AI**, **Chat**, and **Agent** pages next to the main Insights entry.
- **Unpinned-action-reference findings consolidated under a single `MUTABLE_TAG_REFERENCE` rule** (`js/github-actions-analyzer.js`, `js/common.js`, `js/audit-page.js`, `js/view-manager.js`): the prior split between `UNPINNED_ACTION_REFERENCE` (intended for version tags like `v1.2.3`) and `MUTABLE_TAG_REFERENCE` (intended for branch/`latest`-style tags like `main`/`master`) described the *same* underlying issue — the workflow author chose a movable tag over an immutable commit SHA — and the analyzer's `applyHeuristics` was double-firing both rules for the same `owner/repo@ref`, so the Findings page showed two near-duplicate rows per such action. `checkWorkflowLevel` now emits exactly one `MUTABLE_TAG_REFERENCE` finding per unpinned ref — severity `high` when the ref is on the floating-tag list (`main`, `master`, `latest`, `dev`, single-segment `v1`/`v2`/…), severity `medium` for more-specific version tags like `v1.2.3` — so the severity signal preserved the "publisher rolls this forward" vs. "publisher might roll this forward" distinction that the rule-id split was trying to carry. `applyHeuristics` was the source of the duplicate emit and is removed entirely, along with its call site in `analyzeAction`. `getFindingName`/`getFindingDescription` maps in `js/common.js`, `js/audit-page.js`, and `js/view-manager.js` drop the `UNPINNED_ACTION_REFERENCE` key (legacy stored analyses fall back to the raw rule id as the type name, harmless) and the `MUTABLE_TAG_REFERENCE` description was rewritten to make explicit that it covers both branch-style and version-tag refs. Note for previously-stored analyses: their saved `githubActionsAnalysis.findings` still contain both legacy rows; the new "Normalize Stored Findings" debug button cleans them in place without a rescan. New scans get a single row from the start.
- **Quick demo fixtures** ([`data/demo/`](data/demo/), [`js/demo-page.js`](js/demo-page.js)): placeholder CycloneDX/SPDX mini samples were removed in favour of the real bundled export [`sbomplay-demo.json`](data/demo/sbomplay-demo.json); CI now validates that file only (extend the list when adding more demos).
@@ -35,7 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Feeds page removed from top navigation** (`feeds.html`): the page is retained and fully functional but no longer occupies a slot in the header navbar, reducing nav clutter ahead of the upcoming Insights page. Users can still access it via direct URL.
### Fixed
-- **Findings page threw `ReferenceError: Cannot access 'UNPINNABLE_ACTION_RULES' before initialization`** (`js/findings-page.js`): the new rule-classification Set was originally declared as a `const` inside the DOMContentLoaded handler, just before `generateSecurityFindingsHTML`. Function declarations in the same scope hoist their bodies but not their `const` bindings, and the handler's `await loadFindingsData()` call (which transitively calls `generateSecurityFindingsHTML` via the `renderFunction` callback) runs at source-position 184 — well before the `const` at source-position ~294. The function body referenced the binding while it was still in the temporal dead zone, throwing on the first page load. Moved `UNPINNABLE_ACTION_RULES` to module scope (top of `js/findings-page.js`, outside the DOMContentLoaded handler) so the binding is initialised at script-parse time, before any handler runs. Cache-buster bumped on `findings.html`.
+- **Executive dashboard HTML corrupted** (`insights3.html`, `insights4.html`, `insights5.html`): accidental editor line-number prefixes in the file body broke rendering; restored valid HTML from the original import commits.
+- **AI Insights page failed on load with `Cannot access 'MODES' before initialization`** (`js/insights-ai.js`): bootstrap called `renderMetrics()` before the `const MODES` binding ran (same temporal-dead-zone pattern as the Findings page fix). `MODES` is now declared at module scope inside the IIFE before any async load, and page init runs after all helpers are defined. (`js/findings-page.js`): the new rule-classification Set was originally declared as a `const` inside the DOMContentLoaded handler, just before `generateSecurityFindingsHTML`. Function declarations in the same scope hoist their bodies but not their `const` bindings, and the handler's `await loadFindingsData()` call (which transitively calls `generateSecurityFindingsHTML` via the `renderFunction` callback) runs at source-position 184 — well before the `const` at source-position ~294. The function body referenced the binding while it was still in the temporal dead zone, throwing on the first page load. Moved `UNPINNABLE_ACTION_RULES` to module scope (top of `js/findings-page.js`, outside the DOMContentLoaded handler) so the binding is initialised at script-parse time, before any handler runs. Cache-buster bumped on `findings.html`.
- **`window.app` now references the live `SBOMPlayApp` instance** ([`js/app.js`](js/app.js)): page scripts such as [`index-page.js`](js/index-page.js) and the demo page can call `window.app` reliably after initialization.
- **GitHub analysis now persists version drift, staleness, and EOX on each dependency in the saved blob** (`js/app.js`, `js/insights-aggregator.js`, `insights.html`): the post-SBOM path `runLicenseAndVersionDriftEnrichment` → `fetchVersionDriftData` attached drift to the in-memory `allDependencies` array but never mirrored it onto `sbomProcessor.dependencies`, so the following `exportData()` wrote empty `versionDrift` / `staleness` fields — Insights' Package Age and Version Drift charts showed 0% coverage even after a full scan. The flow now copies nested `staleness` onto each dep, calls `EnrichmentPipeline.syncDriftToProcessor` after drift fetch (matching upload / `runFullEnrichment`), and `syncEOXToProcessor` after EOX fetch. `InsightsAggregator` treats `dep.versionDrift.staleness` as a fallback when top-level `dep.staleness` is missing. Removed the temporary Insights-only IndexedDB backfill from `js/insights-page.js` in favour of correct scan-time persistence.
- **Deps-page "Dependency Chain" modal now renders full chains grouped by repository instead of falling back to a flat list of parent names** (`js/deps-page.js`, `deps.html`): when `parentsByRepo` was empty for a dep — typical for resolver-discovered transitives whose parent edges live on the dep's `parents` array (resolver tree) rather than in the repo's SPDX `relationships` — the modal hit its `else` branch and displayed only the parent package names with no repo header, no chain visualisation, and no "→" arrows. For example clicking the parents-count cell on `org.apache.tomcat.embed:tomcat-embed-core@10.1.16` showed `spring-boot-starter-tomcat@3.2.0` and `tomcat-embed-websocket@10.1.16` as bare list items even though the vuln page's "Used in" block on the same dep correctly traced both chains all the way back to `spring-boot-starter-web@3.2.0`. Three-part fix: (1) Added a new `buildChainFromAllDeps(parentKey, targetPkg)` helper inside `showParentsModal` that walks `currentData.allDependencies[].parents` upward via `parents[0]` (mirrors `view-manager.js::buildPathFromParents` so the deps-page modal and the vuln-page chain display use the same data when SPDX edges aren't enumerated in the repo's `relationships`). Capped at 20 hops with a visited-set cycle guard. (2) `buildDependencyChain` now falls back to `buildChainFromAllDeps` whenever the SPDX walk doesn't reach a root direct dep (tracked via a new `reachedDirect` flag) and the allDeps walk produces a strictly longer chain — so SPDX-incomplete repos still surface the full chain. (3) When `parentsByRepo` is empty, the modal now synthesises a per-repo grouping from the target dep's `repositories` field (looked up in `currentData.allDependencies` by `name@version`), intersecting each repo with each parent's `repositories` so we don't attribute a parent to a repo that never used it. The accordion render path uses this synthesised `workingParentsByRepo` when the original is empty, and the existing per-repo accordion + "→"-separated chain rendering takes over from there. Also threaded `packageVersion` through to the modal as a separate argument (alongside `packageName`) so the dep lookup is exact instead of relying on `name@version` string parsing — the legacy "name@version-in-packageName" call shape still works. Cache-busters bumped on `deps.html`.
diff --git a/about.html b/about.html
index 5a9c9fc..88c11e5 100644
--- a/about.html
+++ b/about.html
@@ -6,8 +6,8 @@
SBOM Play - About
-
-
+
+
@@ -520,6 +520,54 @@ References
+
+
+
+
+
+ The AI , Chat , and Agent pages (linked from
+ Insights ) use Chrome’s built-in Prompt API with
+ Gemini Nano . All prompts, context, and model output stay on your machine — SBOM Play
+ does not upload your analysis to a cloud LLM for these features.
+
+
+
Three modes
+
+
+ Page Behavior
+
+
+ AI One-shot executive summaries (CISO brief, M&A diligence, risk narrative) from a compact digest of InsightsAggregator metrics.
+ Chat Multi-turn conversation; the full analysis digest is included once in the session system prompt.
+ Agent Tool-use loop: the model calls query_sbom(); JavaScript runs typed queries (vulnerabilities, licenses, drift, etc.) against in-memory stored data and returns JSON for synthesis.
+
+
+
+
Availability
+
+ Requires a Chromium browser with the Prompt API and Gemini Nano enabled (see
+ Chrome Prompt API documentation ).
+ When the model is missing or unsupported, each page shows a non-blocking “AI unavailable” state;
+ the rest of SBOM Play continues to work offline from IndexedDB.
+
+
+
+
+ Privacy: user and model text is escaped before lightweight markdown rendering in the UI.
+ The shared adapter (js/ai-nano.js) targets the modern LanguageModel API and
+ falls back to the legacy window.ai.languageModel where needed.
+
+
+
References
+
+
+
+