aws_security_viz 0.2.5.pre.alpha.pre.35 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +120 -79
  4. data/exe/aws_security_viz +4 -40
  5. data/lib/aws_security_viz/aws_config.rb +80 -0
  6. data/lib/aws_security_viz/cli.rb +151 -0
  7. data/lib/aws_security_viz/cli_guard.rb +39 -0
  8. data/lib/aws_security_viz/directed_graph.rb +72 -0
  9. data/lib/aws_security_viz/ec2/security_groups.rb +74 -0
  10. data/lib/aws_security_viz/exclusions.rb +18 -0
  11. data/lib/aws_security_viz/export/html/viewer.html +222 -0
  12. data/lib/aws_security_viz/graph.rb +93 -0
  13. data/lib/aws_security_viz/graph_filter.rb +20 -0
  14. data/lib/aws_security_viz/logging.rb +18 -0
  15. data/lib/aws_security_viz/model.rb +87 -0
  16. data/lib/aws_security_viz/obfuscation.rb +45 -0
  17. data/lib/{opts.yml.sample → aws_security_viz/opts.yml.sample} +2 -0
  18. data/lib/aws_security_viz/port_label.rb +44 -0
  19. data/lib/aws_security_viz/provider/ec2.rb +84 -0
  20. data/lib/aws_security_viz/provider/json.rb +17 -0
  21. data/lib/aws_security_viz/renderer/all.rb +49 -0
  22. data/lib/aws_security_viz/renderer/graphviz.rb +119 -0
  23. data/lib/aws_security_viz/renderer/html.rb +46 -0
  24. data/lib/aws_security_viz/renderer/json.rb +29 -0
  25. data/lib/aws_security_viz/renderer/mermaid.rb +105 -0
  26. data/lib/aws_security_viz/risk.rb +18 -0
  27. data/lib/aws_security_viz/vendor/cytoscape/LICENSE +19 -0
  28. data/lib/aws_security_viz/vendor/cytoscape/README.md +15 -0
  29. data/lib/aws_security_viz/vendor/cytoscape/cytoscape.min.js +31 -0
  30. data/lib/aws_security_viz/version.rb +5 -0
  31. data/lib/aws_security_viz.rb +44 -37
  32. metadata +39 -232
  33. data/.dockerignore +0 -7
  34. data/.editorconfig +0 -17
  35. data/.github/dependabot.yml +0 -15
  36. data/.github/workflows/ruby.yml +0 -34
  37. data/.github/workflows/rubygem.yml +0 -29
  38. data/.github/workflows/rubygem_release.yml +0 -27
  39. data/.gitignore +0 -43
  40. data/.tool-versions +0 -1
  41. data/CODE_OF_CONDUCT.md +0 -46
  42. data/Dockerfile +0 -9
  43. data/Gemfile +0 -3
  44. data/Rakefile +0 -15
  45. data/Vagrantfile +0 -18
  46. data/aws_security_viz.gemspec +0 -40
  47. data/config/boot.rb +0 -4
  48. data/images/sample.png +0 -0
  49. data/lib/aws_config.rb +0 -44
  50. data/lib/color_picker.rb +0 -245
  51. data/lib/debug/parse_log.rb +0 -26
  52. data/lib/debug_graph.rb +0 -29
  53. data/lib/ec2/ip_permission.rb +0 -35
  54. data/lib/ec2/security_groups.rb +0 -78
  55. data/lib/ec2/traffic.rb +0 -32
  56. data/lib/exclusions.rb +0 -14
  57. data/lib/export/html/navigator.html +0 -84
  58. data/lib/export/html/view.html +0 -163
  59. data/lib/graph.rb +0 -45
  60. data/lib/graph_filter.rb +0 -38
  61. data/lib/provider/ec2.rb +0 -105
  62. data/lib/provider/json.rb +0 -104
  63. data/lib/renderer/all.rb +0 -18
  64. data/lib/renderer/graphviz.rb +0 -37
  65. data/lib/renderer/json.rb +0 -23
  66. data/lib/renderer/navigator.rb +0 -33
  67. data/lib/version.rb +0 -3
  68. data/spec/color_picker_spec.rb +0 -20
  69. data/spec/graph_filter_spec.rb +0 -85
  70. data/spec/integration/aws_expected.json +0 -1
  71. data/spec/integration/dummy.dot +0 -50
  72. data/spec/integration/dummy.json +0 -64
  73. data/spec/integration/expected.json +0 -1
  74. data/spec/integration/navigator.json +0 -1
  75. data/spec/integration/visualize_aws_spec.rb +0 -96
  76. data/spec/spec_helper.rb +0 -36
  77. data/spec/visualize_aws_spec.rb +0 -194
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "version"
5
+ require_relative "logging"
6
+ require_relative "../aws_security_viz"
7
+
8
+ module AwsSecurityViz
9
+ # Command line front end: `CLI.new(argv).run` returns the exit status (0, 1, or 130 on Ctrl-C).
10
+ class CLI
11
+ RISK_EXIT = 2
12
+
13
+ def initialize(argv, env: ENV, out: $stdout, err: $stderr)
14
+ @argv = argv.dup
15
+ @env = env
16
+ @out = out
17
+ @err = err
18
+ end
19
+
20
+ def run
21
+ AwsSecurityViz.logger = AwsSecurityViz.build_logger(@err)
22
+ opts = defaults
23
+ parser = build_parser(opts)
24
+ begin
25
+ parser.parse!(@argv)
26
+ rescue OptionParser::ParseError => e
27
+ AwsSecurityViz.logger.error("#{e.message} (try --help)")
28
+ return 1
29
+ end
30
+ return 0 if opts[:exit]
31
+ command = @argv.shift
32
+ unknown = [command, *@argv].compact.reject { |a| a == command && %w[setup init].include?(a) }
33
+ unless unknown.empty?
34
+ AwsSecurityViz.logger.error("unknown command '#{unknown.first}' (expected setup or init; try --help)")
35
+ return 1
36
+ end
37
+ return setup(opts) if command
38
+ if opts[:source_file] && (opts[:region] || opts[:all_regions])
39
+ AwsSecurityViz.logger.warn("--region and --all-regions are ignored with --source-file")
40
+ end
41
+ if opts[:source_file] && opts[:show_unused]
42
+ AwsSecurityViz.logger.warn("--show-unused is ignored with --source-file: network interfaces are not part of describe-security-groups output")
43
+ opts.delete(:show_unused)
44
+ end
45
+ visualize(opts)
46
+ end
47
+
48
+ private
49
+
50
+ def defaults
51
+ {
52
+ access_key: @env["AWS_ACCESS_KEY"] || @env["AWS_ACCESS_KEY_ID"],
53
+ secret_key: @env["AWS_SECRET_KEY"] || @env["AWS_SECRET_ACCESS_KEY"],
54
+ session_token: @env["AWS_SESSION_TOKEN"],
55
+ config: "opts.yml",
56
+ color: false,
57
+ debug: nil,
58
+ obfuscate: nil
59
+ }
60
+ end
61
+
62
+ def build_parser(opts)
63
+ OptionParser.new do |o|
64
+ o.banner = "Usage: aws_security_viz [options] [setup|init]"
65
+ o.on("-a", "--access-key=KEY", "AWS access key") { |v| opts[:access_key] = v }
66
+ o.on("-s", "--secret-key=KEY", "AWS secret key") { |v| opts[:secret_key] = v }
67
+ o.on("-e", "--session-token=TOKEN", "AWS session token") { |v| opts[:session_token] = v }
68
+ o.on("-r", "--region=REGION", "AWS region(s) to query, comma-separated (default: SDK chain, e.g. AWS_REGION or profile)") { |v| opts[:region] = v }
69
+ o.on("--all-regions", "Query every region returned by DescribeRegions") { opts[:all_regions] = true }
70
+ o.on("-p", "--profile=NAME", "AWS shared-config profile to use (AWS_PROFILE is read by the SDK)") { |v| opts[:profile] = v }
71
+ o.on("-v", "--vpc-id=ID", "AWS VPC id to show") { |v| opts[:vpc_id] = v }
72
+ o.on("-o", "--source-file=FILE", "--input=FILE", "JSON source file containing security groups") { |v| opts[:source_file] = v }
73
+ o.on("-f", "--filename=FILE", "--output=FILE", "Output file; the format follows the extension: .html, .json, .mmd, .dot/.gv or an image such as .png/.svg (default: aws-security-viz.html)") { |v| opts[:filename] = v }
74
+ o.on("-c", "--config=FILE", "Config file (opts.yml)") { |v| opts[:config] = v }
75
+ o.on("-l", "--[no-]color", "Deprecated, ignored: edges are blue for ingress and red for egress, risky public ingress is dashed crimson") { |v| opts[:color] = v }
76
+ o.on("-n", "--renderer=NAME", "Deprecated, use the -f extension instead: renderer (#{Renderer.all.join("|")})") { |v| opts[:renderer] = v }
77
+ o.on("-y", "--layout=ENGINE", "Graphviz layout engine (#{AwsConfig::LAYOUTS.join("|")}); overrides opts.yml format") { |v| opts[:layout] = v }
78
+ o.on("-d", "--[no-]debug", "Verbose output and stack traces (or DEBUG=true)") { |v| opts[:debug] = v }
79
+ o.on("-b", "--[no-]obfuscate", "Hash group names and ports (or OBFUSCATE=true)") { |v| opts[:obfuscate] = v }
80
+ o.on("-u", "--source-filter=FILTER", "Source filter") { |v| opts[:source_filter] = v }
81
+ o.on("-t", "--target-filter=FILTER", "Target filter") { |v| opts[:target_filter] = v }
82
+ o.on("--show-unused", "Mark groups with no attached network interfaces (needs ec2:DescribeNetworkInterfaces; ignored with --source-file)") { opts[:show_unused] = true }
83
+ o.on("--fail-on-risk", "Exit 2 when 0.0.0.0/0 or ::/0 can reach a sensitive port (the output is still written)") { opts[:fail_on_risk] = true }
84
+ o.on("-i", "--version", "Print version and exit") {
85
+ @out.puts "aws_security_viz v#{VERSION}"
86
+ opts[:exit] = true
87
+ }
88
+ o.on("-h", "--help", "Show this message") {
89
+ @out.puts o
90
+ opts[:exit] = true
91
+ }
92
+ end
93
+ end
94
+
95
+ def setup(opts)
96
+ AwsConfig.write(opts[:config])
97
+ @out.puts "#{opts[:config]} created in current directory."
98
+ 0
99
+ end
100
+
101
+ def visualize(opts)
102
+ debug = false
103
+ CliGuard.run(debug: -> { debug }) do
104
+ AwsSecurityViz.logger.warn("--color is deprecated and ignored; edges are blue (ingress) or red (egress)") if opts[:color]
105
+ # An explicit --[no-]debug / --[no-]obfuscate beats the env var; nil means "not given".
106
+ debug = opts[:debug].nil? ? AwsConfig.boolean(@env["DEBUG"], "DEBUG") : opts[:debug]
107
+ overrides = {
108
+ obfuscate: opts[:obfuscate].nil? ? AwsConfig.boolean(@env["OBFUSCATE"], "OBFUSCATE") : opts[:obfuscate],
109
+ debug: debug,
110
+ layout: opts[:layout]
111
+ }.compact
112
+ config = AwsConfig.load(opts[:config]).merge(overrides)
113
+ opts[:renderer] = resolve_renderer(opts[:renderer])
114
+ filename = opts[:filename] || Renderer.default_file(opts[:renderer] || Renderer::DEFAULT)
115
+ warn_renderer_conflict(opts[:renderer], opts[:filename])
116
+ risky = VisualizeAws.new(config, opts).unleash(filename).to_i
117
+ report_risk(risky)
118
+ return RISK_EXIT if risky > 0 && opts[:fail_on_risk]
119
+ end
120
+ end
121
+
122
+ def report_risk(count)
123
+ if count > 0
124
+ AwsSecurityViz.logger.warn("#{count} risky edge#{"s" unless count == 1}: public ingress (0.0.0.0/0 or ::/0) on a sensitive port or all traffic")
125
+ else
126
+ AwsSecurityViz.logger.info("no risky public ingress found")
127
+ end
128
+ end
129
+
130
+ # An explicit --renderer wins over the -f extension; say so when the two disagree (e.g. -n json -f out.png).
131
+ def warn_renderer_conflict(renderer, file_name)
132
+ return if renderer.nil? || file_name.nil?
133
+ ext = File.extname(file_name.to_s).delete_prefix(".").downcase
134
+ inferred = Renderer::EXTENSIONS[ext]
135
+ return if inferred.nil? || inferred == renderer.to_sym
136
+ AwsSecurityViz.logger.warn("--renderer #{renderer} overrides the .#{ext} extension of #{file_name}; the file will be written as #{renderer}")
137
+ end
138
+
139
+ # --renderer is a deprecated alias for the extension-based format; the removed navigator renderer is the html viewer now.
140
+ def resolve_renderer(name)
141
+ return nil if name.nil?
142
+ if name.to_s == "navigator"
143
+ AwsSecurityViz.logger.warn("--renderer navigator is deprecated and now writes the html viewer; use -f FILE.html")
144
+ return "html"
145
+ end
146
+ Renderer.validate!(name)
147
+ AwsSecurityViz.logger.warn("--renderer is deprecated; the output format is inferred from the -f extension")
148
+ name
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-ec2"
4
+ require_relative "logging"
5
+
6
+ module AwsSecurityViz
7
+ # Runs the CLI body and turns failures into a message and an exit status:
8
+ # 0 on success, 130 on Ctrl-C (quietly), 1 on any other error. `debug` (a flag or a callable)
9
+ # re-raises after printing so the backtrace is visible.
10
+ module CliGuard
11
+ INTERRUPTED = 130
12
+
13
+ def self.run(debug: false, logger: AwsSecurityViz.logger)
14
+ yield
15
+ 0
16
+ rescue Interrupt
17
+ INTERRUPTED
18
+ rescue => e
19
+ logger.error(message(e))
20
+ raise e if debug.respond_to?(:call) ? debug.call : debug
21
+ 1
22
+ end
23
+
24
+ def self.message(error)
25
+ case error
26
+ when Aws::Errors::MissingRegionError
27
+ "no AWS region; pass -r/--region or set AWS_REGION (or use a profile with a region)"
28
+ when Aws::Errors::MissingCredentialsError
29
+ "no AWS credentials found; pass -a/-s, set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or use --profile"
30
+ when Aws::Errors::ServiceError
31
+ "AWS #{error.class.name.split("::").last}: #{error.message}"
32
+ when Seahorse::Client::NetworkingError
33
+ "could not reach AWS: #{error.message}"
34
+ else
35
+ error.message
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwsSecurityViz
4
+ # A small directed graph kept as insertion-ordered adjacency lists.
5
+ class DirectedGraph
6
+ Edge = Data.define(:source, :target) do
7
+ def to_s = "(#{source}-#{target})"
8
+ end
9
+
10
+ # DirectedGraph[1, 2, 2, 3] builds the edges 1->2 and 2->3.
11
+ def self.[](*pairs)
12
+ new.tap { |g| pairs.each_slice(2) { |u, v| g.add_edge(u, v) } }
13
+ end
14
+
15
+ def initialize
16
+ @adjacency = {}
17
+ end
18
+
19
+ def add_vertex(v)
20
+ @adjacency[v] ||= Set.new
21
+ self
22
+ end
23
+
24
+ def add_edge(u, v)
25
+ add_vertex(u)
26
+ add_vertex(v)
27
+ @adjacency[u] << v
28
+ self
29
+ end
30
+
31
+ def has_vertex?(v) = @adjacency.key?(v)
32
+
33
+ def vertices = @adjacency.keys
34
+
35
+ def edges
36
+ @adjacency.flat_map { |u, targets| targets.map { |v| Edge.new(u, v) } }
37
+ end
38
+
39
+ def reverse
40
+ each_edge_into(self.class.new) { |g, u, v| g.add_edge(v, u) }
41
+ end
42
+
43
+ # Vertices reachable from +start+ (including it), by breadth-first search.
44
+ def reachable_from(start)
45
+ return Set.new unless has_vertex?(start)
46
+ seen = Set[start]
47
+ queue = [start]
48
+ until queue.empty?
49
+ @adjacency[queue.shift].each { |v| queue << v if seen.add?(v) }
50
+ end
51
+ seen
52
+ end
53
+
54
+ # The subgraph on +keep+ and the edges between its vertices.
55
+ def induced(keep)
56
+ sub = self.class.new
57
+ @adjacency.each_key { |v| sub.add_vertex(v) if keep.include?(v) }
58
+ @adjacency.each { |u, ts| ts.each { |v| sub.add_edge(u, v) if keep.include?(u) && keep.include?(v) } }
59
+ sub
60
+ end
61
+
62
+ def to_s = edges.map(&:to_s).sort.join
63
+
64
+ private
65
+
66
+ def each_edge_into(target)
67
+ vertices.each { |v| target.add_vertex(v) }
68
+ edges.each { |e| yield target, e.source, e.target }
69
+ target
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../model"
4
+ require_relative "../risk"
5
+
6
+ module AwsSecurityViz
7
+ class SecurityGroups
8
+ include Enumerable
9
+
10
+ def initialize(provider, config)
11
+ @groups = provider.security_groups
12
+ @config = config
13
+ end
14
+
15
+ def each(&block)
16
+ @groups.reject { |sg| @config.exclusions.match(sg.name) }.each(&block)
17
+ end
18
+
19
+ def size
20
+ @groups.size
21
+ end
22
+
23
+ # Group id -> name for referenced groups whose name is known.
24
+ def peer_names(group)
25
+ directed_rules(group).flat_map { |rule, _| rule.peers }
26
+ .select { |p| p.kind == :group && p.name != p.id }.to_h { |p| [p.id, p.name] }
27
+ end
28
+
29
+ def traffic(group)
30
+ all_traffic = directed_rules(group).flat_map { |rule, ingress| rule_traffic(group, rule, ingress) }.uniq
31
+ CidrGroupMapping.new(@groups, @config.groups, peer_names(group)).map(all_traffic)
32
+ end
33
+
34
+ private
35
+
36
+ # [rule, ingress?] pairs; egress rules only when configured.
37
+ def directed_rules(group)
38
+ rules = group.ingress.map { |r| [r, true] }
39
+ @config.egress? ? rules + group.egress.map { |r| [r, false] } : rules
40
+ end
41
+
42
+ def rule_traffic(group, rule, ingress)
43
+ rule.peers.reject { |peer| @config.exclusions.match(peer.name) }.map { |peer|
44
+ text = peer.description.to_s.strip
45
+ Traffic.new(ingress: ingress, from: peer.id, to: group.id, port_range: rule.port_range,
46
+ risky: ingress && Risk.risky?(rule, peer, @config.risky_ports),
47
+ descriptions: text.empty? ? [] : [{ports: rule.port_range, text: text}])
48
+ }
49
+ end
50
+ end
51
+
52
+ class CidrGroupMapping
53
+ def initialize(all_groups, user_groups, peer_names = {})
54
+ @all_groups = all_groups
55
+ @user_groups = user_groups
56
+ @peer_names = peer_names
57
+ end
58
+
59
+ def map(all_traffic)
60
+ traffic = all_traffic.collect { |traffic|
61
+ traffic.with(from: mapping(traffic.from), to: mapping(traffic.to))
62
+ }
63
+ traffic.uniq.group_by { |t| [t.from, t.to, t.ingress] }.collect { |k, v| Traffic.grouped(v) }.uniq
64
+ end
65
+
66
+ private
67
+
68
+ def mapping(val)
69
+ group = @all_groups.find { |g| g.id == val }
70
+ name = group ? group.name : @peer_names[val]
71
+ @user_groups[val] || (name && @user_groups[name]) || val
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwsSecurityViz
4
+ class Exclusions
5
+ attr_reader :patterns
6
+
7
+ def initialize(patterns)
8
+ @patterns = patterns.map { |p| /#{p}/ } unless patterns.nil?
9
+ end
10
+
11
+ def match(str)
12
+ return false if patterns.nil?
13
+ patterns.any? { |p|
14
+ p.match(str)
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,222 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:">
7
+ <title>AWS security groups</title>
8
+ <style>
9
+ html, body { margin: 0; height: 100%; font: 13px/1.4 system-ui, sans-serif; color: #222; }
10
+ #app { display: flex; flex-direction: column; height: 100%; }
11
+ #bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #ccc; background: #f6f6f6; }
12
+ #bar input[type=search] { width: 220px; padding: 3px 6px; }
13
+ #main { display: flex; flex: 1; min-height: 0; }
14
+ #cy { flex: 1; }
15
+ #details { width: 320px; overflow: auto; padding: 10px 12px; border-left: 1px solid #ccc; background: #fafafa; }
16
+ #details h2 { margin: 0 0 6px; font-size: 15px; }
17
+ #details h3 { margin: 12px 0 4px; font-size: 13px; }
18
+ #details ul { margin: 0; padding-left: 18px; }
19
+ .risky { color: #b00020; font-weight: bold; }
20
+ .muted { color: #666; }
21
+ #legend span { margin-right: 10px; }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <div id="app">
26
+ <div id="bar">
27
+ <input id="search" type="search" placeholder="Search groups" aria-label="Search groups">
28
+ <label><input id="ingress" type="checkbox" checked> Ingress</label>
29
+ <label><input id="egress" type="checkbox" checked> Egress</label>
30
+ <label><input id="risky-only" type="checkbox"> Risky only</label>
31
+ <button id="reset" type="button">Reset view</button>
32
+ <span id="legend" class="muted"><span style="color:#1f5fbf">blue: ingress</span><span style="color:#c0392b">red: egress</span><span class="risky">thick dashed: risky public ingress</span><span>grey dashed node: unused</span></span>
33
+ </div>
34
+ <div id="main">
35
+ <div id="cy"></div>
36
+ <div id="details"><p class="muted">Click a group or an edge to see its details.</p></div>
37
+ </div>
38
+ </div>
39
+ <script type="application/json" id="graph-data">/*DATA*/</script>
40
+ <script>/*CYTOSCAPE*/</script>
41
+ <script>
42
+ (function () {
43
+ "use strict";
44
+ var data = JSON.parse(document.getElementById("graph-data").textContent);
45
+ var nodeById = {};
46
+ data.nodes.forEach(function (n) { nodeById[n.id] = n; });
47
+
48
+ // Compound parents: region (when several regions were queried) containing VPC.
49
+ var elements = [];
50
+ var seen = {};
51
+ function parent(id, label, kind, parentId) {
52
+ if (!seen[id]) {
53
+ seen[id] = true;
54
+ var d = { id: id, label: label, kind: kind };
55
+ if (parentId) d.parent = parentId;
56
+ elements.push({ group: "nodes", data: d, classes: kind });
57
+ }
58
+ return id;
59
+ }
60
+ data.nodes.forEach(function (n) {
61
+ var d = { id: n.id, label: n.label, kind: "group", unused: !!n.unused };
62
+ var up = null;
63
+ if (n.region) up = parent("region:" + n.region, n.region, "region", null);
64
+ if (n.vpc) up = parent("vpc:" + (n.region || "") + "|" + n.vpc, n.vpc, "vpc", up);
65
+ if (up) d.parent = up;
66
+ elements.push({ group: "nodes", data: d, classes: n.unused ? "unused" : "" });
67
+ });
68
+ data.edges.forEach(function (e) {
69
+ elements.push({ group: "edges", data: { id: e.id, source: e.source, target: e.target, label: e.label || "", kind: e.kind, risky: !!e.risky }, classes: e.kind + (e.risky ? " risky" : "") });
70
+ });
71
+
72
+ var cy = cytoscape({
73
+ container: document.getElementById("cy"),
74
+ elements: elements,
75
+ wheelSensitivity: 0.3,
76
+ style: [
77
+ { selector: "node", style: { label: "data(label)", "font-size": 11, "text-valign": "center", "text-halign": "center", "background-color": "#e8f0fe", "border-width": 1, "border-color": "#5b7bd5", shape: "round-rectangle", width: "label", height: "label", padding: "8px" } },
78
+ { selector: "node.region, node.vpc", style: { "text-valign": "top", "background-opacity": 0.06, "background-color": "#555", "border-color": "#999", "border-style": "dashed", padding: "14px", "font-weight": "bold", "font-size": 12 } },
79
+ { selector: "node.unused", style: { "background-color": "#eee", "border-style": "dashed", "border-color": "#888", color: "#555" } },
80
+ { selector: "edge", style: { label: "data(label)", "font-size": 9, width: 1.5, "curve-style": "bezier", "target-arrow-shape": "triangle", "text-background-color": "#fff", "text-background-opacity": 0.85, "text-background-padding": "1px" } },
81
+ { selector: "edge.ingress", style: { "line-color": "#1f5fbf", "target-arrow-color": "#1f5fbf" } },
82
+ { selector: "edge.egress", style: { "line-color": "#c0392b", "target-arrow-color": "#c0392b" } },
83
+ { selector: "edge.risky", style: { width: 4, "line-style": "dashed", "line-color": "#dc143c", "target-arrow-color": "#dc143c", color: "#b00020" } },
84
+ { selector: ".dim", style: { opacity: 0.12 } },
85
+ { selector: ".match", style: { "border-width": 3, "border-color": "#f39c12" } },
86
+ { selector: ":selected", style: { "overlay-color": "#f39c12", "overlay-opacity": 0.25 } }
87
+ ],
88
+ layout: { name: "cose", animate: false, nodeDimensionsIncludeLabels: true, padding: 30 }
89
+ });
90
+
91
+ window.awsSecurityViz = { cy: cy }; // read by the browser spec
92
+
93
+ var details = document.getElementById("details");
94
+
95
+ function el(tag, text, cls) {
96
+ var e = document.createElement(tag);
97
+ if (text !== undefined) e.textContent = text;
98
+ if (cls) e.className = cls;
99
+ return e;
100
+ }
101
+ function field(label, value) {
102
+ var p = el("p");
103
+ p.appendChild(el("strong", label + ": "));
104
+ p.appendChild(document.createTextNode(value));
105
+ return p;
106
+ }
107
+ function describeEdges(title, edges, other) {
108
+ var box = document.createDocumentFragment();
109
+ if (!edges.length) return box;
110
+ box.appendChild(el("h3", title));
111
+ var ul = el("ul");
112
+ edges.forEach(function (e) {
113
+ var li = el("li");
114
+ li.appendChild(document.createTextNode(other(e).data("label") + " : " + (e.data("label") || "all")));
115
+ if (e.data("risky")) li.appendChild(el("span", " (risky)", "risky"));
116
+ ul.appendChild(li);
117
+ });
118
+ box.appendChild(ul);
119
+ return box;
120
+ }
121
+ function descriptionList(descs) {
122
+ var ul = el("ul");
123
+ (descs || []).forEach(function (d) {
124
+ ul.appendChild(el("li", (d.ports ? d.ports + ": " : "") + d.text));
125
+ });
126
+ return ul;
127
+ }
128
+ var edgeData = {};
129
+ data.edges.forEach(function (e) { edgeData[e.id] = e; });
130
+
131
+ function show(target) {
132
+ details.textContent = "";
133
+ if (target.isEdge()) {
134
+ var e = edgeData[target.id()];
135
+ details.appendChild(el("h2", target.source().data("label") + " -> " + target.target().data("label")));
136
+ details.appendChild(field("Direction", e.kind));
137
+ details.appendChild(field("Ports", e.label || "all"));
138
+ if (e.risky) details.appendChild(el("p", "Risky: public ingress on a sensitive port or all traffic", "risky"));
139
+ if (e.descriptions && e.descriptions.length) {
140
+ details.appendChild(el("h3", "Rule descriptions"));
141
+ details.appendChild(descriptionList(e.descriptions));
142
+ }
143
+ return;
144
+ }
145
+ var n = nodeById[target.id()];
146
+ if (!n) {
147
+ details.appendChild(el("h2", target.data("label")));
148
+ details.appendChild(field("Type", target.data("kind").toUpperCase()));
149
+ return;
150
+ }
151
+ details.appendChild(el("h2", n.label));
152
+ details.appendChild(field("Id", n.id));
153
+ if (n.vpc) details.appendChild(field("VPC", n.vpc));
154
+ if (n.region) details.appendChild(field("Region", n.region));
155
+ if (n.unused) details.appendChild(el("p", "Unused: no network interfaces attached", "muted"));
156
+ details.appendChild(describeEdges("Incoming", target.incomers("edge").toArray(), function (e) { return e.source(); }));
157
+ details.appendChild(describeEdges("Outgoing", target.outgoers("edge").toArray(), function (e) { return e.target(); }));
158
+ var withDescs = target.connectedEdges().toArray().filter(function (e) { var d = edgeData[e.id()].descriptions; return d && d.length; });
159
+ if (withDescs.length) {
160
+ details.appendChild(el("h3", "Rule descriptions"));
161
+ withDescs.forEach(function (e) {
162
+ details.appendChild(el("p", e.source().data("label") + " -> " + e.target().data("label"), "muted"));
163
+ details.appendChild(descriptionList(edgeData[e.id()].descriptions));
164
+ });
165
+ }
166
+ }
167
+
168
+ function clearFocus() { cy.elements().removeClass("dim"); }
169
+ function focus(target) {
170
+ clearFocus();
171
+ var keep = target.isNode() ? target.closedNeighborhood().union(target.ancestors()).union(target.neighborhood().ancestors()) : target.union(target.connectedNodes()).union(target.connectedNodes().ancestors());
172
+ cy.elements().not(keep).addClass("dim");
173
+ }
174
+
175
+ cy.on("tap", "node, edge", function (evt) {
176
+ var t = evt.target;
177
+ if (t.isNode() && t.isParent()) { show(t); return; }
178
+ show(t);
179
+ focus(t);
180
+ });
181
+ cy.on("tap", function (evt) {
182
+ if (evt.target === cy) {
183
+ clearFocus();
184
+ details.textContent = "";
185
+ details.appendChild(el("p", "Click a group or an edge to see its details.", "muted"));
186
+ }
187
+ });
188
+
189
+ function applyFilters() {
190
+ var ingress = document.getElementById("ingress").checked;
191
+ var egress = document.getElementById("egress").checked;
192
+ var riskyOnly = document.getElementById("risky-only").checked;
193
+ cy.edges().forEach(function (e) {
194
+ var visible = (e.data("kind") === "ingress" ? ingress : egress) && (!riskyOnly || e.data("risky"));
195
+ e.style("display", visible ? "element" : "none");
196
+ });
197
+ }
198
+ ["ingress", "egress", "risky-only"].forEach(function (id) {
199
+ document.getElementById(id).addEventListener("change", applyFilters);
200
+ });
201
+
202
+ document.getElementById("search").addEventListener("input", function (evt) {
203
+ var q = evt.target.value.trim().toLowerCase();
204
+ cy.nodes().removeClass("match");
205
+ if (!q) return;
206
+ var hits = cy.nodes().filter(function (n) {
207
+ return !n.isParent() && (String(n.data("label")).toLowerCase().indexOf(q) !== -1 || String(n.id()).toLowerCase().indexOf(q) !== -1);
208
+ });
209
+ hits.addClass("match");
210
+ if (hits.length) cy.animate({ fit: { eles: hits, padding: 80 }, duration: 0 });
211
+ });
212
+
213
+ document.getElementById("reset").addEventListener("click", function () {
214
+ clearFocus();
215
+ cy.nodes().removeClass("match");
216
+ document.getElementById("search").value = "";
217
+ cy.fit(undefined, 30);
218
+ });
219
+ })();
220
+ </script>
221
+ </body>
222
+ </html>
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "directed_graph"
4
+ require_relative "port_label"
5
+ require_relative "obfuscation"
6
+ require_relative "logging"
7
+
8
+ module AwsSecurityViz
9
+ class Graph
10
+ attr_reader :underlying
11
+
12
+ def initialize(config, underlying = DirectedGraph.new)
13
+ @config = config
14
+ @underlying = underlying
15
+ @edge_properties = {}
16
+ @node_properties = {}
17
+ end
18
+
19
+ def add_node(name, opts)
20
+ log("node: #{loggable(name)}, opts: #{@config.obfuscate? ? Obfuscation.node_opts(opts) : opts}")
21
+ @underlying.add_vertex(name)
22
+ @node_properties[name] = opts
23
+ end
24
+
25
+ # Labels a node that only appears as an edge endpoint (e.g. a group outside the
26
+ # described set); a node already added with properties is left alone.
27
+ def describe_node(key, opts)
28
+ @node_properties[key] ||= opts if @underlying.has_vertex?(key)
29
+ end
30
+
31
+ def add_edge(from, to, opts)
32
+ log("edge: #{loggable(from)} -> #{loggable(to)}")
33
+ @underlying.add_edge(from, to)
34
+ @edge_properties[[from, to]] = merge_edge(@edge_properties[[from, to]], opts)
35
+ end
36
+
37
+ def filter(source, destination)
38
+ @underlying = GraphFilter.new(underlying).filter(resolve(source), resolve(destination))
39
+ end
40
+
41
+ # Risky edges that survived filtering.
42
+ def risky_edge_count
43
+ @underlying.edges.count { |e| opts(e.source, e.target)[:risky] }
44
+ end
45
+
46
+ def output(renderer)
47
+ nodes = @underlying.vertices.map { |v| [v, @node_properties[v] || {}] }
48
+ edges = @underlying.edges.map { |e| [e.source, e.target, opts(e.source, e.target)] }
49
+ nodes, edges = Obfuscation.apply(nodes, edges) if @config.obfuscate?
50
+ nodes.each { |v, node_opts| renderer.add_node(v, node_opts) }
51
+ edges.each { |u, v, edge_opts| renderer.add_edge(u, v, edge_opts) }
52
+ renderer.output
53
+ end
54
+
55
+ def log(msg)
56
+ AwsSecurityViz.logger.debug(msg) if @config.debug?
57
+ end
58
+
59
+ private
60
+
61
+ # Debug output is what users paste into bug reports, so it is hashed like the rest.
62
+ def loggable(value)
63
+ @config.obfuscate? ? Obfuscation.hash(value) : value
64
+ end
65
+
66
+ # A filter may be a group id (a node key) or a group name (a node label).
67
+ def resolve(filter)
68
+ return filter if filter.nil? || @underlying.has_vertex?(filter)
69
+ keys = @underlying.vertices.select { |v| @node_properties.dig(v, :label) == filter }
70
+ raise ArgumentError, "no group or peer matches '#{filter}'" if keys.empty?
71
+ if keys.size > 1
72
+ raise ArgumentError, "'#{filter}' matches several groups (#{keys.map { |k| loggable(k) }.join(", ")}); use a group id"
73
+ end
74
+ keys.first
75
+ end
76
+
77
+ # Rules from several groups (or egress and ingress) can map to one edge: union the
78
+ # port labels and pick the colour independently of rule order (ingress blue wins
79
+ # over egress red).
80
+ def merge_edge(existing, opts)
81
+ return opts unless existing
82
+ color = [existing[:color], opts[:color]].include?(:blue) ? :blue : opts[:color]
83
+ merged = existing.merge(opts).merge(color: color, label: PortLabel.normalise("#{existing[:label]},#{opts[:label]}"))
84
+ merged[:risky] = true if existing[:risky] || opts[:risky]
85
+ descriptions = (existing[:descriptions].to_a + opts[:descriptions].to_a).uniq
86
+ descriptions.empty? ? merged : merged.merge(descriptions: descriptions)
87
+ end
88
+
89
+ def opts(u, v)
90
+ @edge_properties[[u, v]]
91
+ end
92
+ end
93
+ end