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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +122 -81
- data/exe/aws_security_viz +4 -40
- data/lib/aws_security_viz/aws_config.rb +80 -0
- data/lib/aws_security_viz/cli.rb +151 -0
- data/lib/aws_security_viz/cli_guard.rb +39 -0
- data/lib/aws_security_viz/directed_graph.rb +72 -0
- data/lib/aws_security_viz/ec2/security_groups.rb +74 -0
- data/lib/aws_security_viz/exclusions.rb +18 -0
- data/lib/aws_security_viz/export/html/viewer.html +222 -0
- data/lib/aws_security_viz/graph.rb +93 -0
- data/lib/aws_security_viz/graph_filter.rb +20 -0
- data/lib/aws_security_viz/logging.rb +18 -0
- data/lib/aws_security_viz/model.rb +87 -0
- data/lib/aws_security_viz/obfuscation.rb +45 -0
- data/lib/{opts.yml.sample → aws_security_viz/opts.yml.sample} +2 -0
- data/lib/aws_security_viz/port_label.rb +44 -0
- data/lib/aws_security_viz/provider/ec2.rb +84 -0
- data/lib/aws_security_viz/provider/json.rb +17 -0
- data/lib/aws_security_viz/renderer/all.rb +49 -0
- data/lib/aws_security_viz/renderer/graphviz.rb +119 -0
- data/lib/aws_security_viz/renderer/html.rb +46 -0
- data/lib/aws_security_viz/renderer/json.rb +29 -0
- data/lib/aws_security_viz/renderer/mermaid.rb +105 -0
- data/lib/aws_security_viz/risk.rb +18 -0
- data/lib/aws_security_viz/vendor/cytoscape/LICENSE +19 -0
- data/lib/aws_security_viz/vendor/cytoscape/README.md +15 -0
- data/lib/aws_security_viz/vendor/cytoscape/cytoscape.min.js +31 -0
- data/lib/aws_security_viz/version.rb +5 -0
- data/lib/aws_security_viz.rb +44 -37
- metadata +39 -232
- data/.dockerignore +0 -7
- data/.editorconfig +0 -17
- data/.github/dependabot.yml +0 -15
- data/.github/workflows/ruby.yml +0 -34
- data/.github/workflows/rubygem.yml +0 -29
- data/.github/workflows/rubygem_release.yml +0 -27
- data/.gitignore +0 -43
- data/.tool-versions +0 -1
- data/CODE_OF_CONDUCT.md +0 -46
- data/Dockerfile +0 -9
- data/Gemfile +0 -3
- data/Rakefile +0 -15
- data/Vagrantfile +0 -18
- data/aws_security_viz.gemspec +0 -40
- data/config/boot.rb +0 -4
- data/images/sample.png +0 -0
- data/lib/aws_config.rb +0 -44
- data/lib/color_picker.rb +0 -245
- data/lib/debug/parse_log.rb +0 -26
- data/lib/debug_graph.rb +0 -29
- data/lib/ec2/ip_permission.rb +0 -35
- data/lib/ec2/security_groups.rb +0 -78
- data/lib/ec2/traffic.rb +0 -32
- data/lib/exclusions.rb +0 -14
- data/lib/export/html/navigator.html +0 -84
- data/lib/export/html/view.html +0 -163
- data/lib/graph.rb +0 -45
- data/lib/graph_filter.rb +0 -38
- data/lib/provider/ec2.rb +0 -105
- data/lib/provider/json.rb +0 -104
- data/lib/renderer/all.rb +0 -18
- data/lib/renderer/graphviz.rb +0 -37
- data/lib/renderer/json.rb +0 -23
- data/lib/renderer/navigator.rb +0 -33
- data/lib/version.rb +0 -3
- data/spec/color_picker_spec.rb +0 -20
- data/spec/graph_filter_spec.rb +0 -85
- data/spec/integration/aws_expected.json +0 -1
- data/spec/integration/dummy.dot +0 -50
- data/spec/integration/dummy.json +0 -64
- data/spec/integration/expected.json +0 -1
- data/spec/integration/navigator.json +0 -1
- data/spec/integration/visualize_aws_spec.rb +0 -96
- data/spec/spec_helper.rb +0 -36
- 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
|
|
@@ -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
|