Skip to content

Commit 49dbbdc

Browse files
committed
polishing
1 parent 1538ec0 commit 49dbbdc

8 files changed

Lines changed: 351 additions & 32 deletions

File tree

lib/asrfacet_rb/web/session_runner.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,16 @@ def build_meta(target)
286286
{
287287
target: target,
288288
generated_at: Time.now.utc.iso8601,
289-
output_directory: output_root
289+
output_directory: output_root,
290+
report_engine: ASRFacet::Output::RuntimeDetector.engine_label
290291
}
291292
rescue StandardError
292-
{ target: target.to_s, generated_at: Time.now.utc.iso8601, output_directory: output_root }
293+
{
294+
target: target.to_s,
295+
generated_at: Time.now.utc.iso8601,
296+
output_directory: output_root,
297+
report_engine: "ASRFacet-Rb"
298+
}
293299
end
294300

295301
def save_report_bundle(target, payload, requested_format:)

lib/asrfacet_rb/web/web_assets/dashboard.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,30 @@ body.theme-dark .graph-edge {
876876
font-size: 12px;
877877
}
878878

879+
.graph-node {
880+
cursor: pointer;
881+
}
882+
883+
.graph-node circle,
884+
.graph-edge {
885+
transition: opacity 0.18s ease, stroke-width 0.18s ease, transform 0.18s ease;
886+
}
887+
888+
.graph-node.active circle {
889+
stroke-width: 4;
890+
}
891+
892+
.graph-node.dimmed circle,
893+
.graph-node.dimmed text,
894+
.graph-edge.dimmed {
895+
opacity: 0.22;
896+
}
897+
898+
.graph-node.connected circle,
899+
.graph-edge.connected {
900+
opacity: 1;
901+
}
902+
879903
.focus-card {
880904
display: grid;
881905
gap: 14px;
@@ -895,11 +919,24 @@ body.theme-dark .graph-edge {
895919
margin-top: 6px;
896920
}
897921

922+
.focus-list {
923+
margin: 10px 0 0;
924+
padding-left: 18px;
925+
color: var(--text-muted);
926+
}
927+
898928
.report-grid {
899929
display: grid;
900930
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
901931
}
902932

933+
.report-summary {
934+
display: grid;
935+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
936+
gap: 12px;
937+
margin: 14px 0 18px;
938+
}
939+
903940
.report-card {
904941
display: grid;
905942
gap: 10px;

lib/asrfacet_rb/web/web_assets/dashboard.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ <h3>Connected recon entities</h3>
331331
<div>
332332
<div class="eyebrow">Graph Inspector</div>
333333
<h3>Relationship notes</h3>
334+
<p class="panel-copy">Select a node to see its linked assets, role in the session, and the shortest next pivot.</p>
334335
</div>
335336
</div>
336337
<div id="graph-focus" class="focus-card"></div>
@@ -348,6 +349,7 @@ <h3>Saved reports</h3>
348349
<p class="panel-copy">Open the presentation report first, then pivot to raw exports when you need scripting or evidence handling.</p>
349350
</div>
350351
</div>
352+
<div id="report-summary" class="report-summary"></div>
351353
<div id="report-links" class="link-grid report-grid"></div>
352354
</section>
353355

lib/asrfacet_rb/web/web_assets/dashboard.js

Lines changed: 151 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ const state = {
1717
sessionFilter: "all",
1818
docsQuery: "",
1919
theme: "light",
20-
bootstrap: {}
20+
bootstrap: {},
21+
selectedGraphNode: null
2122
};
2223

2324
const fields = [
@@ -245,6 +246,7 @@ function fillForm(session) {
245246
el("verbose").checked = config.verbose !== false;
246247
el("adaptive-rate").checked = config.adaptive_rate !== false;
247248
state.dirty = false;
249+
state.selectedGraphNode = null;
248250
syncModeControls();
249251
renderBuilderNotes(formData());
250252
renderCommandPreview(formData());
@@ -561,10 +563,24 @@ function renderSnapshot(session) {
561563

562564
function renderReports(session) {
563565
const artifacts = session?.artifacts || {};
566+
const readyCount = Object.keys(artifactCatalog).filter((key) => Boolean(artifacts[key])).length;
567+
const reportWarnings = Array.isArray(artifacts.report_errors) ? artifacts.report_errors : [];
564568
const links = Object.entries(artifactCatalog)
565569
.filter(([key]) => Boolean(artifacts[key]))
566570
.map(([key, [label, copy, meta]]) => [label, key, copy, meta]);
567571

572+
el("report-summary").innerHTML = [
573+
["Preferred output", labelForFormat(session?.config?.format || "html")],
574+
["Report engine", session?.payload?.meta?.report_engine || session?.meta?.report_engine || "ASRFacet-Rb"],
575+
["Artifacts ready", String(readyCount)],
576+
["Render warnings", reportWarnings.length > 0 ? String(reportWarnings.length) : "None"]
577+
].map(([label, value]) => `
578+
<div class="summary-tile">
579+
<span>${escapeHtml(label)}</span>
580+
<strong>${escapeHtml(value)}</strong>
581+
</div>
582+
`).join("");
583+
568584
el("report-links").innerHTML = links.map(([label, key, copy, meta]) => `
569585
<a class="button button-secondary report-card" target="_blank" rel="noopener" href="/reports/${encodeURIComponent(session.id)}/${key}">
570586
<span class="report-card-title">
@@ -574,7 +590,20 @@ function renderReports(session) {
574590
<span class="report-card-copy">${escapeHtml(copy)}</span>
575591
<span class="report-card-meta">${escapeHtml(meta)}</span>
576592
</a>
577-
`).join("") || '<div class="notice">Reports appear here after the first completed run.</div>';
593+
`).join("");
594+
595+
if (reportWarnings.length > 0) {
596+
el("report-links").innerHTML += reportWarnings.map((warning) => `
597+
<div class="notice">
598+
<strong>${escapeHtml(String(warning.format || "report").toUpperCase())} warning</strong>
599+
<div>${escapeHtml(warning.message || "A renderer reported a recoverable issue.")}</div>
600+
</div>
601+
`).join("");
602+
}
603+
604+
if (!el("report-links").innerHTML.trim()) {
605+
el("report-links").innerHTML = '<div class="notice">Reports appear here after the first completed run.</div>';
606+
}
578607

579608
el("meta-reports").textContent = links.length > 0 ? `${links.length} ready` : "Awaiting";
580609
}
@@ -716,53 +745,159 @@ function extractGraphModel(session) {
716745

717746
const nodes = [];
718747
const edges = [];
748+
const stats = summaryFor(session);
719749
const columns = {
720750
target: { x: 120 },
721751
host: { x: 330 },
722752
service: { x: 560 },
723753
finding: { x: 790 }
724754
};
725755

726-
nodes.push({ id: "target", label: target, type: "target", x: columns.target.x, y: 280 });
756+
nodes.push({
757+
id: "target",
758+
label: target,
759+
type: "target",
760+
x: columns.target.x,
761+
y: 280,
762+
title: "Primary target",
763+
detail: `This is the current session target. The session has ${stats.subdomains || 0} discovered hosts and ${stats.open_ports || 0} open ports.`,
764+
recommendations: ["Validate scope and exclusions before starting broader active work."]
765+
});
727766

728767
hosts.forEach((host, index) => {
729768
const nodeId = `host-${index}`;
730-
nodes.push({ id: nodeId, label: host, type: "host", x: columns.host.x, y: 110 + (index * 90) });
769+
nodes.push({
770+
id: nodeId,
771+
label: host,
772+
type: "host",
773+
x: columns.host.x,
774+
y: 110 + (index * 90),
775+
title: "Discovered host",
776+
detail: `${host} is part of the collected host surface for this session.`,
777+
recommendations: ["Open the HTML or JSON report to inspect linked services and findings for this host."]
778+
});
731779
edges.push({ from: "target", to: nodeId });
732780
});
733781

734782
ports.forEach((service, index) => {
735783
const nodeId = `service-${index}`;
736-
nodes.push({ id: nodeId, label: service, type: "service", x: columns.service.x, y: 150 + (index * 80) });
784+
nodes.push({
785+
id: nodeId,
786+
label: service,
787+
type: "service",
788+
x: columns.service.x,
789+
y: 150 + (index * 80),
790+
title: "Observed service",
791+
detail: `${service} represents a reachable network service observed in the current result set.`,
792+
recommendations: ["Use service output and banner data first, then move into HTTP or certificate review when applicable."]
793+
});
737794
edges.push({ from: hosts[index % Math.max(1, hosts.length)] ? `host-${index % Math.max(1, hosts.length)}` : "target", to: nodeId });
738795
});
739796

740797
findings.forEach((finding, index) => {
741798
const nodeId = `finding-${index}`;
742-
nodes.push({ id: nodeId, label: finding, type: "finding", x: columns.finding.x, y: 180 + (index * 90) });
799+
nodes.push({
800+
id: nodeId,
801+
label: finding,
802+
type: "finding",
803+
x: columns.finding.x,
804+
y: 180 + (index * 90),
805+
title: "Generated finding",
806+
detail: `${finding} is a heuristic or correlated issue worth manual validation.`,
807+
recommendations: ["Confirm severity and ownership before escalating or ticketing the finding."]
808+
});
743809
edges.push({ from: ports[index % Math.max(1, ports.length)] ? `service-${index % Math.max(1, ports.length)}` : "target", to: nodeId });
744810
});
745811

746812
ips.slice(0, 3).forEach((ip, index) => {
747813
const nodeId = `ip-${index}`;
748-
nodes.push({ id: nodeId, label: ip, type: "host", x: columns.host.x, y: 390 + (index * 70) });
814+
nodes.push({
815+
id: nodeId,
816+
label: ip,
817+
type: "host",
818+
x: columns.host.x,
819+
y: 390 + (index * 70),
820+
title: "Resolved IP",
821+
detail: `${ip} is a resolved or directly scanned IP tied to this session.`,
822+
recommendations: ["Check ownership and hosting context before treating shared infrastructure as fully in scope."]
823+
});
749824
edges.push({ from: "target", to: nodeId });
750825
});
751826

752827
return { nodes, edges };
753828
}
754829

830+
function buildGraphSelection(model, selectedId) {
831+
const selectedNode = model.nodes.find((node) => node.id === selectedId) || model.nodes[0];
832+
const connectedIds = new Set([selectedNode?.id].filter(Boolean));
833+
model.edges.forEach((edge) => {
834+
if (edge.from === selectedNode?.id || edge.to === selectedNode?.id) {
835+
connectedIds.add(edge.from);
836+
connectedIds.add(edge.to);
837+
}
838+
});
839+
840+
return { selectedNode, connectedIds };
841+
}
842+
843+
function renderGraphFocus(session, model, selection) {
844+
const summary = summaryFor(session);
845+
const selectedNode = selection.selectedNode;
846+
const linkedNodes = model.nodes.filter((node) => selection.connectedIds.has(node.id) && node.id !== selectedNode?.id);
847+
848+
if (!selectedNode) {
849+
el("graph-focus").innerHTML = `
850+
<div class="focus-block">
851+
<div class="eyebrow">Graph Summary</div>
852+
<strong>${escapeHtml(session?.name || "Untitled session")}</strong>
853+
<div class="nav-hint">${escapeHtml(session?.config?.target || "No target selected")}</div>
854+
</div>
855+
`;
856+
return;
857+
}
858+
859+
el("graph-focus").innerHTML = `
860+
<div class="focus-block">
861+
<div class="eyebrow">Selected Node</div>
862+
<strong>${escapeHtml(selectedNode.label)}</strong>
863+
<div class="nav-hint">${escapeHtml(selectedNode.title || "Graph entity")}</div>
864+
</div>
865+
<div class="focus-block">
866+
<div class="eyebrow">Meaning</div>
867+
<strong>${escapeHtml(selectedNode.detail || "No extra detail is available for this node.")}</strong>
868+
</div>
869+
<div class="focus-block">
870+
<div class="eyebrow">Linked Entities</div>
871+
<strong>${escapeHtml(String(linkedNodes.length))} directly connected node${linkedNodes.length === 1 ? "" : "s"}</strong>
872+
<ul class="focus-list">
873+
${(linkedNodes.length > 0 ? linkedNodes : [{ label: "No direct neighbors", type: "idle" }]).map((node) => `<li>${escapeHtml(node.label)}${node.type ? ` (${escapeHtml(node.type)})` : ""}</li>`).join("")}
874+
</ul>
875+
</div>
876+
<div class="focus-block">
877+
<div class="eyebrow">Recommended Next Move</div>
878+
<strong>${escapeHtml((selectedNode.recommendations || [])[0] || "Use the linked reports and event stream to continue inspection.")}</strong>
879+
</div>
880+
<div class="focus-block">
881+
<div class="eyebrow">Session Context</div>
882+
<strong>${escapeHtml(`${summary.subdomains || 0} hosts, ${summary.open_ports || 0} services, ${summary.findings || 0} findings are currently represented in this session.`)}</strong>
883+
</div>
884+
`;
885+
}
886+
755887
function renderGraph(session) {
756888
const model = extractGraphModel(session);
889+
const selection = buildGraphSelection(model, state.selectedGraphNode || "target");
757890
const edgeMarkup = model.edges.map((edge) => {
758891
const from = model.nodes.find((node) => node.id === edge.from);
759892
const to = model.nodes.find((node) => node.id === edge.to);
760893
if (!from || !to) return "";
761-
return `<line class="graph-edge" x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}"></line>`;
894+
const edgeConnected = edge.from === selection.selectedNode?.id || edge.to === selection.selectedNode?.id;
895+
const edgeClass = edgeConnected ? "graph-edge connected" : (selection.selectedNode ? "graph-edge dimmed" : "graph-edge");
896+
return `<line class="${edgeClass}" x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}"></line>`;
762897
}).join("");
763898

764899
const nodeMarkup = model.nodes.map((node) => `
765-
<g class="graph-node ${escapeHtml(node.type)}">
900+
<g class="graph-node ${escapeHtml(node.type)} ${node.id === selection.selectedNode?.id ? "active" : ""} ${selection.connectedIds.has(node.id) ? "connected" : "dimmed"}" data-node-id="${escapeHtml(node.id)}">
766901
<circle cx="${node.x}" cy="${node.y}" r="${node.type === "target" ? 34 : 28}"></circle>
767902
<text x="${node.x}" y="${node.y + 4}" text-anchor="middle">${escapeHtml(node.label.slice(0, 18))}</text>
768903
</g>
@@ -774,27 +909,13 @@ function renderGraph(session) {
774909
${nodeMarkup}
775910
</svg>
776911
`;
777-
778-
const summary = summaryFor(session);
779-
el("graph-focus").innerHTML = `
780-
<div class="focus-block">
781-
<div class="eyebrow">Graph Summary</div>
782-
<strong>${escapeHtml(session?.name || "Untitled session")}</strong>
783-
<div class="nav-hint">${escapeHtml(session?.config?.target || "No target selected")}</div>
784-
</div>
785-
<div class="focus-block">
786-
<div class="eyebrow">Connected counts</div>
787-
<strong>${escapeHtml(String(summary.subdomains || 0))} hosts, ${escapeHtml(String(summary.open_ports || 0))} services, ${escapeHtml(String(summary.findings || 0))} findings</strong>
788-
</div>
789-
<div class="focus-block">
790-
<div class="eyebrow">Interpretation</div>
791-
<strong>${summary.findings > 0 ? "The graph is showing service-to-finding pivots so the likely review path is visible." : "The graph is showing target-to-host and host-to-service relationships for infrastructure review."}</strong>
792-
</div>
793-
<div class="focus-block">
794-
<div class="eyebrow">Operator note</div>
795-
<strong>Use this tab to reason about pivots and ownership relationships before opening the detailed reports.</strong>
796-
</div>
797-
`;
912+
Array.from(el("graph-canvas").querySelectorAll("[data-node-id]")).forEach((node) => {
913+
node.addEventListener("click", () => {
914+
state.selectedGraphNode = node.getAttribute("data-node-id");
915+
renderGraph(session);
916+
});
917+
});
918+
renderGraphFocus(session, model, selection);
798919
}
799920

800921
function syncChrome(session) {

0 commit comments

Comments
 (0)