aws_security_viz 0.2.5.pre.alpha.pre.34 → 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 +122 -81
  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,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "directed_graph"
4
+
5
+ module AwsSecurityViz
6
+ class GraphFilter
7
+ def initialize(graph)
8
+ @graph = graph
9
+ end
10
+
11
+ def filter(source, destination)
12
+ return @graph if source.nil? && destination.nil?
13
+ return @graph.induced(@graph.reachable_from(source)) if destination.nil?
14
+ return @graph.induced(@graph.reverse.reachable_from(destination)) if source.nil?
15
+ # Keep every vertex on some path source -> destination: reachable from the
16
+ # source and able to reach the destination. Two searches, O(V+E).
17
+ @graph.induced(@graph.reachable_from(source) & @graph.reverse.reachable_from(destination))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module AwsSecurityViz
6
+ # Diagnostics (warnings, errors, debug output) go to stderr so stdout stays clean for output.
7
+ def self.logger
8
+ @logger ||= build_logger($stderr)
9
+ end
10
+
11
+ def self.logger=(logger)
12
+ @logger = logger
13
+ end
14
+
15
+ def self.build_logger(io)
16
+ Logger.new(io, formatter: proc { |severity, _time, _prog, msg| "[#{severity}] #{msg}\n" })
17
+ end
18
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "port_label"
4
+
5
+ module AwsSecurityViz
6
+ # Where a rule's traffic comes from or goes to. kind is :cidr4, :cidr6, :prefix_list or :group;
7
+ # id is the CIDR, prefix list id or group id and name is what exclusions and labels match on.
8
+ # description is the free-text rule description (nil or empty when none was given).
9
+ Peer = Data.define(:kind, :id, :name, :description) do
10
+ def initialize(kind:, id:, name: nil, description: nil)
11
+ super(kind: kind, id: id, name: name || id, description: description)
12
+ end
13
+ end
14
+
15
+ Rule = Data.define(:protocol, :from_port, :to_port, :peers) do
16
+ def port_range
17
+ PortLabel.format(protocol, from_port, to_port)
18
+ end
19
+ end
20
+
21
+ # region is only set when several regions are queried, so nodes can be grouped by region.
22
+ # unused is true only when --show-unused found no network interface attached to the group.
23
+ SecurityGroup = Data.define(:id, :name, :vpc_id, :ingress, :egress, :region, :unused) do
24
+ def initialize(id:, name:, vpc_id:, ingress:, egress:, region: nil, unused: false)
25
+ super
26
+ end
27
+
28
+ # Builds a group from a describe-security-groups hash with snake_case keys (see Model.normalize).
29
+ def self.from_hash(hash)
30
+ new(
31
+ id: hash[:group_id], name: hash[:group_name], vpc_id: hash[:vpc_id],
32
+ ingress: (hash[:ip_permissions] || []).map { |ip| Rule.from_hash(ip) },
33
+ egress: (hash[:ip_permissions_egress] || []).map { |ip| Rule.from_hash(ip) }
34
+ )
35
+ end
36
+ end
37
+
38
+ class Rule
39
+ def self.from_hash(ip)
40
+ peers = (ip[:ip_ranges] || []).map { |r| Peer.new(kind: :cidr4, id: r[:cidr_ip], description: r[:description]) } +
41
+ (ip[:ipv6_ranges] || []).map { |r| Peer.new(kind: :cidr6, id: r[:cidr_ipv6], description: r[:description]) } +
42
+ (ip[:prefix_list_ids] || []).map { |r| Peer.new(kind: :prefix_list, id: r[:prefix_list_id], description: r[:description]) } +
43
+ (ip[:user_id_group_pairs] || []).map { |g| Peer.new(kind: :group, id: g[:group_id] || g[:group_name], name: g[:group_name], description: g[:description]) }
44
+ new(protocol: ip[:ip_protocol], from_port: ip[:from_port], to_port: ip[:to_port], peers: peers)
45
+ end
46
+ end
47
+
48
+ # descriptions: the non-empty rule descriptions behind this traffic, as {ports:, text:} hashes.
49
+ # risky: public ingress on a sensitive port (see Risk), decided on the real rule.
50
+ Traffic = Data.define(:ingress, :from, :to, :port_range, :descriptions, :risky) do
51
+ def initialize(ingress:, from:, to:, port_range:, descriptions: [], risky: false)
52
+ super
53
+ end
54
+
55
+ def self.grouped(traffic_list)
56
+ t = traffic_list.first
57
+ port_range = PortLabel.normalise(traffic_list.collect(&:port_range).join(","))
58
+ new(ingress: t.ingress, from: t.from, to: t.to, port_range: port_range,
59
+ descriptions: traffic_list.flat_map(&:descriptions).uniq, risky: traffic_list.any?(&:risky))
60
+ end
61
+ end
62
+
63
+ module Model
64
+ # Deep-converts the AWS CLI's PascalCase keys (and the SDK's ipv_6 spelling) to snake_case symbols.
65
+ def self.normalize(value)
66
+ case value
67
+ when Hash then value.to_h { |k, v| [key(k), normalize(v)] }
68
+ when Array then value.map { |v| normalize(v) }
69
+ else value
70
+ end
71
+ end
72
+
73
+ def self.key(name)
74
+ name.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.sub("ipv_6", "ipv6").to_sym
75
+ end
76
+
77
+ # EC2 often omits GroupName in group peers; take it from the described groups so name-based
78
+ # exclusions and labels see the real name (an id is the last resort).
79
+ def self.resolve_peer_names(groups)
80
+ names = groups.to_h { |g| [g.id, g.name] }
81
+ resolve = ->(rule) {
82
+ rule.with(peers: rule.peers.map { |p| (p.kind == :group && p.name == p.id) ? p.with(name: names[p.id] || p.id) : p })
83
+ }
84
+ groups.map { |g| g.with(ingress: g.ingress.map(&resolve), egress: g.egress.map(&resolve)) }
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module AwsSecurityViz
6
+ # Replaces names, ids and port labels with short stable hashes. It runs on the finished
7
+ # graph (after merging and filtering), so filters still match real names and ids and
8
+ # merged edges keep deduplicated, hashed port tokens.
9
+ module Obfuscation
10
+ LENGTH = 10
11
+
12
+ def self.apply(nodes, edges)
13
+ [
14
+ nodes.map { |key, opts| [hash(key), node_opts(opts)] },
15
+ edges.map { |from, to, opts| [hash(from), hash(to), hash_edge_opts(opts)] }
16
+ ]
17
+ end
18
+
19
+ def self.node_opts(opts)
20
+ %i[label vpc_id region group_id].each_with_object(opts.dup) { |field, hashed|
21
+ hashed[field] = hash(opts[field]) if opts.key?(field) && !opts[field].nil?
22
+ }
23
+ end
24
+
25
+ def self.hash_edge_opts(opts)
26
+ hashed = opts.merge(label: hash_tokens(opts[:label]))
27
+ hashed[:descriptions] = hash_descriptions(opts[:descriptions]) if opts.key?(:descriptions)
28
+ hashed
29
+ end
30
+
31
+ # Free text can name people and systems, so descriptions are hashed like everything else.
32
+ def self.hash_descriptions(descriptions)
33
+ descriptions.to_a.map { |d| {ports: hash_tokens(d[:ports]), text: hash(d[:text])} }
34
+ end
35
+
36
+ # Hash each comma-separated port token so that one token always maps to the same value.
37
+ def self.hash_tokens(label)
38
+ label.to_s.split(",").map { |token| hash(token) }.join(",")
39
+ end
40
+
41
+ def self.hash(text)
42
+ Digest::SHA256.hexdigest(text.to_s)[0, LENGTH]
43
+ end
44
+ end
45
+ end
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  :format: dot
3
3
  #:egress: true
