crucible 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +102 -0
  4. data/Gemfile +10 -0
  5. data/LICENSE +21 -0
  6. data/README.md +366 -0
  7. data/Rakefile +23 -0
  8. data/TESTING.md +319 -0
  9. data/config.sample.yml +48 -0
  10. data/crucible.gemspec +48 -0
  11. data/exe/crucible +122 -0
  12. data/lib/crucible/configuration.rb +212 -0
  13. data/lib/crucible/server.rb +123 -0
  14. data/lib/crucible/session_manager.rb +209 -0
  15. data/lib/crucible/stealth/evasions/chrome_app.js +75 -0
  16. data/lib/crucible/stealth/evasions/chrome_csi.js +33 -0
  17. data/lib/crucible/stealth/evasions/chrome_load_times.js +44 -0
  18. data/lib/crucible/stealth/evasions/chrome_runtime.js +190 -0
  19. data/lib/crucible/stealth/evasions/iframe_content_window.js +101 -0
  20. data/lib/crucible/stealth/evasions/media_codecs.js +65 -0
  21. data/lib/crucible/stealth/evasions/navigator_hardware_concurrency.js +18 -0
  22. data/lib/crucible/stealth/evasions/navigator_languages.js +18 -0
  23. data/lib/crucible/stealth/evasions/navigator_permissions.js +53 -0
  24. data/lib/crucible/stealth/evasions/navigator_plugins.js +261 -0
  25. data/lib/crucible/stealth/evasions/navigator_vendor.js +18 -0
  26. data/lib/crucible/stealth/evasions/navigator_webdriver.js +16 -0
  27. data/lib/crucible/stealth/evasions/webgl_vendor.js +43 -0
  28. data/lib/crucible/stealth/evasions/window_outerdimensions.js +18 -0
  29. data/lib/crucible/stealth/utils.js +266 -0
  30. data/lib/crucible/stealth.rb +213 -0
  31. data/lib/crucible/tools/cookies.rb +206 -0
  32. data/lib/crucible/tools/downloads.rb +273 -0
  33. data/lib/crucible/tools/extraction.rb +335 -0
  34. data/lib/crucible/tools/helpers.rb +46 -0
  35. data/lib/crucible/tools/interaction.rb +355 -0
  36. data/lib/crucible/tools/navigation.rb +181 -0
  37. data/lib/crucible/tools/sessions.rb +85 -0
  38. data/lib/crucible/tools/stealth.rb +167 -0
  39. data/lib/crucible/tools.rb +42 -0
  40. data/lib/crucible/version.rb +5 -0
  41. data/lib/crucible.rb +60 -0
  42. metadata +201 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Crucible
