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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +120 -79
- 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,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
|