Information-heavy tiered data, such as cloud infrastructures, can be difficult to visualise in a way that's understandable at a glance.
Rectangular combos with sequential arrangements provide a clear, explorable and space-efficient view.
You can drill down into any issues highlighted in the infrastructure by opening combos, and tracing an attack path across all affected components.
Styling rectangular combos
You can style open rectangular combos in various ways to fit perfectly into your application such as rounding corners, as described in Node shapes and sizes.
To reduce the information load on child nodes, you can add multiple text or font icon labels to the combos themselves; see Advanced combo styling.
Sequential arrangement inside combos
Using a sequential arrangement inside combos can help by revealing more of the hierarachy as you open combos. This arrangement also fits efficiently into rectangular combos using the space well.
Link shape
Angled links reflect the tree-like structure of the data, and help you visualise relationships more clearly. In addition, some links stand out by being curved, using their linkShape property.
Key functions used:
- chart combo functions
- chart.hide()
- chart.ping()
- chart.foreground()
- chart.setProperties()
- chart options 'combos'
- Annotations API
import KeyLines from "keylines";
import { annotationPositions, colours, data } from "./data.js";
let chart;
let graphEngine;
let comboIds;
let currentView = "full";
const comboCrossingLinks = [
"Internet-gateway-elb-1",
"Internet-gateway-elb-2",
"gateway-elb-1-lambda-2b154e",
"gateway-elb-1-lambda-2b354e",
"gateway-elb-2-lambda-2b154e",
"gateway-elb-2-lambda-2b354e",
];
const comboOpenCloseOpts = { adapt: "none", animate: true, time: 70 };
const innerCombos = ["demo-private-us-west-1a", "demo-private-us-west-1f"];
const sequentialArrangeOpts = {
...comboOpenCloseOpts,
animate: false,
name: "sequential",
tightness: 4,
stacking: { arrange: "grid" },
orientation: "right",
linkShape: "angled",
};
const layoutOpts = {
orientation: "right",
linkShape: "angled",
};
const alertNodeIds = [
"s3-bucket-1a",
"s3-bucket-2a",
"ec2-13cc45d",
"lambda-2b154e",
"gateway-elb-1",
"gateway-elb-2",
"Internet",
];
const selectedIdsSet = new Set();
const viewOptionsRadios = Array.from(document.querySelectorAll('input[name="viewOptions"]'));
const alertPanel = document.getElementById("alertPanel");
async function showAlert() {
// Hide non-alerts
const nodesToHide = [];
chart.each({ type: "node", items: "all" }, ({ id }) => {
if (!alertNodeIds.includes(id) && !chart.combo().isCombo(id)) {
nodesToHide.push(id);
}
});
await chart.hide(nodesToHide, { animate: true, time: 500 });
// Uncombine all
await chart.combo().uncombine("aws", { full: true, select: false, animate: false });
await setWarningAnnotationPosition(annotationPositions.onlyAttackPath);
// Sequential layout left to right with Internet first
await chart.layout("sequential", { ...layoutOpts, tightness: 7 });
// Ping s3 buckets and show alert in ne pos
await chart.ping(["s3-bucket-1a", "s3-bucket-2a"], {
c: colours.lightGrey,
repeat: 3,
time: 500,
});
}
// Determines which items need styling updates after a selection change
function getSelectionActions(idsToSelect) {
const idsToSelectSet = new Set(idsToSelect);
const itemsToStyle = {};
idsToSelectSet.forEach((id) => {
if (!selectedIdsSet.has(id)) {
itemsToStyle[id] = "select";
}
});
selectedIdsSet.forEach((id) => {
if (!idsToSelectSet.has(id)) {
itemsToStyle[id] = "deselect";
}
});
return itemsToStyle;
}
async function updateSelectionStyling(selectionActions) {
const itemsToUpdate = [];
// Update items which require re-styling
for (const [id, action] of Object.entries(selectionActions)) {
const item = chart.getItem(id);
if (action === "select") {
// Set selection styling
item.t[0].b = colours.white;
item.b = colours.white;
selectedIdsSet.add(id);
} else {
// Undo selection styling
item.t[0].b = colours.transparent;
item.b = colours.transparent;
selectedIdsSet.delete(id);
}
itemsToUpdate.push({ id, t: item.t, b: item.b });
}
await chart.setProperties(itemsToUpdate);
}
async function highlightSelection(idsToSelect) {
const selectionActions = getSelectionActions(idsToSelect);
updateSelectionStyling(selectionActions);
if (idsToSelect.length > 0) {
graphEngine.load(chart.serialize());
const nonComboNeighbours = graphEngine.neighbours(idsToSelect);
const links = nonComboNeighbours.links;
chart.foreground(
(item) =>
links.includes(item.id) ||
idsToSelect.includes(item.id) ||
nonComboNeighbours.nodes.includes(item.id)
);
} else {
// Clear selection
chart.combo().reveal(comboCrossingLinks);
chart.foreground(() => true);
}
}
async function colourAlertItems() {
const props = [];
chart.each({ type: "node", items: "all" }, ({ id, u }) => {
if (!u || chart.combo().isCombo(id)) return;
const c = alertNodeIds.includes(id) ? colours.alert : colours.nonAlert;
props.push({ id, c });
});
chart.each({ type: "link", items: "underlying" }, ({ id, id1, id2 }) => {
if (alertNodeIds.includes(id2)) {
props.push({ id, c: colours.alert, w: 2, priority: 1 });
}
});
await chart.setProperties(props);
}
async function init(animate = true) {
graphEngine = KeyLines.getGraphEngine();
await chart.load(data);
comboIds = getComboIds();
await openAllCombos(animate);
await updateComboCounters();
await revealLinks();
await chart.layout("sequential", { ...layoutOpts, animate: false, straighten: false });
}
// Updates the warning annotation position angle via setProperties
async function setWarningAnnotationPosition(position) {
await chart.setProperties({
id: "s3-warning-annotation",
position,
});
}
async function resetToInfrastructureView() {
if (currentView === "onlyAttackPath") {
await init(false);
alertPanel.classList.add("hidden");
await setWarningAnnotationPosition(annotationPositions.infrastructure);
}
}
// Toggles the disabled state of unselected radio inputs
function toggleUnselectedRadios(selectedRadioValue) {
viewOptionsRadios.forEach((radio) => {
if (radio.value !== selectedRadioValue) {
radio.disabled = !radio.disabled;
}
});
}
const viewChangeHandlers = {
full: async (e) => {
await resetToInfrastructureView();
await undoColourAlertItems();
currentView = e.target.value;
},
fullAttackPath: async (e) => {
await resetToInfrastructureView();
await colourAlertItems();
currentView = e.target.value;
},
onlyAttackPath: async (e) => {
alertPanel.classList.remove("hidden");
const selectedRadioValue = e.target.value;
toggleUnselectedRadios(selectedRadioValue);
if (currentView === "full") {
await colourAlertItems();
}
await showAlert();
toggleUnselectedRadios(selectedRadioValue);
currentView = selectedRadioValue;
},
};
async function undoColourAlertItems() {
const props = [];
chart.each({ type: "node", items: "all" }, ({ id, u }) => {
if (!u || chart.combo().isCombo(id)) return;
const iconName = u.split("/").pop().split(".").shift();
props.push({ id, c: colours[iconName] });
});
chart.each({ type: "link", items: "underlying" }, ({ id, id1, id2 }) => {
if (alertNodeIds.includes(id1) || alertNodeIds.includes(id2)) {
props.push({ id, c: colours.lightGrey, w: 1, priority: 0 });
}
});
chart.setProperties(props);
}
// Called when KeyLines is created
function initializeInteractions() {
viewOptionsRadios.forEach((radio) => {
radio.addEventListener("change", (e) => {
const selectedView = e.target.value;
viewChangeHandlers[selectedView](e);
});
});
chart.on("selection-change", () => {
const ids = chart
.selection()
.filter((id) => chart.getItem(id).type === "node" && !chart.combo().isCombo(id));
highlightSelection(ids);
});
chart.on("double-click", ({ id, preventDefault }) => {
if (!id) return;
const parent = chart.combo().find(id, { parent: "first" });
const isCombo = chart.combo().isCombo(id);
if (isCombo || parent !== null) {
preventDefault();
}
if (!isCombo) return;
const comboIsOpen = chart.combo().isOpen(id);
const op = comboIsOpen ? chart.combo().close : chart.combo().open;
op(id, comboOpenCloseOpts);
});
}
function getComboIds() {
comboIds = [];
chart.each({ type: "node", items: "all" }, ({ id }) => {
if (chart.combo().isCombo(id)) comboIds.push(id);
});
return comboIds;
}
async function openAllCombos(animate) {
await chart.combo().open(comboIds, { ...comboOpenCloseOpts, animate });
await chart.combo().arrange(innerCombos, sequentialArrangeOpts);
await chart.combo().arrange("demo-vpc", sequentialArrangeOpts);
}
async function updateComboCounters() {
const props = comboIds.map((comboId) => {
const combo = chart.getItem(comboId);
const childNodeCount = chart.combo().info(comboId).nodes.length;
combo.t[combo.t.length - 1].t = childNodeCount;
return { id: comboId, t: combo.t };
});
await chart.setProperties(props);
}
async function revealLinks() {
// Reveal links
chart.combo().reveal(comboCrossingLinks);
// Hide combo links
const comboLinks = [];
chart.each({ type: "link", items: "all" }, ({ id }) => {
if (chart.combo().isCombo(id)) comboLinks.push(id);
});
const hideComboLinks = comboLinks.map((link) => {
return {
id: link,
// use c: 'transparent' instead of hi: true so links are still considered by sequential layout
c: "transparent",
};
});
await chart.setProperties([...hideComboLinks]);
}
async function startKeyLines() {
const options = {
handMode: true,
gradient: {
stops: [
{ r: 0, c: "#246" },
{ r: 1, c: "#123" },
],
},
controlTheme: "dark",
overview: { icon: false },
combos: { shape: "rectangle" },
selectedNode: {},
selectedLink: {},
defaultStyles: {
comboGlyph: null,
},
fontFamily: "Muli",
iconFontFamily: "Font Awesome 5 Free",
imageAlignment: {
"fas fa-cloud": { e: 0.88 },
},
};
chart = await KeyLines.create({ container: "klchart", options });
await init();
initializeInteractions();
}
function loadFontsAndStart() {
document.fonts.ready
.then(() =>
Promise.all([
document.fonts.load('24px "Muli"'),
document.fonts.load('24px "Font Awesome 5 Free"'),
])
)
.then(startKeyLines);
}
window.addEventListener("DOMContentLoaded", loadFontsAndStart); import KeyLines from "keylines";
export const colours = {
orange: "rgb(255,153,0)",
blue: "rgb(20,110,180)",
white: "rgb(255,255,255)",
black: "rgb(0,0,0)",
transparent: "rgba(0,0,0,0)",
darkGrey: "rgb(35,47,62)",
lightGrey: "rgb(242,242,242)",
combo: "rgba(242,242,242,0.1)",
comboLabel: "rgba(242,242,242,0.1)",
alert: "rgb(253,69,75)",
nonAlert: "rgb(34, 68, 102)",
internet: "rgb(116,116,116)",
alb: "rgb(140, 79, 255)",
db: "rgb(201, 37, 209)",
ec2: "rgb(237, 113, 0)",
lambda: "rgb(237,113,0)",
lb: "rgb(140, 79, 255)",
s3: "rgb(34, 136, 85)",
};
const selectionBorderWidth = 2.5;
const closedComboBorderWidth = 1.5;
function createNode(label, imageName, parentId = undefined) {
const nodeLabel = {
t: label,
padding: { left: 6, right: 6, bottom: 2, top: 4 },
margin: { top: 2 },
position: "s",
b: colours.transparent,
bw: selectionBorderWidth,
fbc: colours.darkGrey,
fc: colours.white,
borderRadius: 5,
ff: "verdana",
fs: 16,
};
return {
type: "node",
c: colours[imageName],
w: 72,
h: 72,
borderRadius: 5,
b: colours.transparent,
bw: selectionBorderWidth,
id: label,
...(parentId !== undefined ? { parentId } : {}),
t: nodeLabel,
u: `/images/icons/${imageName}.svg`,
};
}
function createCombo(name, parentId = undefined) {
const comboBanner = {
fbc: colours.darkGrey,
minWidth: "stretch",
position: "n",
borderRadius: "5 5 0 0",
bw: 0,
minHeight: 40,
margin: { bottom: -3.1 },
textAlignment: { horizontal: "left" },
ff: "verdana",
fc: colours.white,
fs: 20,
};
const cloudFontIcon = { t: "fas fa-cloud", c: colours.orange };
const comboIcon = {
fi: cloudFontIcon,
position: "n",
fbc: colours.transparent,
minHeight: 42,
minWidth: "stretch",
margin: { bottom: -3.1, right: -10 },
textAlignment: { horizontal: "left" },
fs: 30,
};
const closedComboStyle = {
b: colours.lightGrey,
bw: closedComboBorderWidth,
c: colours.combo,
sh: "box",
borderRadius: 5,
w: 84,
h: 84,
};
return {
id: name,
type: "node",
...closedComboStyle,
fi: cloudFontIcon,
c: colours.darkGrey,
t: [
{
fbc: colours.darkGrey,
b: colours.lightGrey,
bw: closedComboBorderWidth,
borderRadius: 5,
margin: { top: 2 },
position: "s",
textAlignment: { horizontal: "centre" },
padding: "6 8 6 8",
t: name,
fc: colours.white,
fs: 18,
},
{
t: "X",
position: "ne",
margin: { bottom: -20, left: -20 },
fbc: colours.darkGrey,
borderRadius: 5,
fc: colours.white,
padding: "6 4 4 4",
b: colours.white,
bw: closedComboBorderWidth,
textAlignment: { vertical: "middle", horizontal: "centre" },
minWidth: 20,
minHeight: 18,
fs: 18,
},
],
oc: {
c: colours.combo,
borderRadius: "0 0 5 5",
bw: 0,
b: colours.transparent,
t: [{ ...comboBanner, t: ` ${name}` }, comboIcon],
},
...(parentId !== undefined ? { parentId } : {}),
};
}
function createLink(id1, id2, opts) {
return {
type: "link",
id: `${id1}-${id2}`,
id1,
id2,
a2: true,
c: colours.white,
...opts,
};
}
export const annotationPositions = {
infrastructure: { angle: "e", distance: 80 },
onlyAttackPath: { angle: "n", distance: 80 },
};
const annotationWidth = 274;
const annotationHorizontalPadding = 10;
const warningAnnotation = {
id: "s3-warning-annotation",
type: "annotation",
subject: ["s3-bucket-1a", "s3-bucket-2a"],
connectorStyle: { container: "rectangle", c: "rgb(252, 176, 30)", w: 2, subjectEnd: "dot" },
position: annotationPositions.infrastructure,
c: "rgb(22, 45, 66)",
bw: 2,
b: "rgb(252, 176, 30)",
w: annotationWidth,
h: 80,
t: [
{
t: "Misconfigured S3 Buckets",
fc: "rgb(255, 255, 255)",
ff: "Muli",
fb: true,
position: {
horizontal: "left",
vertical: "top",
},
padding: [16, annotationHorizontalPadding, 3, annotationHorizontalPadding],
},
{
t: "S3 buckets have a policy which allows unauthorised access",
fc: "rgb(255, 255, 255)",
ff: "Muli",
minWidth: "stretch",
padding: [3, annotationHorizontalPadding, 0, annotationHorizontalPadding],
},
],
};
export const data = {
type: "LinkChart",
items: [
createCombo("demo-private-us-west-1a", "demo-vpc"),
createNode("s3-bucket-1a", "s3", "demo-private-us-west-1a"),
createNode("s3-bucket-2a", "s3", "demo-private-us-west-1a"),
createNode("lambda-2b154e", "lambda", "demo-private-us-west-1a"),
createNode("ec2-13cc45d", "ec2", "demo-private-us-west-1a"),
createNode("customer data-copy-1a", "db", "demo-private-us-west-1a"),
createCombo("demo-private-us-west-1f", "demo-vpc"),
createNode("s3-bucket-1f", "s3", "demo-private-us-west-1f"),
createNode("s3-bucket-2f", "s3", "demo-private-us-west-1f"),
createNode("lambda-2b354e", "lambda", "demo-private-us-west-1f"),
createNode("ec2-13cd45d", "ec2", "demo-private-us-west-1f"),
createNode("customer data-copy-1f", "db", "demo-private-us-west-1f"),
createNode("gateway-elb-1", "lb", "demo-vpc"),
createNode("gateway-elb-2", "lb", "demo-vpc"),
createNode("desktop-app-alb-1", "alb", "demo-vpc"),
createCombo("demo-vpc", "us-west"),
createCombo("us-west", "aws"),
createCombo("aws"),
createNode("Internet", "internet"),
createLink("ec2-13cc45d", "s3-bucket-1a"),
createLink("ec2-13cc45d", "s3-bucket-2a"),
createLink("ec2-13cc45d", "customer data-copy-1a"),
createLink("lambda-2b154e", "ec2-13cc45d"),
createLink("ec2-13cd45d", "s3-bucket-1f"),
createLink("ec2-13cd45d", "s3-bucket-2f"),
createLink("ec2-13cd45d", "customer data-copy-1f"),
createLink("lambda-2b354e", "ec2-13cd45d"),
createLink("Internet", "gateway-elb-1", { ls: "dashed", linkShape: { name: "curved" } }),
createLink("Internet", "gateway-elb-2", { ls: "dashed", linkShape: { name: "curved" } }),
createLink("gateway-elb-1", "lambda-2b154e", { ls: "dashed", linkShape: { name: "curved" } }),
createLink("gateway-elb-1", "lambda-2b354e", { ls: "dashed", linkShape: { name: "curved" } }),
createLink("gateway-elb-2", "lambda-2b154e", { ls: "dashed", linkShape: { name: "curved" } }),
createLink("gateway-elb-2", "lambda-2b354e", { ls: "dashed", linkShape: { name: "curved" } }),
warningAnnotation,
],
}; <!doctype html>
<html lang="en" style="background-color: #2d383f">
<head>
<meta charset="utf-8" />
<title>Cloud Security</title>
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/keylines.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
<link
rel="stylesheet"
type="text/css"
href="@fortawesome/[email protected]/css/fontawesome.css"
/>
<link
rel="stylesheet"
type="text/css"
href="@fortawesome/[email protected]/css/solid.css"
/>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="klchart" class="klchart"></div>
<script type="module" src="./code.js"></script>
</body>
</html> #identifiedAlerts {
padding: 14px;
color: white;
display: flex;
align-items: center;
gap: 14px;
background-color: hsl(214, 15%, 91%);
border-bottom: 2px solid rgb(252, 174, 30);
width: 100%;
font-weight: bold;
}
label:has(input[type="radio"]:disabled),
label:has(input[type="radio"]:disabled) * {
cursor: not-allowed;
}
.fa-exclamation-triangle {
color: rgb(252, 174, 30);
margin-bottom: 2px;
}