4
+ # Ports flagged when open to 0.0.0.0/0 or ::/0 (all-traffic rules always are):
5
+ #:risky_ports: [22, 3389, 3306, 5432, 1433, 6379, 9200, 27017]
4
6
  #:exclude:
5
7
  #- .*test.*
6
8
  #- .*perf.*
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwsSecurityViz
4
+ # Edge labels are comma-joined tokens such as "22/tcp", "1000-2000/udp", "icmp" or "proto 50",
5
+ # or "all" for all traffic.
6
+ module PortLabel
7
+ ALL = "all"
8
+ PROTOCOL_NAMES = {"1" => "icmp", "6" => "tcp", "17" => "udp", "58" => "icmpv6"}.freeze
9
+ ICMP = %w[icmp icmpv6].freeze
10
+
11
+ # Label for one permission. For tcp/udp from/to are ports; for icmp they are the type and code
12
+ # (-1 meaning any); other protocols carry no ports and are shown by name or number.
13
+ def self.format(protocol, from, to)
14
+ protocol = protocol.to_s
15
+ return ALL if protocol == "-1"
16
+ name = PROTOCOL_NAMES.fetch(protocol, protocol)
17
+ return icmp(name, from, to) if ICMP.include?(name)
18
+ return "proto #{name}" if name.match?(/\A\d+\z/)
19
+ return name if from.nil? && to.nil?
20
+ "#{[from, to].uniq.join("-")}/#{name}"
21
+ end
22
+
23
+ def self.icmp(name, type, code)
24
+ return name if type.nil? || type == -1
25
+ (code.nil? || code == -1) ? "#{name} #{type}" : "#{name} #{type}/#{code}"
26
+ end
27
+ private_class_method :icmp
28
+
29
+ TOKEN = %r{\A(-?\d+)(?:-(-?\d+))?/(.*)\z}
30
+
31
+ # Deduplicates tokens and sorts them by from port, then protocol; "all" swallows everything else.
32
+ def self.normalise(label)
33
+ tokens = label.to_s.split(",").uniq
34
+ return ALL if tokens.include?(ALL)
35
+ tokens.sort_by { |token| sort_key(token) }.join(",")
36
+ end
37
+
38
+ def self.sort_key(token)
39
+ match = TOKEN.match(token)
40
+ return [1, 0, "", 0, token] unless match
41
+ [0, match[1].to_i, match[3], (match[2] || match[1]).to_i, token]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-ec2"
4
+ require_relative "../model"
5
+ require_relative "../logging"
6
+
7
+ module AwsSecurityViz
8
+ class Ec2Provider
9
+ # Per-region failures that must not abort a multi-region run (region not enabled, no permission).
10
+ SKIPPABLE_ERRORS = [Aws::EC2::Errors::UnauthorizedOperation, Aws::EC2::Errors::AuthFailure, Aws::EC2::Errors::OptInRequired].freeze
11
+
12
+ # client: a single injected client (used as-is, one region); otherwise one client is built per region.
13
+ def initialize(options, client: nil)
14
+ @options = options
15
+ @client = client
16
+ end
17
+
18
+ def security_groups
19
+ regions = @client ? [nil] : region_list
20
+ errors = []
21
+ groups = regions.flat_map do |region|
22
+ describe(@client || build_client(region), (regions.size > 1) ? region : nil)
23
+ rescue *SKIPPABLE_ERRORS => e
24
+ raise if regions.size == 1
25
+ AwsSecurityViz.logger.warn("skipping region #{region}: #{e.class.name.split("::").last}: #{e.message}")
26
+ errors << e
27
+ []
28
+ end
29
+ raise errors.first if errors.size == regions.size
30
+ Model.resolve_peer_names(groups)
31
+ end
32
+
33
+ private
34
+
35
+ def describe(client, region)
36
+ params = {}
37
+ params[:filters] = [{name: "vpc-id", values: [@options[:vpc_id]]}] if @options[:vpc_id]
38
+ groups = client.describe_security_groups(params).flat_map { |page|
39
+ page.security_groups.collect { |sg| SecurityGroup.from_hash(Model.normalize(sg.to_h)).with(region: region) }
40
+ }
41
+ return groups unless @options[:show_unused]
42
+ used = begin
43
+ attached_group_ids(client, params)
44
+ rescue Aws::EC2::Errors::UnauthorizedOperation
45
+ # Without the permission the groups are still worth drawing; just leave them unmarked.
46
+ AwsSecurityViz.logger.warn("--show-unused needs ec2:DescribeNetworkInterfaces; no groups are marked unused#{" in #{region}" if region}")
47
+ return groups
48
+ end
49
+ groups.map { |g| g.with(unused: !used.include?(g.id)) }
50
+ end
51
+
52
+ # Ids of groups attached to at least one network interface (extra call, only for --show-unused).
53
+ def attached_group_ids(client, params)
54
+ client.describe_network_interfaces(params).each_with_object(Set.new) { |page, ids|
55
+ page.network_interfaces.each { |eni| eni.groups.each { |g| ids << g.group_id } }
56
+ }
57
+ end
58
+
59
+ # [nil] means one client on the SDK default region chain (AWS_REGION, profile).
60
+ def region_list
61
+ names = @options[:region].to_s.split(",").map(&:strip).reject(&:empty?).uniq
62
+ # With --all-regions, -r only names the region used to call DescribeRegions.
63
+ return build_client(names.first).describe_regions.regions.map(&:region_name).sort if @options[:all_regions]
64
+ names.empty? ? [nil] : names
65
+ end
66
+
67
+ def build_client(region)
68
+ Aws::EC2::Client.new(client_options(@options, region))
69
+ end
70
+
71
+ # An explicit profile wins over static keys so the SSO/shared-config login is used.
72
+ def client_options(options, region)
73
+ conn_opts = {region: region, profile: options[:profile]}
74
+ unless options[:profile]
75
+ conn_opts.merge!(
76
+ access_key_id: options[:access_key],
77
+ secret_access_key: options[:secret_key],
78
+ session_token: options[:session_token]
79
+ )
80
+ end
81
+ conn_opts.delete_if { |_k, v| v.nil? }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../model"
5
+
6
+ module AwsSecurityViz
7
+ class JsonProvider
8
+ def initialize(options)
9
+ @groups = Model.normalize(JSON.parse(File.read(options[:source_file]))["SecurityGroups"])
10
+ @groups = @groups.select { |sg| sg[:vpc_id] == options[:vpc_id] } if options[:vpc_id]
11
+ end
12
+
13
+ def security_groups
14
+ Model.resolve_peer_names(@groups.map { |sg| SecurityGroup.from_hash(sg) })
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "html"
4
+ require_relative "json"
5
+ require_relative "graphviz"
6
+ require_relative "mermaid"
7
+ module AwsSecurityViz
8
+ module Renderer
9
+ ALL = {graphviz: Renderer::GraphViz, json: Renderer::Json, html: Renderer::Html, mermaid: Renderer::Mermaid}
10
+ DEFAULT = "html"
11
+ EXTENSIONS = {
12
+ "html" => :html, "htm" => :html, "json" => :json, "mmd" => :mermaid,
13
+ "dot" => :graphviz, "gv" => :graphviz
14
+ }.merge(GraphViz::IMAGE_EXTENSIONS.to_h { |e| [e, :graphviz] }) { |_, mine, _| mine }.freeze
15
+
16
+ # Without an explicit renderer the output file's extension decides (see .infer).
17
+ def self.pick(r, output_file, config)
18
+ ALL.fetch(validate!(r || infer(output_file))).new(output_file, config)
19
+ end
20
+
21
+ # Renderer implied by the file extension; html when there is no file name. An unknown or missing extension
22
+ # goes to graphviz, which rejects it with a message listing the usable extensions.
23
+ def self.infer(file_name)
24
+ return DEFAULT.to_sym if file_name.nil?
25
+ EXTENSIONS.fetch(File.extname(file_name.to_s).delete_prefix(".").downcase, :graphviz)
26
+ end
27
+
28
+ # Returns the renderer as a symbol, or raises listing the valid choices.
29
+ def self.validate!(r)
30
+ name = (r || DEFAULT).to_s
31
+ return name.to_sym if ALL.key?(name.to_sym)
32
+ raise ArgumentError, "unknown renderer '#{name}' (choose from: #{all.join(", ")})"
33
+ end
34
+
35
+ # Default output file name: an image for graphviz, text for mermaid, JSON data for json, a page for html.
36
+ def self.default_file(r)
37
+ case validate!(r)
38
+ when :graphviz then "aws-security-viz.png"
39
+ when :mermaid then "aws-security-viz.#{Renderer::Mermaid::DEFAULT_EXTENSION}"
40
+ when :json then "aws-security-viz.json"
41
+ else "aws-security-viz.html"
42
+ end
43
+ end
44
+
45
+ def self.all
46
+ ALL.keys
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AwsSecurityViz
6
+ module Renderer
7
+ # Builds DOT text directly. `.dot`/`.gv` output is the text itself and needs no Graphviz;
8
+ # any other extension is rendered by piping the text through `dot -T<ext> -K<layout>`.
9
+ class GraphViz
10
+ DOT_EXTENSIONS = %w[dot gv].freeze
11
+ IMAGE_EXTENSIONS = %w[png svg pdf jpg jpeg gif webp bmp tiff ps eps json].freeze
12
+ UNUSED_ATTRS = {style: "dashed,filled", fillcolor: "lightgrey", fontcolor: "gray30", tooltip: "Unused: no network interfaces attached"}.freeze
13
+ GRAPH_ATTRS = {overlap: false, splines: true, sep: 1, concentrate: true, rankdir: "LR"}.freeze
14
+
15
+ def initialize(file_name, config)
16
+ @file_name = file_name
17
+ @config = config
18
+ @nodes = {}
19
+ @clusters = {}
20
+ @root_nodes = []
21
+ @edges = []
22
+ end
23
+
24
+ def add_node(name, opts)
25
+ @nodes[name] ||= begin
26
+ node_attrs = {label: opts[:label] || name}
27
+ node_attrs.merge!(UNUSED_ATTRS) if opts[:unused]
28
+ line = "#{quote(name)} [#{attrs(node_attrs)}];"
29
+ if opts[:vpc_id].nil? && opts[:region].nil?
30
+ @root_nodes << line
31
+ else
32
+ (@clusters[[opts[:region], opts[:vpc_id]]] ||= []) << line
33
+ end
34
+ true
35
+ end
36
+ end
37
+
38
+ def add_edge(from, to, opts)
39
+ add_node(from, {})
40
+ add_node(to, {})
41
+ # Edges live in the root graph: an edge inside a cluster would pull its other endpoint in.
42
+ edge_attrs = {style: "bold"}.merge(opts.except(:descriptions, :risky))
43
+ edge_attrs = edge_attrs.merge(color: "crimson", style: "dashed", penwidth: 3) if opts[:risky]
44
+ edge_attrs[:tooltip] = tooltip(opts[:descriptions]) unless opts[:descriptions].to_a.empty?
45
+ @edges << "#{quote(from)} -> #{quote(to)} [#{attrs(edge_attrs)}];"
46
+ end
47
+
48
+ def to_dot
49
+ lines = ["digraph \"G\" {"]
50
+ lines << " graph [#{attrs(GRAPH_ATTRS)}];"
51
+ @clusters.group_by { |(region, _), _| region }.each do |region, entries|
52
+ indent = region ? " " : " "
53
+ body = entries.flat_map { |(_, vpc_id), node_lines| vpc_block(vpc_id, region, node_lines, indent) }
54
+ lines.concat(region ? cluster(region, body) : body)
55
+ end
56
+ (@root_nodes + @edges).each { |l| lines << " #{l}" }
57
+ lines << "}"
58
+ lines.join("\n") + "\n"
59
+ end
60
+
61
+ def output
62
+ engine = @config.layout # validates the engine even when no image is rendered
63
+ format = File.extname(@file_name.to_s).delete_prefix(".").downcase
64
+ return File.write(@file_name, to_dot) if DOT_EXTENSIONS.include?(format)
65
+
66
+ unless IMAGE_EXTENSIONS.include?(format)
67
+ shown = format.empty? ? "no file extension" : "unknown file extension '.#{format}'"
68
+ raise ArgumentError, "cannot pick an output format from #{@file_name.to_s.inspect}: #{shown} (supported: .html, .json, .mmd, .dot/.gv, or an image: #{(IMAGE_EXTENSIONS - %w[json]).map { |e| ".#{e}" }.join(", ")})"
69
+ end
70
+ raise ArgumentError, "Graphviz 'dot' not found; install graphviz" unless on_path?("dot")
71
+ image, err, status = Open3.capture3("dot", "-T#{format}", "-K#{engine}", stdin_data: to_dot, binmode: true)
72
+ raise ArgumentError, "Graphviz failed: #{err.strip}" unless status.success?
73
+ File.binwrite(@file_name, image)
74
+ end
75
+
76
+ private
77
+
78
+ # Nodes of one VPC (or loose nodes of a region without a VPC), indented.
79
+ def vpc_block(vpc_id, region, node_lines, indent)
80
+ return node_lines.map { |l| "#{indent}#{l}" } unless vpc_id
81
+ id = region ? "cluster_#{region}/#{vpc_id}" : "cluster_#{vpc_id}"
82
+ ["#{indent}subgraph #{quote(id)} {", "#{indent} label=#{quote(vpc_id)};"] +
83
+ node_lines.map { |l| "#{indent} #{l}" } + ["#{indent}}"]
84
+ end
85
+
86
+ # One line per rule description, prefixed by its ports; quote() escapes the free text.
87
+ def tooltip(descriptions)
88
+ descriptions.map { |d| "#{d[:ports]}: #{d[:text]}" }.join("\n")
89
+ end
90
+
91
+ def cluster(region, body)
92
+ [" subgraph #{quote("cluster_#{region}")} {", " label=#{quote(region)};"] + body + [" }"]
93
+ end
94
+
95
+ # Quotes an ID or label for DOT: backslash and double quote are escaped, newlines become \n.
96
+ # A backslash is escaped too, so a literal "\N" in data cannot be read as a Graphviz escape.
97
+ def quote(value)
98
+ escaped = value.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }.gsub(/\r\n|\r|\n/) { "\\n" }
99
+ "\"#{escaped}\""
100
+ end
101
+
102
+ def attrs(opts)
103
+ opts.map { |k, v| "#{k}=#{quote(v)}" }.join(", ")
104
+ end
105
+
106
+ def on_path?(command)
107
+ pathext = ENV.fetch("PATHEXT", "").split(File::PATH_SEPARATOR).reject(&:empty?)
108
+ # PATHEXT is upper case (.EXE) but the file is usually dot.exe; only Windows matches case-insensitively.
109
+ suffixes = [""] + pathext.flat_map { |ext| [ext, ext.downcase] }.uniq
110
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir|
111
+ suffixes.any? { |suffix|
112
+ path = File.join(dir, command + suffix)
113
+ File.file?(path) && File.executable?(path)
114
+ }
115
+ }
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AwsSecurityViz
6
+ module Renderer
7
+ # Writes one self-contained .html file: the vendored Cytoscape.js and the graph data are inlined, so it
8
+ # opens from file:// with no network access. Data only reaches the page as JSON (see viewer.html, which
9
+ # builds the DOM with textContent), never as markup.
10
+ class Html
11
+ TEMPLATE = File.expand_path("../export/html/viewer.html", __dir__)
12
+ CYTOSCAPE = File.expand_path("../vendor/cytoscape/cytoscape.min.js", __dir__)
13
+
14
+ def initialize(file_name, config)
15
+ @file_name = file_name
16
+ @config = config
17
+ @nodes = []
18
+ @edges = []
19
+ end
20
+
21
+ def add_node(name, opts)
22
+ @nodes << {id: name, label: opts[:label] || name, vpc: opts[:vpc_id], region: opts[:region], unused: (true if opts[:unused])}.compact
23
+ end
24
+
25
+ def add_edge(from, to, opts)
26
+ edge = {id: "#{from}-#{to}", source: from, target: to, label: opts[:label], kind: (opts[:color] == :red) ? "egress" : "ingress"}
27
+ edge[:descriptions] = opts[:descriptions] if opts[:descriptions]
28
+ edge[:risky] = true if opts[:risky]
29
+ @edges << edge
30
+ end
31
+
32
+ def output
33
+ parts = {"/*CYTOSCAPE*/" => File.read(CYTOSCAPE), "/*DATA*/" => json_for_script({nodes: @nodes, edges: @edges})}
34
+ # One pass, so neither inlined part can be mistaken for the other's placeholder.
35
+ File.write(@file_name, File.read(TEMPLATE).gsub(%r{/\*(?:CYTOSCAPE|DATA)\*/}) { |marker| parts.fetch(marker) })
36
+ end
37
+
38
+ private
39
+
40
+ # "<" is escaped so no value can close the script element or open a comment.
41
+ def json_for_script(data)
42
+ data.to_json.gsub("<", "\\u003c")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwsSecurityViz
4
+ module Renderer
5
+ class Json
6
+ def initialize(file_name, config)
7
+ @nodes = []
8
+ @edges = []
9
+ @file_name = file_name
10
+ @config = config
11
+ end
12
+
13
+ def add_node(name, opts)
14
+ @nodes << {id: name, label: opts[:label] || name, region: opts[:region], unused: (true if opts[:unused])}.compact
15
+ end
16
+
17
+ def add_edge(from, to, opts)
18
+ edge = {id: "#{from}-#{to}", source: from, target: to, label: opts[:label]}
19
+ edge[:descriptions] = opts[:descriptions] if opts[:descriptions]
20
+ edge[:risky] = true if opts[:risky]
21
+ @edges << edge
22
+ end
23
+
24
+ def output
25
+ IO.write(@file_name, {nodes: @nodes, edges: @edges}.to_json)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwsSecurityViz
4
+ module Renderer
5
+ # Writes a Mermaid flowchart (.mmd). Node and subgraph ids are generated (n0, g0, ...) so no
6
+ # data can break the syntax; names, ports and VPC/region titles only appear as quoted labels.
7
+ # Rule descriptions have no Mermaid equivalent (no tooltips) and are left out.
8
+ class Mermaid
9
+ DEFAULT_EXTENSION = "mmd"
10
+ RISKY_STYLE = "stroke:#dc143c,stroke-width:4px,stroke-dasharray:6 3"
11
+ UNUSED_STYLE = "fill:#eeeeee,stroke:#888888,stroke-dasharray:4 2,color:#555555"
12
+ MAX_EDGES = 500 # Mermaid's default maxEdges
13
+ MAX_TEXT_SIZE = 50_000 # Mermaid's default maxTextSize
14
+ EDGE_STYLES = {blue: "stroke:#1f5fbf", red: "stroke:#c0392b"}.freeze
15
+
16
+ def initialize(file_name, config)
17
+ @file_name = file_name
18
+ @config = config
19
+ @ids = {}
20
+ @labels = {}
21
+ @unused = []
22
+ @groups = {}
23
+ @edges = []
24
+ end
25
+
26
+ def add_node(name, opts)
27
+ id = node_id(name)
28
+ return if @labels.key?(id)
29
+ @labels[id] = opts[:label] || name
30
+ @unused << id if opts[:unused]
31
+ (@groups[[opts[:region], opts[:vpc_id]]] ||= []) << id if opts[:vpc_id] || opts[:region]
32
+ end
33
+
34
+ def add_edge(from, to, opts)
35
+ add_node(from, {})
36
+ add_node(to, {})
37
+ @edges << [node_id(from), node_id(to), opts]
38
+ end
39
+
40
+ def to_mermaid
41
+ lines = ["flowchart LR"]
42
+ grouped = @groups.values.flatten
43
+ sequence = 0
44
+ @groups.group_by { |(region, _), _| region }.each do |region, entries|
45
+ indent = region ? " " : " "
46
+ lines << " subgraph g#{sequence += 1}[#{quote(region)}]" if region
47
+ entries.each do |(_, vpc_id), ids|
48
+ if vpc_id
49
+ lines << "#{indent}subgraph g#{sequence += 1}[#{quote(vpc_id)}]"
50
+ ids.each { |id| lines << "#{indent} #{node(id)}" }
51
+ lines << "#{indent}end"
52
+ else
53
+ ids.each { |id| lines << "#{indent}#{node(id)}" }
54
+ end
55
+ end
56
+ lines << " end" if region
57
+ end
58
+ (@labels.keys - grouped).each { |id| lines << " #{node(id)}" }
59
+ @edges.each { |from, to, opts| lines << " #{from} #{arrow(opts)} #{to}" }
60
+ @edges.each_with_index do |(_, _, opts), index|
61
+ style = opts[:risky] ? RISKY_STYLE : EDGE_STYLES[opts[:color]]
62
+ lines << " linkStyle #{index} #{style}" if style
63
+ end
64
+ unless @unused.empty?
65
+ lines << " classDef unused #{UNUSED_STYLE}"
66
+ lines << " class #{@unused.join(",")} unused"
67
+ end
68
+ lines.join("\n") + "\n"
69
+ end
70
+
71
+ def output
72
+ text = to_mermaid
73
+ warn_if_large(text)
74
+ File.write(@file_name, text)
75
+ end
76
+
77
+ private
78
+
79
+ def warn_if_large(text)
80
+ return if @edges.size <= MAX_EDGES && text.size <= MAX_TEXT_SIZE
81
+ AwsSecurityViz.logger.warn("mermaid output has #{@edges.size} edges and #{text.size} characters (Mermaid defaults: maxEdges #{MAX_EDGES}, maxTextSize #{MAX_TEXT_SIZE}); GitHub and mmdc may refuse to render it")
82
+ end
83
+
84
+ def node_id(name)
85
+ @ids[name] ||= "n#{@ids.size}"
86
+ end
87
+
88
+ def node(id)
89
+ "#{id}[#{quote(@labels[id])}]"
90
+ end
91
+
92
+ def arrow(opts)
93
+ label = opts[:label].to_s
94
+ label.empty? ? "-->" : "-->|#{quote(label)}|"
95
+ end
96
+
97
+ # Mermaid quoted text takes HTML-style entity codes; '#' goes first so it never re-escapes.
98
+ def quote(value)
99
+ escaped = value.to_s.gsub("#", "#35;").gsub('"', "#quot;").gsub("<", "#lt;").gsub(">", "#gt;")
100
+ .gsub("`", "#96;").gsub(/\r\n|\r|\n/, " ")
101
+ "\"#{escaped}\""
102
+ end
103
+ end
104
+ end
105
+ end