//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Explore attack paths and alerts on a cloud infrastructure.
import KeyLines from "keylines";
import { annotationPositions, colours, data } from "./cloudsecurity-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.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}
window.addEventListener("DOMContentLoaded", loadFontsAndStart);
<!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="/css/keylines.css">
<link rel="stylesheet" type="text/css" href="/css/minimalsdk.css">
<link rel="stylesheet" type="text/css" href="/css/sdk-layout.css">
<link rel="stylesheet" type="text/css" href="/css/demo.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/fontawesome.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/solid.css">
<link rel="stylesheet" type="text/css" href="/cloudsecurity.css">
<script src="/vendor/jquery.js" defer type="text/javascript"></script>
<script id="keylines" src="/public/keylines.umd.cjs" defer type="text/javascript"></script>
<script src="/cloudsecurity.js" crossorigin="use-credentials" defer type="module"></script>
</head>
<body>
<div class="chart-wrapper demo-cols">
<div class="tab-content-panel flex1" id="lhs" data-tab-group="rhs">
<div class="toggle-content is-visible" id="lhsVisual" style="width:100%; height: 100%;">
<div class="klchart" id="klchart">
</div>
</div>
</div>
<div class="rhs citext closed" id="demorhscontent">
<div class="title-bar">
<svg viewBox="0 0 360 60" style="max-width: 360px; max-height: 60px; font-size: 24px; font-family: Raleway; flex: 1;">
<text x="0" y="38" style="width: 100%;">Cloud Security</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<p>Organise your tiered data into familiar structures with rectangular combos and sequential combo arrangements. Use different link shapes to indicate types of relationship.</p>
<p>
Click the options below to alternate between different cloud infrastructure views,
and a focussed view of the attack path.
</p>
<fieldset>
<legend style="margin-bottom:4px">Select view</legend>
<label class="radio">
<input type="radio" name="viewOptions" value="full" checked="checked"> Full infrastructure
</label>
<label class="radio">
<input type="radio" name="viewOptions" value="fullAttackPath"> Infrastructure with attack path
</label>
<label class="radio">
<input type="radio" name="viewOptions" value="onlyAttackPath"> Attack path only
</label>
</fieldset>
<fieldset class="hidden" id="alertPanel">
<legend style="font-size: 18px">Currently showing</legend>
<div id="identifiedAlerts"> <i class="fa fa-exclamation-triangle"></i>
<div>Misconfigured S3 Buckets</div>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
#identifiedAlerts {
padding: 14px;
color: white;
display: flex;
align-items: center;
gap: 14px;
background-color: rgb(22, 45, 66);
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;
}
Loading source
