heitt 0.4.5 → 0.5.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/bin/heitt +96 -58
- data/lib/heitt/analyzer.rb +153 -0
- data/lib/heitt/database.rb +276 -1782
- data/lib/heitt/decoder.rb +48 -0
- data/lib/heitt/formatter.rb +131 -0
- data/lib/heitt/grouper.rb +20 -0
- data/lib/heitt/profiles.rb +184 -0
- data/lib/heitt/scanner.rb +62 -0
- data/lib/heitt/utils.rb +57 -0
- data/lib/heitt/version.rb +3 -2
- data/lib/heitt.rb +15 -364
- metadata +17 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae7835969de375994bf7a0f9bf03c453f1ccd77d9086b4712a13e755084cb01c
|
|
4
|
+
data.tar.gz: 5754d899e34e5a9e8162c451fcef359939165221a0a758348e9ec608e9c0cc5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f2174d59e403b16385946ca733cdc428fb75f09c6c4275f42d6ecb6870725f8a4224d8389934d4d5a0106e6a01f65d9226eb1de30fc6997d98e2b4c81b09ade
|
|
7
|
+
data.tar.gz: 228b63e61c87e07b4f8f5c68afc8121fe95c6e9bffd644408cda410e05d7e6314684d730e14fa640793c14b8d21dc7664ba22ed84c27b5399fdf99141e249c2d
|
data/bin/heitt
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
require 'optparse'
|
|
3
3
|
require 'io/console'
|
|
4
4
|
require 'heitt'
|
|
5
|
+
#require_relative '../lib/heitt'
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
module HEITT
|
|
9
10
|
class CLI
|
|
10
|
-
attr_accessor :inputs, :extended, :verbose, :
|
|
11
|
-
|
|
11
|
+
attr_accessor :inputs, :extended, :verbose, :tree, :database, :show_regex_match, :min_entropy, :peel_limit
|
|
12
12
|
def initialize
|
|
13
13
|
@inputs = []
|
|
14
14
|
@extended = false
|
|
15
15
|
@verbose = false
|
|
16
|
-
@
|
|
17
|
-
@
|
|
16
|
+
@tree = false
|
|
17
|
+
@peel_limit = 0
|
|
18
18
|
@database = ""
|
|
19
19
|
@show_regex_match = false
|
|
20
20
|
@min_entropy = 3.5
|
|
@@ -45,8 +45,12 @@ module HEITT
|
|
|
45
45
|
opts.separator ""
|
|
46
46
|
opts.separator "OUTPUT OPTIONS:"
|
|
47
47
|
opts.on("-V", "--verbose", "Show description and notes for each candidate") {@verbose = true}
|
|
48
|
-
opts.on("-
|
|
49
|
-
opts.on("
|
|
48
|
+
opts.on("-t", "--tree", "Force tree format when output is redirected"){@tree = true}
|
|
49
|
+
opts.on("--debug", "Output in debug mode") {HEITT::Logger.enable_debug}
|
|
50
|
+
|
|
51
|
+
opts.separator ""
|
|
52
|
+
opts.separator "DECODE OPTIONS:"
|
|
53
|
+
opts.on("-L", "--peel-limit INTEGER", Integer, "Maximum decode layers to peel(supports base64 and hex)") {|v| @peel_limit = v}
|
|
50
54
|
|
|
51
55
|
opts.separator ""
|
|
52
56
|
opts.separator "FILTERING OPTIONS:"
|
|
@@ -62,7 +66,7 @@ module HEITT
|
|
|
62
66
|
opts.separator "EXAMPLES:"
|
|
63
67
|
opts.separator " heitt 634d398e96eb1550956b8128cfeb0747 -r"
|
|
64
68
|
opts.separator " heitt auth.log"
|
|
65
|
-
opts.separator " heitt auth.log
|
|
69
|
+
opts.separator " heitt auth.log | cat"
|
|
66
70
|
opts.separator " heitt auth.log --extended --regex-match"
|
|
67
71
|
opts.separator " heitt auth.log --min-entropy 4.0"
|
|
68
72
|
opts.separator " cat auth.log | heitt "
|
|
@@ -70,6 +74,7 @@ module HEITT
|
|
|
70
74
|
opts.separator ""
|
|
71
75
|
opts.separator "NOTES:"
|
|
72
76
|
opts.separator " JSON format is default when output is redirected or piped."
|
|
77
|
+
opts.separator " Custom database if merged with default database"
|
|
73
78
|
opts.separator " Regex-match candidates are hidden by default, use '-r' to show."
|
|
74
79
|
opts.separator " Running without input starts interactive mode."
|
|
75
80
|
opts.separator "#{header("=========================================================================")}"
|
|
@@ -78,30 +83,13 @@ module HEITT
|
|
|
78
83
|
end.parse!
|
|
79
84
|
|
|
80
85
|
@inputs = if ARGV.empty?
|
|
86
|
+
|
|
81
87
|
if $stdin.tty?
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
puts "Enter a hash or file"
|
|
85
|
-
loop do
|
|
86
|
-
print "heitt> "
|
|
87
|
-
input = $stdin.gets&.strip
|
|
88
|
-
break if input.nil? || input.empty?
|
|
89
|
-
results = HEITT::Scanner.scan(input, min_entropy: @min_entropy)
|
|
90
|
-
groups = HEITT::Grouper.group(results)
|
|
91
|
-
format = @json ? :json : :tree
|
|
92
|
-
output = case format
|
|
93
|
-
when :json
|
|
94
|
-
HEITT::Formatter.json(groups, extended: @extended, show_regex_match: @show_regex_match)
|
|
95
|
-
else
|
|
96
|
-
HEITT::Formatter.tree(groups, extended: @extended, show_regex_match: @show_regex_match)
|
|
97
|
-
end
|
|
98
|
-
puts output
|
|
99
|
-
puts ""
|
|
100
|
-
end
|
|
88
|
+
# instead of hanging, make it interactive
|
|
89
|
+
interactive_loop
|
|
101
90
|
else
|
|
102
91
|
[$stdin.read]
|
|
103
92
|
end
|
|
104
|
-
|
|
105
93
|
|
|
106
94
|
else
|
|
107
95
|
ARGV.dup
|
|
@@ -109,69 +97,119 @@ module HEITT
|
|
|
109
97
|
self
|
|
110
98
|
end
|
|
111
99
|
|
|
100
|
+
def interactive_loop
|
|
101
|
+
database = @database.empty? ? HEITT::DATABASE : load_custom_database(@database)
|
|
102
|
+
# Interactive mode
|
|
103
|
+
puts header("=== HEITT INTERACTIVE MODE ====")
|
|
104
|
+
#print "[debug:#{HEITT::Logger.debug_enabled? ? "on": "off"} || "
|
|
105
|
+
print "extended: #{@extended ? "on": "off"} || "
|
|
106
|
+
print "peel-limit: #{@peel_limit} || "
|
|
107
|
+
print "verbose: #{@verbose ? "on": "off"} || "
|
|
108
|
+
print "regex-match: #{@show_regex_match ? "on": "off"} || "
|
|
109
|
+
print "min-entropy: #{@min_entropy} || "
|
|
110
|
+
print "database: #{@database.empty? ? "default": database} || "
|
|
111
|
+
print "output-format: #{@json ? "json": "tree"}]\n"
|
|
112
|
+
puts ""
|
|
113
|
+
puts "Enter a hash or filepath. Press Ctrl+C or Enter to exit"
|
|
114
|
+
#puts "Extended: true" unless
|
|
115
|
+
# puts used flags here
|
|
116
|
+
|
|
117
|
+
loop do
|
|
118
|
+
print "heitt> "
|
|
119
|
+
input = $stdin.gets&.strip
|
|
120
|
+
break if input.nil? || input.empty?
|
|
121
|
+
results = HEITT::Scanner.scan(input, min_entropy: @min_entropy)
|
|
122
|
+
groups = HEITT::Grouper.group(results)
|
|
123
|
+
|
|
124
|
+
format = :tree
|
|
125
|
+
|
|
126
|
+
output = case format
|
|
127
|
+
when :tree
|
|
128
|
+
HEITT::Formatter.json(groups, extended: @extended, show_regex_match: @show_regex_match)
|
|
129
|
+
else
|
|
130
|
+
HEITT::Formatter.tree(groups, verbose: @verbose, extended: @extended, show_regex_match: @show_regex_match)
|
|
131
|
+
end
|
|
132
|
+
puts output
|
|
133
|
+
puts ""
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
|
|
112
138
|
def run
|
|
113
|
-
database = @database.empty? ? HEITT::DATABASE : load_custom_database(@database)
|
|
139
|
+
database = @database.empty? ? HEITT::DATABASE : load_custom_database(@database)
|
|
114
140
|
|
|
115
|
-
format = @
|
|
141
|
+
format = if @tree
|
|
142
|
+
:tree
|
|
143
|
+
elsif !$stdout.tty?
|
|
144
|
+
:json
|
|
145
|
+
else
|
|
146
|
+
:tree
|
|
147
|
+
end
|
|
148
|
+
|
|
116
149
|
output = @inputs.map do |input|
|
|
117
|
-
|
|
150
|
+
text = read_input(input)
|
|
151
|
+
text = HEITT::Decoder.peel(text, peel_limit: @peel_limit)[:final] if @peel_limit > 0
|
|
152
|
+
results = HEITT::Scanner.scan(text, database: database, min_entropy: @min_entropy)
|
|
118
153
|
groups = HEITT::Grouper.group(results)
|
|
119
154
|
|
|
120
155
|
case format
|
|
121
156
|
when :json
|
|
122
157
|
HEITT::Formatter.json(groups, extended: @extended, show_regex_match: @show_regex_match)
|
|
123
158
|
else
|
|
124
|
-
HEITT::Formatter.tree(groups, extended: @extended, show_regex_match: @show_regex_match)
|
|
159
|
+
HEITT::Formatter.tree(groups, verbose: @verbose, extended: @extended, show_regex_match: @show_regex_match)
|
|
125
160
|
end
|
|
126
161
|
end.join("\n")
|
|
127
162
|
|
|
128
|
-
unless @output.empty?
|
|
129
|
-
|
|
130
|
-
else
|
|
131
|
-
|
|
132
|
-
end
|
|
163
|
+
#unless @output.empty?
|
|
164
|
+
# File.write(@output, output)
|
|
165
|
+
#else
|
|
166
|
+
puts output
|
|
167
|
+
#end
|
|
133
168
|
end
|
|
134
169
|
|
|
135
170
|
private
|
|
171
|
+
def read_input(input)
|
|
172
|
+
File.exist?(File.expand_path(input)) ? File.read(File.expand_path(input)) : input
|
|
173
|
+
end
|
|
174
|
+
|
|
136
175
|
def header(text)
|
|
137
176
|
terminal_width = IO.console.winsize[1] rescue 70
|
|
138
177
|
text.center(terminal_width)
|
|
139
178
|
end
|
|
140
179
|
|
|
141
180
|
|
|
142
|
-
|
|
181
|
+
|
|
182
|
+
def load_custom_database(file_path)
|
|
183
|
+
filepath = File.expand_path(file_path)
|
|
184
|
+
HEITT::Logger.debug("filepath: #{filepath}")
|
|
143
185
|
unless File.exist?(filepath)
|
|
144
|
-
|
|
186
|
+
HEITT::Logger.error("file #{filepath} does not exist")
|
|
145
187
|
exit(1)
|
|
146
188
|
end
|
|
147
189
|
begin
|
|
148
190
|
custom_database = JSON.parse(File.read(filepath), symbolize_names: true)
|
|
149
191
|
HEITT::DATABASE + custom_database
|
|
150
192
|
rescue JSON::ParserError => e
|
|
151
|
-
|
|
193
|
+
HEITT::Logger.error("Invalid JSON in #{label} file: #{e.message}")
|
|
152
194
|
exit(1)
|
|
153
195
|
end
|
|
154
196
|
end
|
|
155
|
-
|
|
156
|
-
# def decode(text, decoders)
|
|
157
|
-
#Check if result is printable ascii after decoding
|
|
158
|
-
# decoders.each do |decoder|
|
|
159
|
-
# case decoder
|
|
160
|
-
# when "b64"
|
|
161
|
-
# text = text.gsub(/[A-Za-z0-9+\/]{4,}={0,2}/) do |match|
|
|
162
|
-
# current = match
|
|
163
|
-
# loop do
|
|
164
|
-
# #until word is non printable ascii continue decoding
|
|
165
|
-
# decoded = Base64.decode64(word) #rescue break
|
|
166
|
-
# current = decoded.strip.match?(/\A[[:print:]]+\z/) ? decoded.strip : break
|
|
167
|
-
#puts "IS PRINTABLE?: #{decoded.match?(/\A[[:print:]]+\z/)}"
|
|
168
|
-
#puts "RESULT: #{result}"
|
|
169
|
-
# end
|
|
170
|
-
# current
|
|
171
|
-
# end
|
|
172
|
-
# when "hex"
|
|
173
|
-
# text = tesxt.gsub(/)
|
|
174
197
|
end
|
|
175
198
|
end
|
|
176
199
|
|
|
177
200
|
cli = HEITT::CLI.new.parse!.run #if __FILE__ == $0
|
|
201
|
+
|
|
202
|
+
## Some flags will be removed.
|
|
203
|
+
# output flag will be replaced by tree flag. it existed because
|
|
204
|
+
# tree couldnt show when output was redirected.
|
|
205
|
+
# json flag will be removed, although it was created so its format will be seen
|
|
206
|
+
# it now looks redundant to the automatic version.
|
|
207
|
+
# -r flag wont be used for everything. it was made
|
|
208
|
+
# to filter out low confidence types from high confidence ones and show when asked for.
|
|
209
|
+
# in case of all being "regex-matched", -r shouldnt be necessary.
|
|
210
|
+
# this will make it convenient to check single hashes without always needing -r flag
|
|
211
|
+
#auto decoding hasn't being dropped, a library "codeberg.org/jobotow/lookslike" is in the making to help with encoded string identification.
|
|
212
|
+
# it will decode and check it it's a binary blob. if first decode shows binary blob, then it certainly isn't a hash hex or proper b64.
|
|
213
|
+
# else it should continue decoding till ut also meets binary blob before returning back to previous text before last decode"
|
|
214
|
+
# from the context that a text that's encoded once cant be decoded twice because the binary blob will show that the second text undergoing decoding was the actual answer.
|
|
215
|
+
# binary blob will be the eyes as it's uncertain the number of times a text has being encoded.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
require_relative 'profiles'
|
|
2
|
+
|
|
3
|
+
module HEITT
|
|
4
|
+
module Analyzer
|
|
5
|
+
def self.analyze(text, profiles: HEITT::PROFILES)#database: HEITT::DATABASE)
|
|
6
|
+
keyword_counts = keyword_counts(text.downcase, profiles: profiles)
|
|
7
|
+
algorithm_scores(keyword_counts, profiles: profiles)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.extract_prefix(text, offset)
|
|
11
|
+
line_start = text.rindex("\n", offset) || 0
|
|
12
|
+
text[line_start...offset]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.high_entropy?(text, min_ent)
|
|
16
|
+
entropy(text) >= min_ent
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def self.score_candidates(modes, delim_prefix, context_scores, profiles: HEITT::PROFILES)
|
|
21
|
+
prefix_matched_mode = nil
|
|
22
|
+
#context based scoring
|
|
23
|
+
matches = modes.map do |mode|
|
|
24
|
+
profile = profiles[mode[:name]] || {}
|
|
25
|
+
score = context_scores[mode[:name]] || 0
|
|
26
|
+
#score = score_data || 0
|
|
27
|
+
|
|
28
|
+
if prefix_match?(profile, delim_prefix)
|
|
29
|
+
#boost score as confidence is high if prefix matched
|
|
30
|
+
prefix_matched_mode = mode[:name]
|
|
31
|
+
score += 20
|
|
32
|
+
end
|
|
33
|
+
{
|
|
34
|
+
name: mode[:name],
|
|
35
|
+
hashcat: mode[:hashcat],
|
|
36
|
+
john: mode[:john],
|
|
37
|
+
extended: mode[:extended],
|
|
38
|
+
description: profile[:description],
|
|
39
|
+
notes: profile[:notes],
|
|
40
|
+
score: score
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
return [] if matches.empty?
|
|
44
|
+
|
|
45
|
+
#calculate confidence
|
|
46
|
+
scores_hash = matches.map {|m| [m[:name], m[:score]]}.to_h
|
|
47
|
+
|
|
48
|
+
confidences = assign_confidence(scores_hash, prefix_matched_mode)
|
|
49
|
+
scored_candidates = matches.map{|m| m.merge(confidence: confidences[m[:name]])}.sort_by {|m| -m[:score]}
|
|
50
|
+
scored_candidates
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
#def self.get_modes(entry)
|
|
56
|
+
# entry[:modes] || entry[:algorithms] || entry[:hashes] ||
|
|
57
|
+
# entry[:candidates] || entry[:types] || entry[:hashtypes]
|
|
58
|
+
#end
|
|
59
|
+
|
|
60
|
+
#this code is an inspiration of "https://github.com/chrisjchandler/entropy/blob/main/entropy.go"
|
|
61
|
+
def self.entropy(text)
|
|
62
|
+
frequency = Hash.new(0)
|
|
63
|
+
text.each_char { |ch| frequency[ch] += 1 }
|
|
64
|
+
|
|
65
|
+
#calculate the total number of characters
|
|
66
|
+
total = text.length.to_f
|
|
67
|
+
#caluclate entropy
|
|
68
|
+
entropy = 0.0
|
|
69
|
+
frequency.each_value do |count|
|
|
70
|
+
probability = count.to_f / total
|
|
71
|
+
entropy += probability * Math.log2(probability)
|
|
72
|
+
end
|
|
73
|
+
#negate the sum as entropy is positive
|
|
74
|
+
-entropy
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.keyword_counts(content_lower, profiles: HEITT::PROFILES) #HEITT::DATABASE)
|
|
78
|
+
keywords = profiles.values.flat_map { |p| p[:context] || [] }.uniq.map(&:downcase)
|
|
79
|
+
#database.flat_map do |entry|
|
|
80
|
+
#modes = get_modes(entry)
|
|
81
|
+
#next [] unless modes
|
|
82
|
+
#modes.flat_map {|mode| HEITT::PROFILES[mode[:name]][:context] || []}
|
|
83
|
+
#end#.uniq.map(&:downcase)
|
|
84
|
+
|
|
85
|
+
counts = {}
|
|
86
|
+
keywords.each do |kw|
|
|
87
|
+
count = content_lower.scan(/\b#{Regexp.escape(kw)}\b/).size
|
|
88
|
+
counts[kw] = count if count > 0
|
|
89
|
+
end
|
|
90
|
+
counts
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def self.algorithm_scores(keyword_counts, profiles: HEITT::PROFILES)#database: HEITT::DATABASE)
|
|
95
|
+
scores = {}
|
|
96
|
+
return scores if keyword_counts.nil?
|
|
97
|
+
|
|
98
|
+
profiles.each do |name, profile| #database.each do |entry|
|
|
99
|
+
context = profile[:context] || []
|
|
100
|
+
next if context.empty?
|
|
101
|
+
#modes = get_modes(entry)
|
|
102
|
+
#next unless modes
|
|
103
|
+
#modes.each do |mode|
|
|
104
|
+
# contexts = mode[:context] || []
|
|
105
|
+
# next if contexts.empty?
|
|
106
|
+
total = context.sum {|kw| keyword_counts[kw.downcase] || 0}
|
|
107
|
+
scores[name] = total if total > 0
|
|
108
|
+
# end
|
|
109
|
+
end
|
|
110
|
+
scores
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def self.prefix_match?(profile, delim_prefix)
|
|
115
|
+
#prefixes = mode[:prefixes] || []
|
|
116
|
+
prefixes = profile[:prefixes] || []
|
|
117
|
+
return false if prefixes.empty?
|
|
118
|
+
|
|
119
|
+
delimiters = "= : "
|
|
120
|
+
raw_prefix = delim_prefix.strip.split(/[#{Regexp.escape(delimiters)}]/).last&.strip&.downcase
|
|
121
|
+
prefixes.map(&:downcase).include?(raw_prefix)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def self.assign_confidence(scores_hash, prefix_matched_mode=nil)
|
|
126
|
+
all_scores = scores_hash.values
|
|
127
|
+
|
|
128
|
+
return {} if all_scores.empty?
|
|
129
|
+
|
|
130
|
+
avg_score = all_scores.sum.to_f / all_scores.size
|
|
131
|
+
|
|
132
|
+
scores_hash.transform_values do |score|
|
|
133
|
+
if score == 0
|
|
134
|
+
"regex-match"
|
|
135
|
+
else
|
|
136
|
+
mode_name = scores_hash.key(score)
|
|
137
|
+
is_prefix_mode = (prefix_matched_mode == mode_name)
|
|
138
|
+
deviation = (score - avg_score) / avg_score
|
|
139
|
+
|
|
140
|
+
case deviation
|
|
141
|
+
when 2.0..Float::INFINITY
|
|
142
|
+
"high"
|
|
143
|
+
when 0.5..2.0
|
|
144
|
+
is_prefix_mode ? "high" : "medium-high"
|
|
145
|
+
else
|
|
146
|
+
is_prefix_mode ? "medium-high" : "medium-low"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
#private_constant :Analyzer
|
|
153
|
+
end
|