Search

Cloud Security

Combos

Explore attack paths and alerts on a cloud infrastructure.

Cloud Security
View live example →

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.

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:

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;
}

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.