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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aaae42753b6fd3c04375200d118517b8b3f45eb42d0bf6b4fddc9b08c64e5af7
4
- data.tar.gz: ed0b83ef1116ebc291b000985784107a709156df68be16443598d2a41ae59853
3
+ metadata.gz: ae7835969de375994bf7a0f9bf03c453f1ccd77d9086b4712a13e755084cb01c
4
+ data.tar.gz: 5754d899e34e5a9e8162c451fcef359939165221a0a758348e9ec608e9c0cc5a
5
5
  SHA512:
6
- metadata.gz: c4f7ea072d396d4c5ba96f9f65402ab006c1a94856b77cd359417d7ddc26bde65bc81928369f64ad36562aa0d7ce4e7b0efbe4d04edb6d04e467c9df4ce5ab6d
7
- data.tar.gz: 3a1c7494156de04f10c2a3f132f85337fdd597197883b81cc4d1df934fd3c9d70dcaf89378729837a7e0f086d6cb2d2c75518675e0af56b60e4ee7c3e2f1f804
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, :output, :json, :database, :show_regex_match, :min_entropy
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
- @output = ""
17
- @json = false
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("-j", "--json","Output in json format") {@json = true}
49
- opts.on("-o", "--output FILEPATH", String, "File to write output to") {|v| @output = v}
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 --json --output result.json"
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
- # Interactive mode
83
- puts "#{header("=== HEITT INTERACTIVE MODE ====\n")}"
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 = @json || !$stdout.tty? ? :json : :tree
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
- results = HEITT::Scanner.scan(input, database: database, min_entropy: @min_entropy)
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
- File.write(@output, output)
130
- else
131
- puts output
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
- def load_custom_database(filepath)
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
- puts HEITT::Color.colorize("[ERROR] Database file #{filepath} does not exists", :bold, :red)
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
- puts "#{HEITT::Color.colorize("[ERROR] Invalid JSON in database file: ", :bold, :red)}#{e.message}"
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