6
+ # Configuration for the Crucible server
7
+ #
8
+ # Supports both programmatic configuration and YAML config files.
9
+ #
10
+ # @example Programmatic configuration
11
+ # config = Crucible::Configuration.new(headless: false, timeout: 60)
12
+ # config.viewport_width = 1920
13
+ #
14
+ # @example From YAML file
15
+ # config = Crucible::Configuration.from_file("~/.config/crucible/config.yml")
16
+ #
17
+ class Configuration
18
+ DEFAULT_CONFIG_PATHS = [
19
+ '~/.config/crucible/config.yml',
20
+ '~/.crucible.yml',
21
+ '.crucible.yml'
22
+ ].freeze
23
+
24
+ DEFAULTS = {
25
+ headless: true,
26
+ viewport_width: 1280,
27
+ viewport_height: 720,
28
+ chrome_path: nil,
29
+ timeout: 30,
30
+ error_level: :warn,
31
+ screenshot_format: :png,
32
+ content_format: :html,
33
+ # Stealth settings
34
+ stealth_enabled: true,
35
+ stealth_profile: :moderate,
36
+ stealth_locale: 'en-US,en',
37
+ # Mode settings
38
+ mode: nil,
39
+ log_file: nil
40
+ }.freeze
41
+
42
+ VALID_ERROR_LEVELS = %i[debug info warn error].freeze
43
+ VALID_SCREENSHOT_FORMATS = %i[png jpeg base64].freeze
44
+ VALID_CONTENT_FORMATS = %i[html text].freeze
45
+ VALID_STEALTH_PROFILES = %i[minimal moderate maximum].freeze
46
+
47
+ attr_accessor(*DEFAULTS.keys, :modes)
48
+
49
+ # Load configuration from a YAML file
50
+ # @param path [String] path to the YAML config file
51
+ # @return [Configuration]
52
+ def self.from_file(path)
53
+ expanded_path = File.expand_path(path)
54
+ raise Error, "Config file not found: #{expanded_path}" unless File.exist?(expanded_path)
55
+
56
+ yaml = YAML.safe_load_file(expanded_path, symbolize_names: true)
57
+ from_yaml_hash(yaml)
58
+ end
59
+
60
+ # Load configuration from default locations
61
+ # @return [Configuration] config from first found file, or defaults
62
+ def self.from_defaults
63
+ DEFAULT_CONFIG_PATHS.each do |path|
64
+ expanded = File.expand_path(path)
65
+ return from_file(expanded) if File.exist?(expanded)
66
+ end
67
+
68
+ new
69
+ end
70
+
71
+ # Create configuration from a parsed YAML hash
72
+ # @param yaml [Hash] parsed YAML configuration
73
+ # @return [Configuration]
74
+ def self.from_yaml_hash(yaml)
75
+ options = {}
76
+
77
+ # Browser settings
78
+ if yaml[:browser]
79
+ options[:headless] = yaml[:browser][:headless] if yaml[:browser].key?(:headless)
80
+ if yaml[:browser][:window_size].is_a?(Array)
81
+ options[:viewport_width] = yaml[:browser][:window_size][0]
82
+ options[:viewport_height] = yaml[:browser][:window_size][1]
83
+ end
84
+ options[:chrome_path] = yaml[:browser][:chrome_path] if yaml[:browser][:chrome_path]
85
+ options[:timeout] = yaml[:browser][:timeout] if yaml[:browser][:timeout]
86
+ end
87
+
88
+ # Stealth settings
89
+ if yaml[:stealth]
90
+ options[:stealth_enabled] = yaml[:stealth][:enabled] if yaml[:stealth].key?(:enabled)
91
+ options[:stealth_profile] = yaml[:stealth][:profile]&.to_sym if yaml[:stealth][:profile]
92
+ options[:stealth_locale] = yaml[:stealth][:locale] if yaml[:stealth][:locale]
93
+ end
94
+
95
+ # Server settings
96
+ if yaml[:server]
97
+ options[:error_level] = yaml[:server][:log_level]&.to_sym if yaml[:server][:log_level]
98
+ options[:log_file] = yaml[:server][:logfile] if yaml[:server][:logfile]
99
+ end
100
+
101
+ config = new(options)
102
+
103
+ # Store modes for runtime switching
104
+ config.modes = yaml[:modes] if yaml[:modes]
105
+
106
+ config
107
+ end
108
+
109
+ # @param options [Hash] configuration options
110
+ def initialize(options = {})
111
+ DEFAULTS.each do |key, default|
112
+ value = options.fetch(key, default)
113
+ # Symbolize string keys for enums
114
+ value = value.to_sym if value.is_a?(String) && enum_key?(key)
115
+ instance_variable_set(:"@#{key}", value)
116
+ end
117
+ @modes = {}
118
+ end
119
+
120
+ # Creates a new Configuration with merged options
121
+ # @param options [Hash] options to merge
122
+ # @return [Configuration]
123
+ def merge(options)
124
+ self.class.new(to_h.merge(options))
125
+ end
126
+
127
+ # Converts configuration to a hash
128
+ # @return [Hash]
129
+ def to_h
130
+ DEFAULTS.keys.to_h { |k| [k, instance_variable_get(:"@#{k}")] }
131
+ end
132
+
133
+ # Returns options suitable for Ferrum::Browser.new
134
+ # @return [Hash]
135
+ def browser_options
136
+ opts = {
137
+ headless: headless,
138
+ window_size: [viewport_width, viewport_height],
139
+ timeout: timeout,
140
+ process_timeout: timeout * 2
141
+ }
142
+ opts[:browser_path] = chrome_path if chrome_path
143
+ opts
144
+ end
145
+
146
+ # Creates a Stealth instance based on configuration
147
+ # @return [Stealth]
148
+ def stealth
149
+ Stealth.new(
150
+ profile: stealth_profile,
151
+ enabled: stealth_enabled,
152
+ locale: stealth_locale
153
+ )
154
+ end
155
+
156
+ # Apply a named mode from the modes configuration
157
+ # @param mode_name [String, Symbol] the mode to apply
158
+ # @return [Configuration] self with mode settings applied
159
+ def apply_mode(mode_name)
160
+ mode_name = mode_name.to_sym
161
+ return self unless modes && modes[mode_name]
162
+
163
+ mode_config = modes[mode_name]
164
+
165
+ self.stealth_profile = mode_config[:stealth_profile]&.to_sym if mode_config[:stealth_profile]
166
+ self.screenshot_format = mode_config[:screenshot_format]&.to_sym if mode_config[:screenshot_format]
167
+ self.timeout = mode_config[:wait_timeout] / 1000 if mode_config[:wait_timeout]
168
+
169
+ @mode = mode_name
170
+ self
171
+ end
172
+
173
+ # Get list of available mode names
174
+ # @return [Array<Symbol>]
175
+ def available_modes
176
+ modes&.keys || []
177
+ end
178
+
179
+ # Validates the configuration
180
+ # @raise [Error] if configuration is invalid
181
+ def validate!
182
+ raise Error, 'Timeout must be positive' unless timeout.positive?
183
+ raise Error, 'Viewport width must be positive' unless viewport_width.positive?
184
+ raise Error, 'Viewport height must be positive' unless viewport_height.positive?
185
+
186
+ unless VALID_ERROR_LEVELS.include?(error_level)
187
+ raise Error, "Invalid error_level: #{error_level}. Must be one of: #{VALID_ERROR_LEVELS.join(', ')}"
188
+ end
189
+
190
+ unless VALID_SCREENSHOT_FORMATS.include?(screenshot_format)
191
+ raise Error,
192
+ "Invalid screenshot_format: #{screenshot_format}. Must be one of: #{VALID_SCREENSHOT_FORMATS.join(', ')}"
193
+ end
194
+
195
+ unless VALID_CONTENT_FORMATS.include?(content_format)
196
+ raise Error, "Invalid content_format: #{content_format}. Must be one of: #{VALID_CONTENT_FORMATS.join(', ')}"
197
+ end
198
+
199
+ unless VALID_STEALTH_PROFILES.include?(stealth_profile)
200
+ raise Error, "Invalid stealth_profile: #{stealth_profile}. Must be one of: #{VALID_STEALTH_PROFILES.join(', ')}"
201
+ end
202
+
203
+ true
204
+ end
205
+
206
+ private
207
+
208
+ def enum_key?(key)
209
+ %i[error_level screenshot_format content_format stealth_profile].include?(key)
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Crucible
6
+ # MCP Server for browser automation
7
+ #
8
+ # Provides browser automation tools via the Model Context Protocol.
9
+ # Uses stdio transport for communication with AI agents.
10
+ #
11
+ # @example
12
+ # config = Crucible::Configuration.new(headless: true)
13
+ # server = Crucible::Server.new(config)
14
+ # server.run
15
+ #
16
+ class Server
17
+ # @param config [Configuration, Hash] server configuration
18
+ def initialize(config)
19
+ @config = config.is_a?(Configuration) ? config : Configuration.new(config)
20
+ @config.validate!
21
+ @session_manager = SessionManager.new(@config)
22
+ @running = false
23
+ end
24
+
25
+ # Starts the MCP server (blocking)
26
+ def run
27
+ @running = true
28
+ setup_signal_handlers
29
+
30
+ server = create_mcp_server
31
+ transport = MCP::Server::Transports::StdioTransport.new(server)
32
+
33
+ log(:info, "Crucible server starting (headless: #{@config.headless})")
34
+ transport.open
35
+ rescue Interrupt
36
+ log(:info, 'Shutting down...')
37
+ ensure
38
+ shutdown
39
+ end
40
+
41
+ # Gracefully shuts down the server
42
+ def shutdown
43
+ return unless @running
44
+
45
+ @running = false
46
+ @session_manager.close_all
47
+ log(:info, 'Server stopped')
48
+ end
49
+
50
+ private
51
+
52
+ def create_mcp_server
53
+ MCP::Server.new(
54
+ name: 'crucible',
55
+ version: VERSION,
56
+ instructions: instructions_text,
57
+ tools: Tools.all(@session_manager, @config)
58
+ )
59
+ end
60
+
61
+ def instructions_text
62
+ <<~INSTRUCTIONS
63
+ Browser automation server powered by Ferrum and headless Chrome.
64
+
65
+ ## Sessions
66
+ Use the `session` parameter to manage multiple independent browser instances.
67
+ Default session is "default". Sessions persist until explicitly closed.
68
+
69
+ ## Common Workflows
70
+
71
+ ### Basic Navigation
72
+ 1. navigate(url: "https://example.com")
73
+ 2. wait_for(selector: ".content")
74
+ 3. get_content(format: "text")
75
+
76
+ ### Form Interaction
77
+ 1. navigate(url: "https://example.com/login")
78
+ 2. type(selector: "#email", text: "user@example.com")
79
+ 3. type(selector: "#password", text: "secret")
80
+ 4. click(selector: "button[type=submit]")
81
+
82
+ ### Screenshots & PDFs
83
+ - screenshot() - viewport screenshot
84
+ - screenshot(full_page: true) - full page
85
+ - screenshot(selector: ".element") - specific element
86
+ - pdf(format: "A4", landscape: true)
87
+
88
+ ### JavaScript Execution
89
+ - evaluate(expression: "document.title")
90
+ - evaluate(expression: "window.scrollTo(0, 0)")
91
+
92
+ ## Session Management
93
+ - list_sessions() - see all active sessions
94
+ - close_session(session: "name") - close specific session
95
+ - close_session(all: true) - close all sessions
96
+ INSTRUCTIONS
97
+ end
98
+
99
+ def setup_signal_handlers
100
+ %w[INT TERM].each do |signal|
101
+ Signal.trap(signal) do
102
+ # Can't call complex code (mutex) from trap context
103
+ # Just raise Interrupt to let the main thread handle cleanup
104
+ Thread.main.raise(Interrupt)
105
+ end
106
+ end
107
+ end
108
+
109
+ def log(level, message)
110
+ return if should_suppress_log?(level)
111
+
112
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
113
+ warn "[#{timestamp}] [#{level.upcase}] #{message}"
114
+ end
115
+
116
+ def should_suppress_log?(level)
117
+ levels = %i[debug info warn error]
118
+ current_level_index = levels.index(@config.error_level) || 2
119
+ message_level_index = levels.index(level) || 0
120
+ message_level_index < current_level_index
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ferrum'
4
+
5
+ module Crucible
6
+ # Manages multiple named browser sessions
7
+ #
8
+ # Thread-safe session management with lazy browser initialization.
9
+ # Each session maintains its own Ferrum::Browser instance.
10
+ # Supports per-session stealth mode settings.
11
+ #
12
+ # @example
13
+ # manager = SessionManager.new(config)
14
+ # page = manager.page("my-session")
15
+ # page.go_to("https://example.com")
16
+ # manager.close("my-session")
17
+ #
18
+ class SessionManager
19
+ # Session metadata
20
+ SessionInfo = Struct.new(:browser, :stealth, :stealth_enabled, keyword_init: true)
21
+
22
+ # @param config [Configuration] the server configuration
23
+ def initialize(config)
24
+ @config = config
25
+ @sessions = {}
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ # Gets or creates a session by name, returning the browser
30
+ # @param name [String] session name (default: "default")
31
+ # @return [Ferrum::Browser]
32
+ def get_or_create(name = 'default')
33
+ @mutex.synchronize do
34
+ @sessions[name] ||= create_session
35
+ @sessions[name].browser
36
+ end
37
+ end
38
+
39
+ # Gets an existing session by name
40
+ # @param name [String] session name
41
+ # @return [Ferrum::Browser]
42
+ # @raise [SessionNotFoundError] if session doesn't exist
43
+ def get(name)
44
+ info = @mutex.synchronize { @sessions[name] }
45
+ raise SessionNotFoundError, "Session '#{name}' not found. Use get_or_create or navigate first." unless info
46
+
47
+ info.browser
48
+ end
49
+
50
+ # Convenience method to get the page for a session
51
+ # @param name [String] session name (default: "default")
52
+ # @return [Ferrum::Page]
53
+ def page(name = 'default')
54
+ browser = get_or_create(name)
55
+ browser.page || browser.create_page
56
+ end
57
+
58
+ # Creates a new named session
59
+ # @param name [String] session name
60
+ # @return [Ferrum::Browser]
61
+ # @raise [Error] if session already exists
62
+ def create(name)
63
+ @mutex.synchronize do
64
+ raise Error, "Session '#{name}' already exists" if @sessions.key?(name)
65
+
66
+ @sessions[name] = create_session
67
+ @sessions[name].browser
68
+ end
69
+ end
70
+
71
+ # Closes a session and quits its browser
72
+ # @param name [String] session name
73
+ # @return [Boolean] true if session was closed, false if not found
74
+ def close(name)
75
+ info = @mutex.synchronize { @sessions.delete(name) }
76
+ return false unless info
77
+
78
+ info.browser.quit
79
+ true
80
+ rescue Ferrum::Error
81
+ # Browser may already be dead
82
+ true
83
+ end
84
+
85
+ # Closes all sessions
86
+ def close_all
87
+ sessions = @mutex.synchronize do
88
+ result = @sessions.values
89
+ @sessions.clear
90
+ result
91
+ end
92
+
93
+ sessions.each do |info|
94
+ info.browser.quit
95
+ rescue Ferrum::Error
96
+ # Ignore errors during shutdown
97
+ end
98
+ end
99
+
100
+ # Lists all active session names
101
+ # @return [Array<String>]
102
+ def list
103
+ @mutex.synchronize { @sessions.keys.dup }
104
+ end
105
+
106
+ # Checks if a session exists
107
+ # @param name [String] session name
108
+ # @return [Boolean]
109
+ def exists?(name)
110
+ @mutex.synchronize { @sessions.key?(name) }
111
+ end
112
+
113
+ # Returns the count of active sessions
114
+ # @return [Integer]
115
+ def count
116
+ @mutex.synchronize { @sessions.size }
117
+ end
118
+
119
+ # Enable stealth mode for a session
120
+ # @param name [String] session name
121
+ # @param profile [Symbol] stealth profile (:minimal, :moderate, :maximum)
122
+ # @return [Boolean] true if stealth was enabled
123
+ def enable_stealth(name, profile: nil)
124
+ info = @mutex.synchronize { @sessions[name] }
125
+ raise SessionNotFoundError, "Session '#{name}' not found" unless info
126
+
127
+ profile ||= @config.stealth_profile
128
+ stealth = Stealth.new(profile: profile, enabled: true, locale: @config.stealth_locale)
129
+
130
+ # Apply stealth to the browser
131
+ stealth.apply(info.browser)
132
+
133
+ # Update session metadata
134
+ @mutex.synchronize do
135
+ @sessions[name] = SessionInfo.new(
136
+ browser: info.browser,
137
+ stealth: stealth,
138
+ stealth_enabled: true
139
+ )
140
+ end
141
+
142
+ true
143
+ end
144
+
145
+ # Disable stealth mode for a session
146
+ # Note: This won't undo already-applied evasions, but prevents new ones
147
+ # @param name [String] session name
148
+ # @return [Boolean] true if stealth was disabled
149
+ def disable_stealth(name)
150
+ info = @mutex.synchronize { @sessions[name] }
151
+ raise SessionNotFoundError, "Session '#{name}' not found" unless info
152
+
153
+ @mutex.synchronize do
154
+ @sessions[name] = SessionInfo.new(
155
+ browser: info.browser,
156
+ stealth: info.stealth,
157
+ stealth_enabled: false
158
+ )
159
+ end
160
+
161
+ true
162
+ end
163
+
164
+ # Check if stealth is enabled for a session
165
+ # @param name [String] session name
166
+ # @return [Boolean]
167
+ def stealth_enabled?(name)
168
+ info = @mutex.synchronize { @sessions[name] }
169
+ return false unless info
170
+
171
+ info.stealth_enabled
172
+ end
173
+
174
+ # Get stealth info for a session
175
+ # @param name [String] session name
176
+ # @return [Hash] stealth status and profile
177
+ def stealth_info(name)
178
+ info = @mutex.synchronize { @sessions[name] }
179
+ raise SessionNotFoundError, "Session '#{name}' not found" unless info
180
+
181
+ {
182
+ enabled: info.stealth_enabled,
183
+ profile: info.stealth&.profile&.to_s
184
+ }
185
+ end
186
+
187
+ private
188
+
189
+ def create_session
190
+ browser = create_browser
191
+ stealth = @config.stealth
192
+
193
+ # Apply stealth if enabled in config
194
+ stealth.apply(browser) if @config.stealth_enabled
195
+
196
+ SessionInfo.new(
197
+ browser: browser,
198
+ stealth: stealth,
199
+ stealth_enabled: @config.stealth_enabled
200
+ )
201
+ rescue Ferrum::Error => e
202
+ raise BrowserError, "Failed to create browser: #{e.message}"
203
+ end
204
+
205
+ def create_browser
206
+ Ferrum::Browser.new(@config.browser_options)
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Evasion: chrome.app
3
+ * Mock the chrome.app object that exists in headed Chrome.
4
+ */
5
+ (function() {
6
+ 'use strict';
7
+
8
+ if (!window.chrome) {
9
+ Object.defineProperty(window, 'chrome', {
10
+ writable: true,
11
+ enumerable: true,
12
+ configurable: false,
13
+ value: {}
14
+ });
15
+ }
16
+
17
+ if ('app' in window.chrome) {
18
+ return; // Already exists
19
+ }
20
+
21
+ const makeError = {
22
+ ErrorInInvocation: fn => {
23
+ const err = new TypeError(`Error in invocation of app.${fn}()`);
24
+ return err;
25
+ }
26
+ };
27
+
28
+ const STATIC_DATA = {
29
+ isInstalled: false,
30
+ InstallState: {
31
+ DISABLED: 'disabled',
32
+ INSTALLED: 'installed',
33
+ NOT_INSTALLED: 'not_installed'
34
+ },
35
+ RunningState: {
36
+ CANNOT_RUN: 'cannot_run',
37
+ READY_TO_RUN: 'ready_to_run',
38
+ RUNNING: 'running'
39
+ }
40
+ };
41
+
42
+ window.chrome.app = {
43
+ ...STATIC_DATA,
44
+
45
+ get isInstalled() {
46
+ return false;
47
+ },
48
+
49
+ getDetails: function getDetails() {
50
+ if (arguments.length) {
51
+ throw makeError.ErrorInInvocation('getDetails');
52
+ }
53
+ return null;
54
+ },
55
+
56
+ getIsInstalled: function getIsInstalled() {
57
+ if (arguments.length) {
58
+ throw makeError.ErrorInInvocation('getIsInstalled');
59
+ }
60
+ return false;
61
+ },
62
+
63
+ runningState: function runningState() {
64
+ if (arguments.length) {
65
+ throw makeError.ErrorInInvocation('runningState');
66
+ }
67
+ return 'cannot_run';
68
+ }
69
+ };
70
+
71
+ // Patch toString for native appearance
72
+ if (window._stealthUtils) {
73
+ window._stealthUtils.patchToStringNested(window.chrome.app);
74
+ }
75
+ })();
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Evasion: chrome.csi
3
+ * Mock the chrome.csi function that exists in headed Chrome.
4
+ */
5
+ (function() {
6
+ 'use strict';
7
+
8
+ if (!window.chrome) {
9
+ Object.defineProperty(window, 'chrome', {
10
+ writable: true,
11
+ enumerable: true,
12
+ configurable: false,
13
+ value: {}
14
+ });
15
+ }
16
+
17
+ if ('csi' in window.chrome) {
18
+ return; // Already exists
19
+ }
20
+
21
+ window.chrome.csi = function csi() {
22
+ return {
23
+ onloadT: Date.now(),
24
+ startE: Date.now(),
25
+ pageT: Math.random() * 1000 + 100,
26
+ tran: 15
27
+ };
28
+ };
29
+
30
+ if (window._stealthUtils) {
31
+ window._stealthUtils.patchToString(window.chrome.csi);
32
+ }
33
+ })();
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Evasion: chrome.loadTimes
3
+ * Mock the chrome.loadTimes function that exists in headed Chrome.
4
+ */
5
+ (function() {
6
+ 'use strict';
7
+
8
+ if (!window.chrome) {
9
+ Object.defineProperty(window, 'chrome', {
10
+ writable: true,
11
+ enumerable: true,
12
+ configurable: false,
13
+ value: {}
14
+ });
15
+ }
16
+
17
+ if ('loadTimes' in window.chrome) {
18
+ return; // Already exists
19
+ }
20
+
21
+ window.chrome.loadTimes = function loadTimes() {
22
+ const now = Date.now() / 1000;
23
+ const rand = Math.random();
24
+ return {
25
+ commitLoadTime: now - rand,
26
+ connectionInfo: 'http/1.1',
27
+ finishDocumentLoadTime: now - rand + 0.1,
28
+ finishLoadTime: now - rand + 0.2,
29
+ firstPaintAfterLoadTime: 0,
30
+ firstPaintTime: now - rand + 0.05,
31
+ navigationType: 'Other',
32
+ npnNegotiatedProtocol: 'unknown',
33
+ requestTime: now - rand - 0.1,
34
+ startLoadTime: now - rand,
35
+ wasAlternateProtocolAvailable: false,
36
+ wasFetchedViaSpdy: false,
37
+ wasNpnNegotiated: false
38
+ };
39
+ };
40
+
41
+ if (window._stealthUtils) {
42
+ window._stealthUtils.patchToString(window.chrome.loadTimes);
43
+ }
44
+ })();