diff --git a/.bundle/config b/.bundle/config index 4c8cd7f74..b6ce7f0ea 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,3 +1,2 @@ --- -BUNDLE_WITHOUT: "development:test" BUNDLE_WITH: "geoip:ext_msf:ext_notifications:ext_dns:ext_qrcode" diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 000000000..2365fea36 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,82 @@ +name: JavaScript Linting + +on: + pull_request: + branches: [ master ] + +permissions: + contents: read + pull-requests: read + +jobs: + lint-changed-files: + name: Lint Changed JavaScript Files (Blocking) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Get changed JavaScript files + id: changed-js-files + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: '**/*.js' + files_ignore: | + node_modules/** + core/main/client/lib/** + **/*.min.js + extensions/admin_ui/media/javascript-min/** + separator: ' ' + + - name: Lint changed JavaScript files + if: steps.changed-js-files.outputs.any_changed == 'true' + run: | + echo "Linting ${{ steps.changed-js-files.outputs.all_changed_files_count }} changed JavaScript file(s)" + npx eslint ${{ steps.changed-js-files.outputs.all_changed_files }} + + - name: No JavaScript files changed + if: steps.changed-js-files.outputs.any_changed == 'false' + run: | + echo "No JavaScript files were changed in this PR. Skipping ESLint." + + lint-all-files: + name: Lint All JavaScript Files (Non-blocking Report) + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint all JavaScript files (report-only) + run: | + echo "Running ESLint on entire codebase" + echo "This is a report-only job and will not block the PR." + npx eslint . || true + + - name: Summary + if: always() + run: | + echo "Full codebase linting completed. Check the logs above for details." + echo "This job does not affect PR merge status." diff --git a/.gitignore b/.gitignore index 64282a1c5..c261e2701 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ beef.log test/msf-test extensions/admin_ui/media/javascript-min/ custom-config.yaml + +# Generated at runtime by Hook_default_browser#pre_send +modules/host/hook_default_browser/bounce_to_ie_configured.pdf + +# Generated at runtime by browser/spyder_eye#post_execute (and possibly other capture modules) +screenshot_*.png .DS_Store .gitignore .rvmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..ec9e05d8a --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=3 diff --git a/.rspec b/.rspec index 9619a10b3..7fe3cb532 100644 --- a/.rspec +++ b/.rspec @@ -2,3 +2,5 @@ --color --require spec_helper -I . +--tag ~run_on_browserstack +--tag ~run_on_long_tests diff --git a/.simplecov b/.simplecov new file mode 100644 index 000000000..9ecf5c4e0 --- /dev/null +++ b/.simplecov @@ -0,0 +1,32 @@ +# SimpleCov configuration file +# This provides a cleaner alternative to configuring SimpleCov in spec_helper.rb + +SimpleCov.configure do + # Basic filters + add_filter '/spec/' + add_filter '/config/' + add_filter '/test/' + + # Group coverage by component + add_group 'Core', 'core' + add_group 'Extensions', 'extensions' + add_group 'Modules', 'modules' + + # Track files based on coverage focus + if ENV['COVERAGE'] == 'core' + track_files 'core/**/*.rb' + elsif ENV['COVERAGE'] == 'extensions' + track_files 'extensions/**/*.rb' + elsif ENV['COVERAGE'] == 'modules' + track_files 'modules/**/*.rb' + else + # Default: track everything + track_files '{core,extensions,modules}/**/*.rb' + end + + # Coverage thresholds + minimum_coverage 80 if ENV['CI'] + + # Formatters + formatter SimpleCov::Formatter::HTMLFormatter +end \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index e1d833568..28f68c30b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,7 +90,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.19.4) + json (2.19.5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -171,7 +171,7 @@ GEM rack-test (2.2.0) rack (>= 1.3) rainbow (3.1.1) - rake (13.4.1) + rake (13.4.2) rdoc (7.2.0) erb psych (>= 4.0.0) diff --git a/Rakefile b/Rakefile index 6a80c1b39..9b25479d6 100644 --- a/Rakefile +++ b/Rakefile @@ -5,12 +5,82 @@ # require 'rspec/core/rake_task' -task :default => ["short"] +task default: ['short'] -RSpec::Core::RakeTask.new(:short) do |task| - task.rspec_opts = ['--tag ~run_on_browserstack', '--tag ~run_on_long_tests'] +# Run rspec with an explicit file list (avoids envs that only run 557). +# Note: when run with the full file list, module specs load after extensions; the Dns stub in +# network_spec must not run before the real Dns extension (dns_spec) or you get "superclass mismatch". +desc 'Run short spec suite (all specs except browserstack/long)' +task :short do + short_files = Dir[File.join(Dir.pwd, 'spec', '**', '*_spec.rb')].sort + $stderr.puts "[rake short] spec files=#{short_files.size}" + abort '[rake short] Expected 60+ spec files; check you are in project root.' if short_files.size < 60 + opts = [ + '--tag', '~run_on_browserstack', + '--tag', '~run_on_long_tests' + ] + ok = system('bundle', 'exec', 'rspec', *short_files, *opts) + abort 'rspec failed' unless ok end +# Legacy namespace for backward compatibility +namespace :coverage do + task :modules => 'coverage_modules' + task :core => 'coverage_core' + task :extensions => 'coverage_extensions' + task :all => 'coverage' +end + +# Base spec tasks +RSpec::Core::RakeTask.new(:spec) do |t| + t.pattern = 'spec/**/*_spec.rb' + t.rspec_opts = ['--tag', '~run_on_browserstack', '--tag', '~run_on_long_tests'] +end + +RSpec::Core::RakeTask.new(:spec_core) do |t| + t.pattern = 'spec/beef/core/**/*_spec.rb' + t.rspec_opts = ['--tag', '~run_on_browserstack', '--tag', '~run_on_long_tests'] +end + +RSpec::Core::RakeTask.new(:spec_extensions) do |t| + t.pattern = 'spec/beef/extensions/**/*_spec.rb' + t.rspec_opts = ['--tag', '~run_on_browserstack', '--tag', '~run_on_long_tests'] +end + +RSpec::Core::RakeTask.new(:spec_modules) do |t| + t.pattern = 'spec/beef/modules/**/*_spec.rb' + t.rspec_opts = ['--tag', '~run_on_browserstack', '--tag', '~run_on_long_tests'] +end + +# Coverage tasks using environment variables for cleaner configuration +desc 'Run all specs with complete coverage tracking' +task :coverage do + ENV['COVERAGE'] = 'all' + Rake::Task['spec'].invoke +end + +desc 'Run core specs with coverage' +task :coverage_core do + ENV['COVERAGE'] = 'core' + Rake::Task['spec_core'].invoke +end + +desc 'Run extensions specs with coverage' +task :coverage_extensions do + ENV['COVERAGE'] = 'extensions' + Rake::Task['spec_extensions'].invoke +end + +desc 'Run modules specs with coverage' +task :coverage_modules do + ENV['COVERAGE'] = 'modules' + Rake::Task['spec_modules'].invoke +end + +# Alias for backward compatibility +task :coverage_complete => :coverage +task :coverage_all => :coverage + RSpec::Core::RakeTask.new(:long) do |task| task.rspec_opts = ['--tag ~run_on_browserstack'] end diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..44cee5d4f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,61 @@ +"use strict"; + +const js = require("@eslint/js"); +const globals = require("globals"); + +module.exports = [ + { + ignores: [ + "core/main/client/lib/**", + "modules/**", + "node_modules/**", + "extensions/admin_ui/media/javascript-min/**", + "**/*.min.js", + ], + }, + js.configs.recommended, + { + files: ["**/*.js"], + ignores: ["eslint.config.js"], + languageOptions: { + ecmaVersion: 5, + sourceType: "script", + globals: { + ...globals.browser, + + // BeEF + beef: "readonly", + beef_init: "readonly", + + // jQuery + $: "readonly", + jQuery: "readonly", + $j: "readonly", + + // Libraries + MobileEsp: "readonly", + evercookie: "readonly", + swfobject: "readonly", + + // Browser-specific (old IE / old Firefox) + XDomainRequest: "readonly", + MozWebSocket: "readonly", + clipboardData: "readonly", + + // Debug + isDebug: "readonly", + }, + }, + }, + { + files: ["eslint.config.js"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + globals: { + module: "writable", + require: "readonly", + }, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 22ad54e99..5736d91ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "0.6.0.0", "license": "GNU General Public License v2.0", "devDependencies": { - "jsdoc": "^4.0.5", - "jsdoc-to-markdown": "^9.1.3" + "@eslint/js": "10.0.1", + "eslint": "10.4.0", + "globals": "17.6.0", + "jsdoc": "4.0.5", + "jsdoc-to-markdown": "9.1.3" } }, "node_modules/@babel/helper-string-parser": { @@ -23,10 +26,11 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", - "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -60,6 +64,200 @@ "node": ">=6.9.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jsdoc/salty": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", @@ -107,6 +305,27 @@ "node": ">= 8" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -129,6 +348,46 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -159,12 +418,35 @@ "node": ">=12.17" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -324,6 +606,21 @@ "node": ">=0.10.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/current-module-paths": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/current-module-paths/-/current-module-paths-1.1.2.tgz", @@ -334,6 +631,31 @@ "node": ">=12.17" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dmd": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/dmd/-/dmd-7.1.1.tgz", @@ -381,6 +703,191 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -397,6 +904,20 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -406,6 +927,19 @@ "reusify": "^1.0.4" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-set": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/file-set/-/file-set-5.3.0.tgz", @@ -457,6 +991,44 @@ } } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -469,6 +1041,19 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -506,6 +1091,26 @@ "node": ">=8" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -536,6 +1141,13 @@ "node": ">=0.12.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js2xmlparser": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", @@ -648,6 +1260,37 @@ } } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/klaw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", @@ -657,6 +1300,20 @@ "graceful-fs": "^4.1.9" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -666,6 +1323,22 @@ "uc.micro": "^2.0.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -746,6 +1419,22 @@ "node": ">=8.6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -767,6 +1456,20 @@ "node": ">=10" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -783,6 +1486,76 @@ "node": ">=8.0.0" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -796,6 +1569,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -867,6 +1660,29 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sort-array": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/sort-array/-/sort-array-5.1.1.tgz", @@ -956,6 +1772,19 @@ "node": ">=8.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typical": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/typical/-/typical-7.2.0.tgz", @@ -990,6 +1819,16 @@ "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", "dev": true }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/walk-back": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/walk-back/-/walk-back-5.1.1.tgz", @@ -999,6 +1838,32 @@ "node": ">=12.17" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -1019,6 +1884,19 @@ "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index b6edfce2b..083939500 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,18 @@ "version": "0.6.0.0", "description": "The Browser Exploitation Framework Project", "scripts": { - "docs": "./node_modules/.bin/jsdoc -c conf.json" + "docs": "./node_modules/.bin/jsdoc -c conf.json", + "lint": "eslint .", + "lint:fix": "eslint --fix ." }, "author": "Wade Alcorn", "license": "GNU General Public License v2.0", "devDependencies": { - "jsdoc": "^4.0.5", - "jsdoc-to-markdown": "^9.1.3" + "@eslint/js": "10.0.1", + "eslint": "10.4.0", + "globals": "17.6.0", + "jsdoc": "4.0.5", + "jsdoc-to-markdown": "9.1.3" }, "dependencies": {} -} \ No newline at end of file +} diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 000000000..2a524a6cd --- /dev/null +++ b/spec/README.md @@ -0,0 +1,168 @@ +# BeEF Test Suite + +This directory contains the BeEF test suite using RSpec and SimpleCov for comprehensive testing and coverage reporting. + +## Setup + +### Prerequisites +- Ruby 3.0+ +- Bundler +- All gems from `Gemfile` + +### Configuration Files +- `spec/spec_helper.rb` - Main test configuration +- `.simplecov` - Coverage configuration +- `spec/support/` - Test helpers and utilities + +## Running Tests + +### Basic Commands + +```bash +# Run all tests (fast, no coverage) +bundle exec rake short +# or +bundle exec rspec spec/ --tag '~run_on_browserstack' --tag '~run_on_long_tests' + +# Run specific component tests +bundle exec rspec spec/beef/core/ +bundle exec rspec spec/beef/extensions/ +bundle exec rspec spec/beef/modules/ +``` + +### Coverage Commands + +```bash +# Complete coverage (recommended) +bundle exec rake coverage +# or +COVERAGE=all bundle exec rspec spec/ --tag '~run_on_browserstack' --tag '~run_on_long_tests' + +# Component-specific coverage +bundle exec rake coverage_core # Core only +bundle exec rake coverage_modules # Modules only +bundle exec rake coverage_extensions # Extensions only +``` + +### Legacy Commands (Still Supported) + +```bash +# Old coverage commands still work +bundle exec rake coverage:modules +bundle exec rake coverage:all +bundle exec rake coverage_complete +``` + +## Architecture + +### SimpleCov Configuration + +- **Environment-based**: Uses `COVERAGE=core|modules|extensions|all` environment variable +- **Grouped reporting**: Separate groups for Core, Extensions, and Modules +- **Filtered tracking**: Only tracks relevant files based on focus area +- **HTML reports**: Generated in `coverage/` directory + +### Test Organization + +- **Centralized config**: `BeefTestConfig` module provides test data instead of global constants +- **Component isolation**: Each component (core/extensions/modules) has dedicated specs +- **Branch coverage**: Realistic test data for conditional logic testing +- **Mock management**: Proper mocking of external dependencies + +### Rake Tasks + +- **Clean separation**: Base `spec*` tasks vs coverage `coverage*` tasks +- **Environment variables**: Coverage controlled via `COVERAGE` env var +- **No sequential execution**: Single test runs with proper filtering +- **Backward compatibility**: Old task names still work + +## Key Improvements + +### ✅ **Eliminated Global Constant Collisions** +- Branch-coverage data for the dynamic module spec loaders lives in + `BeefTestConfig.branch_coverage_for(:component)` (in `spec/spec_helper.rb`). +- Each module spec file pulls its own component (`:browser`, `:exploits`, `:host`, + `:misc`, `:network`, `:social_engineering`) into a uniquely-named local + constant (e.g. `BRANCH_COVERAGE_HOST`), so loading the full suite no longer + triggers "already initialized constant" warnings or silently overwrites data. + +### ✅ **Simplified Coverage Logic** +- Cleaner filtering using `track_files` instead of complex `add_filter` logic +- Environment variable `COVERAGE` instead of `COVERAGE_FOCUS` + +### ✅ **Better Test Organization** +- Centralized test configuration in `BeefTestConfig` +- Component-specific test data loading +- Reduced code duplication + +### ✅ **Cleaner Rake Tasks** +- Single execution instead of sequential runs +- Proper environment variable usage +- Backward compatibility maintained + +### ✅ **Standard Patterns** +- Uses `.simplecov` config file (standard practice) +- Follows RSpec best practices +- Better separation of concerns + +## Module Spec Pattern (dynamic loaders) + +Specs under `spec/beef/modules/**/_spec.rb` walk every +`modules//**/module.rb` file and generate a `RSpec.describe` block per +class. They cover: + +- `.options` returns an `Array` (when defined) +- `#pre_send` runs without raising (when defined) +- `#post_execute` runs without raising (when defined) +- An extra `#post_execute` example with a realistic datastore for any module + listed in `BeefTestConfig.branch_coverage_for(:category)` + +Trade-off: this is a coverage-driving baseline. The `expect { ... }.not_to +raise_error` assertion catches load failures, missing constants, nil crashes, +and undefined methods, but it does not assert behavioural correctness. Add +targeted assertions for high-value modules (see the `Wordpress_add_user` and +`Test_get_variable` examples in `spec/beef/modules/misc/misc_spec.rb`). + +## Coverage Focus Areas + +- **core**: Framework core functionality +- **extensions**: Extension modules +- **modules**: Command modules (main focus) +- **all**: Complete coverage across all areas + +## Troubleshooting + +### Common Issues + +1. **"already initialized constant" warnings** + - Fixed by using centralized config instead of global constants + +2. **Low coverage percentages** + - Use `COVERAGE=all` for complete coverage + - Ensure realistic test data triggers conditional paths + +3. **Test failures** + - Check that mocks are properly configured + - Verify test data matches module expectations + +### Debug Commands + +```bash +# Run with debug output +bundle exec rspec --format documentation + +# Run single test file +bundle exec rspec spec/beef/modules/browser/browser_spec.rb + +# Check coverage report +open coverage/index.html +``` + +## Contributing + +When adding new tests: + +1. Use `BeefTestConfig.branch_coverage_for(:component)` for branch test data +2. Add realistic datastore values that trigger conditional logic +3. Mock external dependencies (database, network, etc.) +4. Follow existing patterns for consistency \ No newline at end of file diff --git a/spec/beef/core/filter/base_spec.rb b/spec/beef/core/filter/base_spec.rb index 6e54d0dea..d818bf9e4 100644 --- a/spec/beef/core/filter/base_spec.rb +++ b/spec/beef/core/filter/base_spec.rb @@ -307,4 +307,142 @@ RSpec.describe BeEF::Filters do end end end + + describe '.is_valid_ip?' do + it 'returns false for nil, empty, or non-string' do + expect(BeEF::Filters.is_valid_ip?(nil)).to be(false) + expect(BeEF::Filters.is_valid_ip?('')).to be(false) + end + + it 'returns true for valid IPv4' do + expect(BeEF::Filters.is_valid_ip?('127.0.0.1')).to be(true) + expect(BeEF::Filters.is_valid_ip?('192.168.1.1')).to be(true) + expect(BeEF::Filters.is_valid_ip?('10.0.0.1')).to be(true) + expect(BeEF::Filters.is_valid_ip?('0.0.0.0')).to be(true) + end + + it 'returns false for invalid IPv4' do + expect(BeEF::Filters.is_valid_ip?('256.1.1.1')).to be(false) + expect(BeEF::Filters.is_valid_ip?('1.2.3')).to be(false) + expect(BeEF::Filters.is_valid_ip?('not.an.ip')).to be(false) + end + + it 'accepts :ipv4 version' do + expect(BeEF::Filters.is_valid_ip?('127.0.0.1', :ipv4)).to be(true) + expect(BeEF::Filters.is_valid_ip?('256.1.1.1', :ipv4)).to be(false) + end + + it 'accepts :both version (default)' do + expect(BeEF::Filters.is_valid_ip?('127.0.0.1')).to be(true) + end + end + + describe '.is_valid_private_ip?' do + it 'returns false when ip is not valid' do + expect(BeEF::Filters.is_valid_private_ip?(nil)).to be(false) + expect(BeEF::Filters.is_valid_private_ip?('8.8.8.8')).to be(false) + end + + it 'returns true for 127.x (localhost)' do + expect(BeEF::Filters.is_valid_private_ip?('127.0.0.1')).to be(true) + end + + it 'returns true for 192.168.x' do + expect(BeEF::Filters.is_valid_private_ip?('192.168.1.1')).to be(true) + end + + it 'returns true for 10.x' do + expect(BeEF::Filters.is_valid_private_ip?('10.0.0.1')).to be(true) + end + + it 'returns false for public IPv4' do + expect(BeEF::Filters.is_valid_private_ip?('8.8.8.8')).to be(false) + end + end + + describe '.is_valid_port?' do + it 'returns true for valid port range' do + expect(BeEF::Filters.is_valid_port?(1)).to be(true) + expect(BeEF::Filters.is_valid_port?('80')).to be(true) + expect(BeEF::Filters.is_valid_port?(65535)).to be(true) + end + + it 'returns false for 0 or negative' do + expect(BeEF::Filters.is_valid_port?(0)).to be(false) + expect(BeEF::Filters.is_valid_port?('0')).to be(false) + end + + it 'returns false for port above 65535' do + expect(BeEF::Filters.is_valid_port?(65536)).to be(false) + end + end + + describe '.is_valid_domain?' do + it 'returns false for nil or empty' do + expect(BeEF::Filters.is_valid_domain?(nil)).to be(false) + expect(BeEF::Filters.is_valid_domain?('')).to be(false) + end + + it 'returns true for valid domain format' do + expect(BeEF::Filters.is_valid_domain?('example.com')).to be(true) + expect(BeEF::Filters.is_valid_domain?('sub.example.co.uk')).to be(true) + end + + it 'returns false for invalid domain format' do + expect(BeEF::Filters.is_valid_domain?('no-tld')).to be(false) + expect(BeEF::Filters.is_valid_domain?('.leading')).to be(false) + end + end + + describe '.has_valid_browser_details_chars?' do + it 'returns false for nil or empty' do + expect(BeEF::Filters.has_valid_browser_details_chars?(nil)).to be(false) + expect(BeEF::Filters.has_valid_browser_details_chars?('')).to be(false) + end + + it 'returns true when string only has allowed chars' do + # Method returns nil-on-no-match: nil.nil? == true, so a fully-valid string returns true. + expect(BeEF::Filters.has_valid_browser_details_chars?('abc')).to be(true) + expect(BeEF::Filters.has_valid_browser_details_chars?('a-b (c)')).to be(true) + end + + it 'returns false when string contains disallowed character' do + expect(BeEF::Filters.has_valid_browser_details_chars?('ab@c')).to be(false) + end + end + + describe '.has_valid_base_chars?' do + it 'returns false for nil or empty' do + expect(BeEF::Filters.has_valid_base_chars?(nil)).to be(false) + expect(BeEF::Filters.has_valid_base_chars?('')).to be(false) + end + + it 'returns true when string only has printable (and registered symbol)' do + expect(BeEF::Filters.has_valid_base_chars?('abc')).to be(true) + expect(BeEF::Filters.has_valid_base_chars?('Hello 123')).to be(true) + end + + it 'returns false when string has non-printable character' do + expect(BeEF::Filters.has_valid_base_chars?("ab\x00c")).to be(false) + end + end + + describe '.is_valid_yes_no?' do + it 'returns true for Yes and No (case insensitive)' do + expect(BeEF::Filters.is_valid_yes_no?('Yes')).to be(true) + expect(BeEF::Filters.is_valid_yes_no?('No')).to be(true) + expect(BeEF::Filters.is_valid_yes_no?('yes')).to be(true) + expect(BeEF::Filters.is_valid_yes_no?('no')).to be(true) + end + + it 'returns false for other values' do + expect(BeEF::Filters.is_valid_yes_no?('')).to be(false) + expect(BeEF::Filters.is_valid_yes_no?('maybe')).to be(false) + expect(BeEF::Filters.is_valid_yes_no?('1')).to be(false) + end + + it 'returns false when string has non-printable character' do + expect(BeEF::Filters.is_valid_yes_no?("Yes\x00")).to be(false) + end + end end diff --git a/spec/beef/core/filter/command_spec.rb b/spec/beef/core/filter/command_spec.rb index 1cb4dda42..c15d2759f 100644 --- a/spec/beef/core/filter/command_spec.rb +++ b/spec/beef/core/filter/command_spec.rb @@ -11,6 +11,10 @@ RSpec.describe BeEF::Filters do expect(BeEF::Filters.is_valid_path_info?("\x00")).to be(false) expect(BeEF::Filters.is_valid_path_info?(nil)).to be(false) end + + it 'returns false when argument is not a String' do + expect(BeEF::Filters.is_valid_path_info?(123)).to be(false) + end end describe '.is_valid_hook_session_id?' do @@ -43,15 +47,22 @@ RSpec.describe BeEF::Filters do end describe '.has_valid_param_chars?' do - it 'false' do + it 'returns false for nil, empty, or invalid chars' do chars = [nil, '', '+'] chars.each do |c| expect(BeEF::Filters.has_valid_param_chars?(c)).to be(false) end end - it 'true' do + it 'returns true for word, underscore, and colon' do expect(BeEF::Filters.has_valid_param_chars?('A')).to be(true) + expect(BeEF::Filters.has_valid_param_chars?('key_name')).to be(true) + expect(BeEF::Filters.has_valid_param_chars?('a:1')).to be(true) + end + + it 'returns false for string with spaces or special chars' do + expect(BeEF::Filters.has_valid_param_chars?('a b')).to be(false) + expect(BeEF::Filters.has_valid_param_chars?('a-b')).to be(false) end end end diff --git a/spec/beef/core/hbmanager_spec.rb b/spec/beef/core/hbmanager_spec.rb new file mode 100644 index 000000000..c1a00589c --- /dev/null +++ b/spec/beef/core/hbmanager_spec.rb @@ -0,0 +1,41 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::HBManager do + describe '.get_by_session' do + it 'returns the hooked browser when session exists' do + hb = BeEF::Core::Models::HookedBrowser.create!(session: 'hb_session_123', ip: '127.0.0.1') + + result = described_class.get_by_session('hb_session_123') + + expect(result).to eq(hb) + expect(result.session).to eq('hb_session_123') + end + + it 'returns nil when no hooked browser has the session' do + result = described_class.get_by_session('nonexistent_session') + + expect(result).to be_nil + end + end + + describe '.get_by_id' do + it 'returns the hooked browser when id exists' do + hb = BeEF::Core::Models::HookedBrowser.create!(session: 'hb_by_id', ip: '127.0.0.1') + + result = described_class.get_by_id(hb.id) + + expect(result).to eq(hb) + expect(result.id).to eq(hb.id) + end + + it 'raises when id does not exist' do + expect { described_class.get_by_id(999_999) }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/beef/core/main/console/banners_spec.rb b/spec/beef/core/main/console/banners_spec.rb new file mode 100644 index 000000000..e27b7cc8f --- /dev/null +++ b/spec/beef/core/main/console/banners_spec.rb @@ -0,0 +1,196 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Console::Banners do + let(:config) { BeEF::Core::Configuration.instance } + + before do + allow(described_class).to receive(:print_info) + allow(described_class).to receive(:print_more) + end + + describe '.print_welcome_msg' do + it 'calls print_info with version from config' do + allow(config).to receive(:get).with('beef.version').and_return('1.0.0') + described_class.print_welcome_msg + expect(described_class).to have_received(:print_info).with('Browser Exploitation Framework (BeEF) 1.0.0') + end + + it 'calls print_more with project links' do + allow(config).to receive(:get).with('beef.version').and_return('1.0.0') + described_class.print_welcome_msg + expect(described_class).to have_received(:print_more).with(a_string_including('@beefproject')) + expect(described_class).to have_received(:print_more).with(a_string_including('beefproject.com')) + expect(described_class).to have_received(:print_more).with(a_string_including('github.com/beefproject')) + end + + it 'calls print_info with project creator' do + allow(config).to receive(:get).with('beef.version').and_return('1.0.0') + described_class.print_welcome_msg + expect(described_class).to have_received(:print_info).with(a_string_including('Wade Alcorn')) + expect(described_class).to have_received(:print_info).with(a_string_including('@WadeAlcorn')) + end + end + + describe '.print_network_interfaces_count' do + it 'uses config local_host and sets interfaces when host is 0.0.0.0' do + allow(config).to receive(:local_host).and_return('0.0.0.0') + mock_addrs = [double('Addr', ip_address: '127.0.0.1', ipv4?: true), double('Addr', ip_address: '192.168.1.1', ipv4?: true)] + allow(Socket).to receive(:ip_address_list).and_return(mock_addrs) + described_class.print_network_interfaces_count + expect(described_class.interfaces).to eq(['127.0.0.1', '192.168.1.1']) + expect(described_class).to have_received(:print_info).with('2 network interfaces were detected.') + end + + it 'sets single interface when host is not 0.0.0.0' do + allow(config).to receive(:local_host).and_return('192.168.1.1') + described_class.print_network_interfaces_count + expect(described_class.interfaces).to eq(['192.168.1.1']) + expect(described_class).to have_received(:print_info).with('1 network interfaces were detected.') + end + end + + describe '.print_loaded_extensions' do + it 'calls print_info with count from Extensions.get_loaded' do + allow(BeEF::Extensions).to receive(:get_loaded).and_return({ 'AdminUI' => { 'name' => 'Admin UI' }, 'DNS' => { 'name' => 'DNS' } }) + described_class.print_loaded_extensions + expect(described_class).to have_received(:print_info).with('2 extensions enabled:') + expect(described_class).to have_received(:print_more).with(a_string_including('Admin UI')) + expect(described_class).to have_received(:print_more).with(a_string_including('DNS')) + end + + it 'handles empty extensions' do + allow(BeEF::Extensions).to receive(:get_loaded).and_return({}) + described_class.print_loaded_extensions + expect(described_class).to have_received(:print_info).with('0 extensions enabled:') + end + end + + describe '.print_loaded_modules' do + it 'calls print_info with count from Modules.get_enabled' do + enabled = double('Relation', count: 42) + allow(BeEF::Modules).to receive(:get_enabled).and_return(enabled) + described_class.print_loaded_modules + expect(described_class).to have_received(:print_info).with('42 modules enabled.') + end + end + + describe '.print_ascii_art' do + it 'reads and puts file content when beef.ascii exists' do + allow(File).to receive(:exist?).with('core/main/console/beef.ascii').and_return(true) + io = StringIO.new("BEEF\nASCII\n") + allow(File).to receive(:open).with('core/main/console/beef.ascii', 'r').and_yield(io) + allow(described_class).to receive(:puts) + described_class.print_ascii_art + expect(described_class).to have_received(:puts).with("BEEF\n") + expect(described_class).to have_received(:puts).with("ASCII\n") + end + + it 'does nothing when beef.ascii does not exist' do + allow(File).to receive(:exist?).with('core/main/console/beef.ascii').and_return(false) + expect(File).not_to receive(:open) + described_class.print_ascii_art + end + end + + describe '.print_network_interfaces_routes' do + before do + described_class.interfaces = ['127.0.0.1', '192.168.1.1'] + end + + it 'prints hook and UI URL for each interface when admin_ui enabled' do + allow(config).to receive(:local_proto).and_return('http') + allow(config).to receive(:hook_file_path).and_return('/hook.js') + allow(config).to receive(:get).with('beef.extension.admin_ui.enable').and_return(true) + allow(config).to receive(:get).with('beef.extension.admin_ui.base_path').and_return('/ui') + allow(config).to receive(:local_port).and_return(3000) + allow(config).to receive(:public_enabled?).and_return(false) + + described_class.print_network_interfaces_routes + + expect(described_class).to have_received(:print_info).with('running on network interface: 127.0.0.1') + expect(described_class).to have_received(:print_info).with('running on network interface: 192.168.1.1') + expect(described_class).to have_received(:print_more).with(a_string_matching(%r{Hook URL: http://127\.0\.0\.1:3000/hook\.js})) + expect(described_class).to have_received(:print_more).at_least(:twice).with(a_string_matching(%r{UI URL:.*/ui/panel})) + end + + it 'omits UI URL when admin_ui disabled' do + allow(config).to receive(:local_proto).and_return('http') + allow(config).to receive(:hook_file_path).and_return('/hook.js') + allow(config).to receive(:get).with('beef.extension.admin_ui.enable').and_return(false) + allow(config).to receive(:get).with('beef.extension.admin_ui.base_path').and_return('/ui') + allow(config).to receive(:local_port).and_return(3000) + allow(config).to receive(:public_enabled?).and_return(false) + + described_class.print_network_interfaces_routes + + expect(described_class).to have_received(:print_more).with("Hook URL: http://127.0.0.1:3000/hook.js\n") + expect(described_class).to have_received(:print_more).with("Hook URL: http://192.168.1.1:3000/hook.js\n") + end + + it 'prints public hook and UI when public_enabled?' do + allow(config).to receive(:local_proto).and_return('http') + allow(config).to receive(:hook_file_path).and_return('/hook.js') + allow(config).to receive(:get).with('beef.extension.admin_ui.enable').and_return(true) + allow(config).to receive(:get).with('beef.extension.admin_ui.base_path').and_return('/ui') + allow(config).to receive(:local_port).and_return(3000) + allow(config).to receive(:public_enabled?).and_return(true) + allow(config).to receive(:hook_url).and_return('http://public.example.com/hook.js') + allow(config).to receive(:beef_url_str).and_return('http://public.example.com') + + described_class.print_network_interfaces_routes + + expect(described_class).to have_received(:print_info).with('Public:') + expect(described_class).to have_received(:print_more).with(a_string_including('http://public.example.com/hook.js')) + expect(described_class).to have_received(:print_more).at_least(:once).with(a_string_including('/ui/panel')) + end + end + + describe '.print_websocket_servers' do + it 'prints WebSocket server line with host, port and timer' do + allow(config).to receive(:beef_host).and_return('0.0.0.0') + allow(config).to receive(:get).with('beef.http.websocket.ws_poll_timeout').and_return(5) + allow(config).to receive(:get).with('beef.http.websocket.port').and_return(61_985) + allow(config).to receive(:get).with('beef.http.websocket.secure').and_return(false) + + described_class.print_websocket_servers + + expect(described_class).to have_received(:print_info).with('Starting WebSocket server ws://0.0.0.0:61985 [timer: 5]') + end + + it 'prints WebSocketSecure server when secure enabled' do + allow(config).to receive(:beef_host).and_return('0.0.0.0') + allow(config).to receive(:get).with('beef.http.websocket.ws_poll_timeout').and_return(10) + allow(config).to receive(:get).with('beef.http.websocket.port').and_return(61_985) + allow(config).to receive(:get).with('beef.http.websocket.secure').and_return(true) + allow(config).to receive(:get).with('beef.http.websocket.secure_port').and_return(61_986) + + described_class.print_websocket_servers + + expect(described_class).to have_received(:print_info).with('Starting WebSocket server ws://0.0.0.0:61985 [timer: 10]') + expect(described_class).to have_received(:print_info).with(a_string_matching(/WebSocketSecure.*wss:.*61986.*timer: 10/)) + end + end + + describe '.print_http_proxy' do + it 'prints proxy address and port from config' do + allow(config).to receive(:get).with('beef.extension.proxy.address').and_return('127.0.0.1') + allow(config).to receive(:get).with('beef.extension.proxy.port').and_return(8080) + + described_class.print_http_proxy + + expect(described_class).to have_received(:print_info).with('HTTP Proxy: http://127.0.0.1:8080') + end + end + + describe '.print_dns' do + it 'does not raise when DNS config is not set' do + expect { described_class.print_dns }.not_to raise_error + end + end +end diff --git a/spec/beef/core/main/console/commandline_spec.rb b/spec/beef/core/main/console/commandline_spec.rb new file mode 100644 index 000000000..2804b6b30 --- /dev/null +++ b/spec/beef/core/main/console/commandline_spec.rb @@ -0,0 +1,126 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Console::CommandLine do + DEFAULT_OPTIONS = { + verbose: false, + resetdb: false, + ascii_art: false, + ext_config: '', + port: '', + ws_port: '', + update_disabled: false, + update_auto: false + }.freeze + + def reset_commandline_state + described_class.instance_variable_set(:@already_parsed, false) + described_class.instance_variable_set(:@options, DEFAULT_OPTIONS.dup) + end + + before do + reset_commandline_state + end + + describe '.parse' do + it 'returns default options when ARGV is empty' do + original_argv = ARGV.dup + ARGV.replace([]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:verbose]).to be false + expect(result[:resetdb]).to be false + expect(result[:ext_config]).to eq('') + expect(result[:port]).to eq('') + end + + it 'sets verbose when -v is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-v]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:verbose]).to be true + end + + it 'sets resetdb when -x is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-x]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:resetdb]).to be true + end + + it 'sets ascii_art when -a is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-a]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:ascii_art]).to be true + end + + it 'sets ext_config when -c FILE is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-c custom.yaml]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:ext_config]).to eq('custom.yaml') + end + + it 'sets port when -p PORT is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-p 9090]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:port]).to eq('9090') + end + + it 'sets ws_port when -w WS_PORT is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-w 61985]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:ws_port]).to eq('61985') + end + + it 'sets update_disabled when -d is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-d]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:update_disabled]).to be true + end + + it 'sets update_auto when -u is given' do + original_argv = ARGV.dup + ARGV.replace(%w[-u]) + result = described_class.parse + ARGV.replace(original_argv) + expect(result[:update_auto]).to be true + end + + it 'returns cached options on second parse' do + original_argv = ARGV.dup + ARGV.replace([]) + first = described_class.parse + ARGV.replace(%w[-v -x]) + second = described_class.parse + ARGV.replace(original_argv) + expect(second).to eq(first) + expect(second[:verbose]).to be false + end + + it 'prints and exits on invalid option' do + original_argv = ARGV.dup + ARGV.replace(%w[--invalid-option]) + allow(Kernel).to receive(:puts).with(/Invalid command line option/) + allow(Kernel).to receive(:exit).with(1) { raise SystemExit.new(1) } + expect { described_class.parse }.to raise_error(SystemExit) + ARGV.replace(original_argv) + end + end +end diff --git a/spec/beef/core/main/constants/browsers_spec.rb b/spec/beef/core/main/constants/browsers_spec.rb new file mode 100644 index 000000000..c28ee66a8 --- /dev/null +++ b/spec/beef/core/main/constants/browsers_spec.rb @@ -0,0 +1,60 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Constants::Browsers do + describe 'constants' do + it 'defines short browser codes' do + expect(described_class::FF).to eq('FF') + expect(described_class::C).to eq('C') + expect(described_class::IE).to eq('IE') + expect(described_class::S).to eq('S') + expect(described_class::ALL).to eq('ALL') + expect(described_class::UNKNOWN).to eq('UN') + end + + it 'defines friendly names' do + expect(described_class::FRIENDLY_FF_NAME).to eq('Firefox') + expect(described_class::FRIENDLY_C_NAME).to eq('Chrome') + expect(described_class::FRIENDLY_UN_NAME).to eq('UNKNOWN') + end + end + + describe '.friendly_name' do + it 'returns Firefox for FF' do + expect(described_class.friendly_name(described_class::FF)).to eq('Firefox') + end + + it 'returns Chrome for C' do + expect(described_class.friendly_name(described_class::C)).to eq('Chrome') + end + + it 'returns Internet Explorer for IE' do + expect(described_class.friendly_name(described_class::IE)).to eq('Internet Explorer') + end + + it 'returns Safari for S' do + expect(described_class.friendly_name(described_class::S)).to eq('Safari') + end + + it 'returns MSEdge for E' do + expect(described_class.friendly_name(described_class::E)).to eq('MSEdge') + end + + it 'returns UNKNOWN for UN' do + expect(described_class.friendly_name(described_class::UNKNOWN)).to eq('UNKNOWN') + end + + it 'returns nil for unknown browser code' do + expect(described_class.friendly_name('XX')).to be_nil + end + + it 'returns nil for nil' do + expect(described_class.friendly_name(nil)).to be_nil + end + end +end diff --git a/spec/beef/core/main/constants/commandmodule_spec.rb b/spec/beef/core/main/constants/commandmodule_spec.rb new file mode 100644 index 000000000..eb94fc00a --- /dev/null +++ b/spec/beef/core/main/constants/commandmodule_spec.rb @@ -0,0 +1,18 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Constants::CommandModule do + describe 'constants' do + it 'defines verified working status values' do + expect(described_class::VERIFIED_WORKING).to eq(0) + expect(described_class::VERIFIED_UNKNOWN).to eq(1) + expect(described_class::VERIFIED_USER_NOTIFY).to eq(2) + expect(described_class::VERIFIED_NOT_WORKING).to eq(3) + end + end +end diff --git a/spec/beef/core/main/constants/hardware_spec.rb b/spec/beef/core/main/constants/hardware_spec.rb new file mode 100644 index 000000000..1846d0c4c --- /dev/null +++ b/spec/beef/core/main/constants/hardware_spec.rb @@ -0,0 +1,69 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Constants::Hardware do + describe 'constants' do + it 'defines hardware UA strings and image paths' do + expect(described_class::HW_IPHONE_UA_STR).to eq('iPhone') + expect(described_class::HW_IPAD_UA_STR).to eq('iPad') + expect(described_class::HW_BLACKBERRY_UA_STR).to eq('BlackBerry') + expect(described_class::HW_ALL_UA_STR).to eq('All') + expect(described_class::HW_UNKNOWN_IMG).to eq('pc.png') + end + end + + describe '.match_hardware' do + it 'returns iPhone for iphone-like strings' do + expect(described_class.match_hardware('iPhone')).to eq('iPhone') + expect(described_class.match_hardware('iPhone OS')).to eq('iPhone') + end + + it 'returns iPad for ipad-like strings' do + expect(described_class.match_hardware('iPad')).to eq('iPad') + end + + it 'returns iPod for ipod-like strings' do + expect(described_class.match_hardware('iPod')).to eq('iPod') + end + + it 'returns BlackBerry for blackberry-like strings' do + expect(described_class.match_hardware('BlackBerry')).to eq('BlackBerry') + end + + it 'returns Windows Phone for windows phone-like strings' do + expect(described_class.match_hardware('Windows Phone')).to eq('Windows Phone') + end + + it 'returns Kindle for kindle-like strings' do + expect(described_class.match_hardware('Kindle')).to eq('Kindle') + end + + it 'returns Nokia for nokia-like strings' do + expect(described_class.match_hardware('Nokia')).to eq('Nokia') + end + + it 'returns HTC for htc-like strings' do + expect(described_class.match_hardware('HTC')).to eq('HTC') + end + + it 'returns Nexus for google-like strings' do + expect(described_class.match_hardware('Google Nexus')).to eq('Nexus') + end + + it 'is case insensitive' do + expect(described_class.match_hardware('IPHONE')).to eq('iPhone') + expect(described_class.match_hardware('ipad')).to eq('iPad') + expect(described_class.match_hardware('BLACKBERRY')).to eq('BlackBerry') + end + + it 'returns ALL for unknown hardware strings' do + expect(described_class.match_hardware('UnknownDevice')).to eq('ALL') + expect(described_class.match_hardware('')).to eq('ALL') + end + end +end diff --git a/spec/beef/core/main/constants/os_spec.rb b/spec/beef/core/main/constants/os_spec.rb new file mode 100644 index 000000000..5cba824e9 --- /dev/null +++ b/spec/beef/core/main/constants/os_spec.rb @@ -0,0 +1,73 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Constants::Os do + describe 'constants' do + it 'defines OS UA strings and image paths' do + expect(described_class::OS_WINDOWS_UA_STR).to eq('Windows') + expect(described_class::OS_LINUX_UA_STR).to eq('Linux') + expect(described_class::OS_MAC_UA_STR).to eq('Mac') + expect(described_class::OS_ANDROID_UA_STR).to eq('Android') + expect(described_class::OS_ALL_UA_STR).to eq('All') + expect(described_class::OS_UNKNOWN_IMG).to eq('unknown.png') + end + end + + describe '.match_os' do + it 'returns Windows for win-like strings' do + expect(described_class.match_os('Windows')).to eq('Windows') + expect(described_class.match_os('Windows NT')).to eq('Windows') + expect(described_class.match_os('Win32')).to eq('Windows') + end + + it 'returns Linux for lin-like strings' do + expect(described_class.match_os('Linux')).to eq('Linux') + expect(described_class.match_os('Lin')).to eq('Linux') + end + + it 'returns Mac for os x, osx, mac-like strings' do + expect(described_class.match_os('Mac OS X')).to eq('Mac') + expect(described_class.match_os('OSX')).to eq('Mac') + expect(described_class.match_os('Macintosh')).to eq('Mac') + end + + it 'returns iOS for iphone, ipad, ipod' do + expect(described_class.match_os('iPhone')).to eq('iOS') + expect(described_class.match_os('iPad')).to eq('iOS') + expect(described_class.match_os('iPod')).to eq('iOS') + expect(described_class.match_os('iOS')).to eq('iOS') + end + + it 'returns Android for android-like strings' do + expect(described_class.match_os('Android')).to eq('Android') + end + + it 'returns BlackBerry for blackberry-like strings' do + expect(described_class.match_os('BlackBerry')).to eq('BlackBerry') + end + + it 'returns QNX for qnx-like strings' do + expect(described_class.match_os('QNX')).to eq('QNX') + end + + it 'returns SunOS for sun-like strings' do + expect(described_class.match_os('SunOS')).to eq('SunOS') + end + + it 'is case insensitive' do + expect(described_class.match_os('WINDOWS')).to eq('Windows') + expect(described_class.match_os('linux')).to eq('Linux') + expect(described_class.match_os('ANDROID')).to eq('Android') + end + + it 'returns ALL for unknown OS strings' do + expect(described_class.match_os('UnknownOS')).to eq('ALL') + expect(described_class.match_os('')).to eq('ALL') + end + end +end diff --git a/spec/beef/core/main/crypto_spec.rb b/spec/beef/core/main/crypto_spec.rb index bfe4ccfbf..048d75c3e 100644 --- a/spec/beef/core/main/crypto_spec.rb +++ b/spec/beef/core/main/crypto_spec.rb @@ -59,6 +59,7 @@ RSpec.describe 'BeEF::Core::Crypto' do it 'raises TypeError for invalid inputs' do expect { BeEF::Core::Crypto.random_hex_string('invalid') }.to raise_error(TypeError) expect { BeEF::Core::Crypto.random_hex_string(0) }.to raise_error(TypeError, /Invalid length/) + expect { BeEF::Core::Crypto.random_hex_string(-1) }.to raise_error(TypeError, /Invalid length/) end end diff --git a/spec/beef/core/main/handlers/browserdetails_spec.rb b/spec/beef/core/main/handlers/browserdetails_spec.rb index 637dc0e83..92a761942 100644 --- a/spec/beef/core/main/handlers/browserdetails_spec.rb +++ b/spec/beef/core/main/handlers/browserdetails_spec.rb @@ -412,5 +412,158 @@ RSpec.describe BeEF::Core::Handlers::BrowserDetails do expect(BeEF::Core::Models::BrowserDetails).to receive(:set).with(session_id, 'network.proxy.server', 'proxy.example.com') described_class.new(proxy_data) end + + context 'filter failures (err_msg branches)' do + # Full data with optional keys so later filters (e.g. battery, capabilities) don't trigger err_msg + let(:full_data) do + data.merge('results' => data['results'].merge( + 'hardware.battery.level' => '50%', + 'browser.name.reported' => 'Mozilla/5.0', + 'browser.engine' => 'Gecko', + 'browser.window.cookies' => 'session=abc', + 'host.os.name' => 'Windows', + 'host.os.family' => 'Windows', + 'host.os.version' => '10', + 'browser.capabilities.vbscript' => 'yes' + )) + end + + def stub_all_filters_valid_except(except_key = nil) + %i[ + is_valid_hook_session_id? is_valid_browsername? is_valid_browserversion? is_valid_ip? + is_valid_browserstring? is_valid_cookies? is_valid_osname? is_valid_hwname? + is_valid_date_stamp? is_valid_pagetitle? is_valid_url? is_valid_pagereferrer? + is_valid_hostname? is_valid_port? is_valid_browser_plugins? is_valid_system_platform? + nums_only? is_valid_yes_no? is_valid_memory? is_valid_gpu? is_valid_cpu? alphanums_only? + ].each do |m| + allow(BeEF::Filters).to receive(m).and_return(except_key == m ? false : true) + end + end + + it 'calls err_msg when browser name is invalid' do + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + stub_all_filters_valid_except(:is_valid_browsername?) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name) + zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1') + allow(zombie).to receive(:firstseen=) + allow(zombie).to receive(:domain=) + allow(zombie).to receive(:port=) + allow(zombie).to receive(:httpheaders=) + allow(zombie).to receive(:httpheaders).and_return('{}') + allow(zombie).to receive(:save!) + allow(JSON).to receive(:parse).with('{}').and_return({}) + allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie) + err_msg_calls = [] + allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last } + described_class.new(full_data) + expect(err_msg_calls).to include(a_string_matching(/Invalid browser name/)) + end + + it 'calls err_msg when IP is invalid' do + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + stub_all_filters_valid_except(:is_valid_ip?) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox') + zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1') + allow(zombie).to receive(:firstseen=) + allow(zombie).to receive(:domain=) + allow(zombie).to receive(:port=) + allow(zombie).to receive(:httpheaders=) + allow(zombie).to receive(:httpheaders).and_return('{}') + allow(zombie).to receive(:save!) + allow(JSON).to receive(:parse).with('{}').and_return({}) + allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie) + err_msg_calls = [] + allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last } + described_class.new(full_data) + expect(err_msg_calls).to include(a_string_matching(/Invalid IP address/)) + end + + it 'calls err_msg when browser version is invalid' do + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + stub_all_filters_valid_except(:is_valid_browserversion?) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox') + zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1') + allow(zombie).to receive(:firstseen=) + allow(zombie).to receive(:domain=) + allow(zombie).to receive(:port=) + allow(zombie).to receive(:httpheaders=) + allow(zombie).to receive(:httpheaders).and_return('{}') + allow(zombie).to receive(:save!) + allow(JSON).to receive(:parse).with('{}').and_return({}) + allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie) + err_msg_calls = [] + allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last } + described_class.new(full_data) + expect(err_msg_calls).to include(a_string_matching(/Invalid browser version/)) + end + + it 'calls err_msg when browser.name.reported is invalid' do + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + allow(BeEF::Filters).to receive(:is_valid_browsername?).and_return(true) + allow(BeEF::Filters).to receive(:is_valid_browserversion?).and_return(true) + allow(BeEF::Filters).to receive(:is_valid_ip?).and_return(true) + allow(BeEF::Filters).to receive(:is_valid_browserstring?).and_return(false) + stub_all_filters_valid_except(nil) + allow(BeEF::Filters).to receive(:is_valid_browserstring?).and_return(false) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox') + zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1') + allow(zombie).to receive(:firstseen=) + allow(zombie).to receive(:domain=) + allow(zombie).to receive(:port=) + allow(zombie).to receive(:httpheaders=) + allow(zombie).to receive(:httpheaders).and_return('{}') + allow(zombie).to receive(:save!) + allow(JSON).to receive(:parse).with('{}').and_return({}) + allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie) + err_msg_calls = [] + allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last } + described_class.new(full_data) + expect(err_msg_calls).to include(a_string_matching(/browser\.name\.reported/)) + end + + it 'calls err_msg when cookies are invalid' do + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + stub_all_filters_valid_except(:is_valid_cookies?) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox') + zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1') + allow(zombie).to receive(:firstseen=) + allow(zombie).to receive(:domain=) + allow(zombie).to receive(:port=) + allow(zombie).to receive(:httpheaders=) + allow(zombie).to receive(:httpheaders).and_return('{}') + allow(zombie).to receive(:save!) + allow(JSON).to receive(:parse).with('{}').and_return({}) + allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie) + err_msg_calls = [] + allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last } + described_class.new(full_data) + expect(err_msg_calls).to include(a_string_matching(/Invalid cookies/)) + end + + it 'calls err_msg when host.os.name is invalid' do + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + stub_all_filters_valid_except(:is_valid_osname?) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + allow(BeEF::Core::Constants::Browsers).to receive(:friendly_name).and_return('Firefox') + zombie = double('HookedBrowser', id: 1, ip: '127.0.0.1') + allow(zombie).to receive(:firstseen=) + allow(zombie).to receive(:domain=) + allow(zombie).to receive(:port=) + allow(zombie).to receive(:httpheaders=) + allow(zombie).to receive(:httpheaders).and_return('{}') + allow(zombie).to receive(:save!) + allow(JSON).to receive(:parse).with('{}').and_return({}) + allow(BeEF::Core::Models::HookedBrowser).to receive(:new).and_return(zombie) + err_msg_calls = [] + allow_any_instance_of(described_class).to receive(:err_msg) { |*args| err_msg_calls << args.last } + described_class.new(full_data) + expect(err_msg_calls).to include(a_string_matching(/operating system name/)) + end + end end end diff --git a/spec/beef/core/main/handlers/hookedbrowsers_spec.rb b/spec/beef/core/main/handlers/hookedbrowsers_spec.rb index 595b7f9e8..d3953f6c8 100644 --- a/spec/beef/core/main/handlers/hookedbrowsers_spec.rb +++ b/spec/beef/core/main/handlers/hookedbrowsers_spec.rb @@ -4,37 +4,160 @@ # See the file 'doc/COPYING' for copying permission # +require 'spec_helper' + RSpec.describe BeEF::Core::Handlers::HookedBrowsers do - # Test the confirm_browser_user_agent logic directly - describe 'confirm_browser_user_agent logic' do - it 'matches legacy browser user agents' do - allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return(['IE 8.0']) - - # Test the logic: browser_type = user_agent.split(' ').last - user_agent = 'Mozilla/5.0 IE 8.0' - browser_type = user_agent.split(' ').last - - # Test the matching logic - matched = false - BeEF::Core::Models::LegacyBrowserUserAgents.user_agents.each do |ua_string| - matched = true if ua_string.include?(browser_type) - end - - expect(matched).to be true + # .new returns Sinatra::Wrapper; use allocate to get the real class instance for unit testing + let(:handler) { described_class.allocate } + + describe "GET '/'" do + let(:config) { BeEF::Core::Configuration.instance } + # Use a host permitted by Router's host_authorization (.localhost, .test, or config public host) + let(:rack_env) { { 'REMOTE_ADDR' => '192.168.1.1', 'HTTP_HOST' => 'localhost' } } + + def app + described_class end - it 'does not match non-legacy browser user agents' do - allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return([]) - - user_agent = 'Chrome/91.0' - browser_type = user_agent.split(' ').last - - matched = false - BeEF::Core::Models::LegacyBrowserUserAgents.user_agents.each do |ua_string| - matched = true if ua_string.include?(browser_type) + before do + allow(BeEF::Core::Logger.instance).to receive(:register) + allow(config).to receive(:get).and_call_original + allow(config).to receive(:get).with('beef.http.restful_api.allow_cors').and_return(false) + end + + it 'returns 404 when permitted_hooking_subnet is nil' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(nil) + get '/', {}, rack_env + expect(last_response.status).to eq(404) + end + + it 'returns 404 when permitted_hooking_subnet is empty' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return([]) + get '/', {}, rack_env + expect(last_response.status).to eq(404) + end + + it 'returns 404 when client IP is not in permitted subnet' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['10.0.0.0/8']) + get '/', {}, rack_env + expect(last_response.status).to eq(404) + end + + it 'returns 404 when client IP is in excluded_hooking_subnet' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['0.0.0.0/0']) + allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return(['192.168.1.0/24']) + get '/', {}, rack_env + expect(last_response.status).to eq(404) + end + + it 'returns 200 and hook body when IP permitted, not excluded, no session (new browser)' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['192.168.0.0/16']) + allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([]) + allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook') + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + allow(BeEF::Filters).to receive(:is_valid_hostname?).with('localhost').and_return(true) + allow(config).to receive(:get).with('beef.http.websocket.enable').and_return(false) + allow_any_instance_of(described_class).to receive(:confirm_browser_user_agent).and_return(false) + allow_any_instance_of(described_class).to receive(:legacy_build_beefjs!).with('localhost') + get '/', {}, rack_env + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to include('javascript') + end + + it 'uses multi_stage_beefjs when websocket disabled and confirm_browser_user_agent true' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['0.0.0.0/0']) + allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([]) + allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook') + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + allow(BeEF::Filters).to receive(:is_valid_hostname?).with('localhost').and_return(true) + allow(config).to receive(:get).with('beef.http.websocket.enable').and_return(false) + allow_any_instance_of(described_class).to receive(:confirm_browser_user_agent).and_return(true) + allow_any_instance_of(described_class).to receive(:multi_stage_beefjs!).with('localhost') + get '/', {}, { 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_HOST' => 'localhost' } + expect(last_response.status).to eq(200) + end + + it 'returns early with empty body when hostname is invalid' do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['0.0.0.0/0']) + allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([]) + allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook') + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).and_return([]) + # Use permitted host so request reaches handler; stub hostname validation to fail + allow(BeEF::Filters).to receive(:is_valid_hostname?).with('localhost').and_return(false) + get '/', {}, { 'REMOTE_ADDR' => '127.0.0.1', 'HTTP_HOST' => 'localhost' } + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('') + end + + context 'when session exists (existing browser path)' do + let(:hooked_browser) do + double('HookedBrowser', + id: 1, + ip: '192.168.1.1', + lastseen: Time.new.to_i - 120, + session: 'existing_session', + count!: nil, + save!: true).tap do |d| + allow(d).to receive(:lastseen=) + allow(d).to receive(:ip=) + end end - - expect(matched).to be false + + before do + allow(config).to receive(:get).with('beef.restrictions.permitted_hooking_subnet').and_return(['192.168.0.0/16']) + allow(config).to receive(:get).with('beef.restrictions.excluded_hooking_subnet').and_return([]) + allow(config).to receive(:get).with('beef.http.hook_session_name').and_return('beefhook') + allow(config).to receive(:get).with('beef.http.allow_reverse_proxy').and_return(false) + relation = double('Relation', first: hooked_browser) + allow(BeEF::Core::Models::HookedBrowser).to receive(:where).with(session: 'existing_session').and_return(relation) + allow(BeEF::Core::Models::Command).to receive(:where).with(hooked_browser_id: 1, instructions_sent: false).and_return([]) + allow(BeEF::API::Registrar.instance).to receive(:fire) + end + + it 'returns 200 and updates lastseen' do + get '/', { 'beefhook' => 'existing_session' }, rack_env + expect(last_response.status).to eq(200) + expect(hooked_browser).to have_received(:save!) + end + + it 'logs zombie comeback when lastseen was more than 60 seconds ago' do + get '/', { 'beefhook' => 'existing_session' }, rack_env + expect(BeEF::Core::Logger.instance).to have_received(:register).with('Zombie', /appears to have come back online/, '1') + end + + it 'calls add_command_instructions for each pending command' do + command = double('Command', id: 1, command_module_id: 1) + allow(BeEF::Core::Models::Command).to receive(:where).with(hooked_browser_id: 1, instructions_sent: false).and_return([command]) + expect_any_instance_of(described_class).to receive(:add_command_instructions).with(command, hooked_browser) + get '/', { 'beefhook' => 'existing_session' }, rack_env + end + end + end + + describe '#confirm_browser_user_agent' do + it 'returns true when user_agent suffix matches a legacy UA string' do + allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return(['IE 8.0']) + + # browser_type = user_agent.split(' ').last => '8.0'; 'IE 8.0'.include?('8.0') => true + expect(handler.confirm_browser_user_agent('Mozilla/5.0 IE 8.0')).to be true + end + + it 'returns true when first legacy UA matches' do + allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return(['IE 8.0', 'Firefox/3.6']) + + expect(handler.confirm_browser_user_agent('Mozilla/5.0 IE 8.0')).to be true + end + + it 'returns false when no legacy UA includes the browser type' do + allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return([]) + + expect(handler.confirm_browser_user_agent('Mozilla/5.0 Chrome/91.0')).to be false + end + + it 'returns false when legacy list has entries but none match' do + allow(BeEF::Core::Models::LegacyBrowserUserAgents).to receive(:user_agents).and_return(['IE 8.0']) + + expect(handler.confirm_browser_user_agent('Chrome/91.0')).to be false end end end diff --git a/spec/beef/core/main/logger_spec.rb b/spec/beef/core/main/logger_spec.rb new file mode 100644 index 000000000..5bb6a3c8c --- /dev/null +++ b/spec/beef/core/main/logger_spec.rb @@ -0,0 +1,75 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Logger do + let(:logger) { described_class.instance } + let(:log_double) { instance_double(BeEF::Core::Models::Log, save!: true) } + + before do + allow(BeEF::Core::Models::Log).to receive(:create).and_return(log_double) + allow(logger).to receive(:print_debug) + logger.instance_variable_set(:@notifications, nil) + end + + describe '#register' do + it 'creates a log entry with from, event, and hooked_browser_id' do + result = logger.register('Authentication', 'User logged in', 0) + + expect(result).to be true + expect(BeEF::Core::Models::Log).to have_received(:create).with( + hash_including( + logtype: 'Authentication', + event: 'User logged in', + hooked_browser_id: 0 + ) + ) + expect(log_double).to have_received(:save!) + end + + it 'converts hb to integer' do + logger.register('From', 'Event', '42') + + expect(BeEF::Core::Models::Log).to have_received(:create).with( + hash_including(hooked_browser_id: 42) + ) + end + + it 'defaults hb to 0 when not provided' do + logger.register('From', 'Event') + + expect(BeEF::Core::Models::Log).to have_received(:create).with( + hash_including(hooked_browser_id: 0) + ) + end + + it 'raises TypeError when from is not a String' do + expect { logger.register(123, 'Event', 0) }.to raise_error( + TypeError, "'from' is Integer; expected String" + ) + end + + it 'raises TypeError when event is not a String' do + expect { logger.register('From', nil, 0) }.to raise_error( + TypeError, "'event' is NilClass; expected String" + ) + end + + it 'calls notifications when extension is enabled' do + notifications_double = double('Notifications') + logger.instance_variable_set(:@notifications, notifications_double) + allow(notifications_double).to receive(:new) + logger.register('Zombie', 'Browser hooked', 7) + expect(notifications_double).to have_received(:new).with( + 'Zombie', + 'Browser hooked', + kind_of(Time), + 7 + ) + end + end +end diff --git a/spec/beef/core/main/models/command_spec.rb b/spec/beef/core/main/models/command_spec.rb new file mode 100644 index 000000000..0a6d75d03 --- /dev/null +++ b/spec/beef/core/main/models/command_spec.rb @@ -0,0 +1,123 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Models::Command do + describe 'associations' do + it 'has_many results' do + expect(described_class.reflect_on_association(:results)).not_to be_nil + expect(described_class.reflect_on_association(:results).macro).to eq(:has_many) + end + + it 'has_one command_module' do + expect(described_class.reflect_on_association(:command_module)).not_to be_nil + expect(described_class.reflect_on_association(:command_module).macro).to eq(:has_one) + end + + it 'has_one hooked_browser' do + expect(described_class.reflect_on_association(:hooked_browser)).not_to be_nil + expect(described_class.reflect_on_association(:hooked_browser).macro).to eq(:has_one) + end + end + + describe '.show_status' do + it 'returns ERROR for status -1' do + expect(described_class.show_status(-1)).to eq('ERROR') + end + + it 'returns SUCCESS for status 1' do + expect(described_class.show_status(1)).to eq('SUCCESS') + end + + it 'returns UNKNOWN for status 0' do + expect(described_class.show_status(0)).to eq('UNKNOWN') + end + + it 'returns UNKNOWN for any other status' do + expect(described_class.show_status(2)).to eq('UNKNOWN') + expect(described_class.show_status(99)).to eq('UNKNOWN') + end + end + + describe '.save_result' do + let(:hooked_browser) { BeEF::Core::Models::HookedBrowser.create!(session: 'cmd_save_session', ip: '127.0.0.1') } + let(:command_module) { BeEF::Core::Models::CommandModule.create!(name: 'cmd_save_mod', path: 'modules/test/') } + let(:command) do + described_class.create!( + hooked_browser_id: hooked_browser.id, + command_module_id: command_module.id + ) + end + + before do + allow(BeEF::Core::Logger.instance).to receive(:register) + allow(described_class).to receive(:print_info) + end + + it 'creates a Result and returns true when all args are valid' do + result = described_class.save_result( + 'cmd_save_session', + command.id, + 'Friendly Name', + { 'output' => 'data' }, + 1 + ) + + expect(result).to be true + created = BeEF::Core::Models::Result.last + expect(created).not_to be_nil + expect(created.command_id).to eq(command.id) + expect(created.hooked_browser_id).to eq(hooked_browser.id) + expect(created.status).to eq(1) + expect(JSON.parse(created.data)).to eq({ 'output' => 'data' }) + end + + it 'raises TypeError when hook_session_id is not a String' do + expect do + described_class.save_result(123, command.id, 'Name', {}, 1) + end.to raise_error(TypeError, '"hook_session_id" needs to be a string') + end + + it 'raises TypeError when command_id is not an Integer' do + expect do + described_class.save_result('cmd_save_session', '1', 'Name', {}, 1) + end.to raise_error(TypeError, '"command_id" needs to be an integer') + end + + it 'raises TypeError when command_friendly_name is not a String' do + expect do + described_class.save_result('cmd_save_session', command.id, 123, {}, 1) + end.to raise_error(TypeError, '"command_friendly_name" needs to be a string') + end + + it 'raises TypeError when result is not a Hash' do + expect do + described_class.save_result('cmd_save_session', command.id, 'Name', 'string', 1) + end.to raise_error(TypeError, '"result" needs to be a hash') + end + + it 'raises TypeError when status is not an Integer' do + expect do + described_class.save_result('cmd_save_session', command.id, 'Name', {}, '1') + end.to raise_error(TypeError, '"status" needs to be an integer') + end + + it 'raises TypeError when hooked_browser is not found for session' do + expect do + described_class.save_result('nonexistent_session', command.id, 'Name', {}, 1) + end.to raise_error(TypeError, 'hooked_browser is nil') + end + + it 'raises TypeError when command is not found for id and hooked_browser' do + BeEF::Core::Models::HookedBrowser.create!(session: 'other_session', ip: '127.0.0.1') + + expect do + described_class.save_result('other_session', command.id, 'Name', {}, 1) + end.to raise_error(TypeError, 'command is nil') + end + end +end diff --git a/spec/beef/core/main/models/commandmodule_spec.rb b/spec/beef/core/main/models/commandmodule_spec.rb new file mode 100644 index 000000000..96a1e272a --- /dev/null +++ b/spec/beef/core/main/models/commandmodule_spec.rb @@ -0,0 +1,26 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Models::CommandModule do + describe 'associations' do + it 'has_many commands' do + expect(described_class.reflect_on_association(:commands)).not_to be_nil + expect(described_class.reflect_on_association(:commands).macro).to eq(:has_many) + end + end + + describe '.create' do + it 'creates a command module with name and path' do + mod = described_class.create!(name: 'test_module', path: 'modules/test/') + + expect(mod).to be_persisted + expect(mod.name).to eq('test_module') + expect(mod.path).to eq('modules/test/') + end + end +end diff --git a/spec/beef/core/main/models/hookedbrowser_spec.rb b/spec/beef/core/main/models/hookedbrowser_spec.rb new file mode 100644 index 000000000..eeef52924 --- /dev/null +++ b/spec/beef/core/main/models/hookedbrowser_spec.rb @@ -0,0 +1,44 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Models::HookedBrowser do + describe 'associations' do + it 'has_many commands' do + expect(described_class.reflect_on_association(:commands)).not_to be_nil + expect(described_class.reflect_on_association(:commands).macro).to eq(:has_many) + end + + it 'has_many results' do + expect(described_class.reflect_on_association(:results)).not_to be_nil + expect(described_class.reflect_on_association(:results).macro).to eq(:has_many) + end + + it 'has_many logs' do + expect(described_class.reflect_on_association(:logs)).not_to be_nil + expect(described_class.reflect_on_association(:logs).macro).to eq(:has_many) + end + end + + describe '#count!' do + it 'sets count to 1 when count is nil' do + hb = described_class.create!(session: 'count_nil', ip: '127.0.0.1', count: nil) + + hb.count! + + expect(hb.count).to eq(1) + end + + it 'increments count when count is already set' do + hb = described_class.create!(session: 'count_set', ip: '127.0.0.1', count: 3) + + hb.count! + + expect(hb.count).to eq(4) + end + end +end diff --git a/spec/beef/core/main/models/log_spec.rb b/spec/beef/core/main/models/log_spec.rb new file mode 100644 index 000000000..9c2750ceb --- /dev/null +++ b/spec/beef/core/main/models/log_spec.rb @@ -0,0 +1,42 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# + +require 'spec_helper' + +RSpec.describe BeEF::Core::Models::Log do + describe 'associations' do + it 'has_one hooked_browser' do + expect(described_class.reflect_on_association(:hooked_browser)).not_to be_nil + expect(described_class.reflect_on_association(:hooked_browser).macro).to eq(:has_one) + end + end + + describe '.create' do + it 'creates a log with logtype, event, and date' do + log = described_class.create!( + logtype: 'TestSource', + event: 'Test event message', + date: Time.now + ) + + expect(log).to be_persisted + expect(log.logtype).to eq('TestSource') + expect(log.event).to eq('Test event message') + end + + it 'can store hooked_browser_id' do + hb = BeEF::Core::Models::HookedBrowser.create!(session: 'log_hb', ip: '127.0.0.1') + log = described_class.create!( + logtype: 'Hook', + event: 'Browser hooked', + date: Time.now, + hooked_browser_id: hb.id + ) + + expect(log.hooked_browser_id).to eq(hb.id) + end + end +end diff --git a/spec/beef/core/main/server_spec.rb b/spec/beef/core/main/server_spec.rb index b694c9bb1..b19ddcf53 100644 --- a/spec/beef/core/main/server_spec.rb +++ b/spec/beef/core/main/server_spec.rb @@ -110,4 +110,68 @@ RSpec.describe BeEF::Core::Server do server.remap end end + + describe '#prepare' do + before do + allow(BeEF::API::Registrar.instance).to receive(:fire).with(BeEF::API::Server, 'mount_handler', server) + allow(config).to receive(:get).and_return(nil) + allow(config).to receive(:get).with('beef.http.hook_file').and_return('/hook.js') + allow(config).to receive(:get).with('beef.http.host').and_return('0.0.0.0') + allow(config).to receive(:get).with('beef.http.port').and_return('3000') + allow(config).to receive(:get).with('beef.http.debug').and_return(false) + allow(config).to receive(:get).with('beef.http.https.enable').and_return(false) + allow(config).to receive(:get).with('beef.debug').and_return(false) + allow(Thin::Server).to receive(:new).and_return(double('Thin::Server')) + end + + it 'mounts hook file handler and init handler' do + server.prepare + expect(server.mounts).to have_key('/hook.js') + expect(server.mounts).to have_key('/init') + expect(server.mounts['/hook.js']).not_to be_nil + expect(server.mounts['/init']).to eq(BeEF::Core::Handlers::BrowserDetails) + end + + it 'builds Rack URLMap from mounts' do + server.prepare + expect(server.instance_variable_get(:@rack_app)).to be_a(Rack::URLMap) + end + + it 'returns early when @http_server already set' do + allow(Thin::Server).to receive(:new) + existing = double('Thin::Server') + server.instance_variable_set(:@http_server, existing) + server.prepare + expect(Thin::Server).not_to have_received(:new) + end + + it 'sets Thin::Logging when beef.http.debug is true' do + allow(config).to receive(:get).with('beef.http.debug').and_return(true) + allow(Thin::Logging).to receive(:silent=) + allow(Thin::Logging).to receive(:debug=) + server.prepare + expect(Thin::Logging).to have_received(:silent=).with(false) + expect(Thin::Logging).to have_received(:debug=).with(true) + end + end + + describe '#start' do + it 'rescues port-in-use error and exits' do + mock_thin = double('Thin::Server') + allow(mock_thin).to receive(:start).and_raise(RuntimeError.new('no acceptor')) + server.instance_variable_set(:@http_server, mock_thin) + allow(server).to receive(:print_error) + allow(server).to receive(:exit).with(127) { raise SystemExit.new(127) } + expect { server.start }.to raise_error(SystemExit) + expect(server).to have_received(:print_error).with(/port|invalid IP/i) + expect(server).to have_received(:exit).with(127) + end + + it 're-raises RuntimeError when message does not include no acceptor' do + mock_thin = double('Thin::Server') + allow(mock_thin).to receive(:start).and_raise(RuntimeError.new('other error')) + server.instance_variable_set(:@http_server, mock_thin) + expect { server.start }.to raise_error(RuntimeError, 'other error') + end + end end diff --git a/spec/beef/core/ruby/security_spec.rb b/spec/beef/core/ruby/security_spec.rb index ba257bfcd..9b9fa9a33 100644 --- a/spec/beef/core/ruby/security_spec.rb +++ b/spec/beef/core/ruby/security_spec.rb @@ -25,4 +25,30 @@ RSpec.describe 'Security method overrides' do expect(Kernel.method(:system).source_location).not_to be_nil expect(Kernel.method(:system).source_location[0]).to include('core/ruby/security.rb') end + + describe 'override behavior' do + it 'exec prints security message and exits' do + allow(Kernel).to receive(:puts) + allow(Kernel).to receive(:exit) + exec('ls') + expect(Kernel).to have_received(:puts).with(/security reasons.*exec/) + expect(Kernel).to have_received(:exit) + end + + it 'system prints security message and exits' do + allow(Kernel).to receive(:puts) + allow(Kernel).to receive(:exit) + system('ls') + expect(Kernel).to have_received(:puts).with(/security reasons.*system/) + expect(Kernel).to have_received(:exit) + end + + it 'Kernel.system prints security message and exits' do + allow(Kernel).to receive(:puts) + allow(Kernel).to receive(:exit) + Kernel.system('ls') + expect(Kernel).to have_received(:puts).with(/security reasons.*system/) + expect(Kernel).to have_received(:exit) + end + end end diff --git a/spec/beef/modules/browser/browser_spec.rb b/spec/beef/modules/browser/browser_spec.rb new file mode 100644 index 000000000..df19163c2 --- /dev/null +++ b/spec/beef/modules/browser/browser_spec.rb @@ -0,0 +1,102 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/browser/ (including hooked_origin). +# Branch coverage: extra post_execute for modules that set BrowserDetails when results match. +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +browser_module_paths = Dir[File.join(project_root, 'modules/browser/**/module.rb')].sort + +# Load branch coverage data from centralised config +BRANCH_COVERAGE_BROWSER = BeefTestConfig.branch_coverage_for(:browser) + +browser_module_paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + branch_key = File.dirname(path).sub("#{project_root}/", '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_url_str).and_return('http://127.0.0.1:3000') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_raw) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + # Some modules (e.g. Spyder_eye) write screenshot files to $home_dir on the success path. + # Stub File.open with a block-aware double so the test never touches the real filesystem. + file_double = double('File').as_null_object + allow(File).to receive(:open) do |*_args, &block| + block ? block.call(file_double) : file_double + end + + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + + if BRANCH_COVERAGE_BROWSER[branch_key] + it 'runs branch path with realistic datastore' do + branch = BRANCH_COVERAGE_BROWSER[branch_key] + + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_raw) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + allow(BeEF::Core::Models::BrowserDetails).to receive(:set) + instance = build_command_instance(described_class, branch[:datastore]) + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end + end +end diff --git a/spec/beef/modules/chrome_extensions/chrome_extensions_spec.rb b/spec/beef/modules/chrome_extensions/chrome_extensions_spec.rb new file mode 100644 index 000000000..9f19b73b2 --- /dev/null +++ b/spec/beef/modules/chrome_extensions/chrome_extensions_spec.rb @@ -0,0 +1,71 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/chrome_extensions/. +# Each directory is tested for .options (when present) and #post_execute. +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +chrome_module_paths = Dir[File.join(project_root, 'modules/chrome_extensions/**/module.rb')].sort + +chrome_module_paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, 'return' => '', 'result' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + + instance = build_command_instance(described_class, 'return' => '', 'result' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end +end diff --git a/spec/beef/modules/debug/debug_spec.rb b/spec/beef/modules/debug/debug_spec.rb new file mode 100644 index 000000000..003481c50 --- /dev/null +++ b/spec/beef/modules/debug/debug_spec.rb @@ -0,0 +1,75 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/debug/. +# Each directory is tested for .options (when present) and #post_execute. +# See test_beef_debugs_spec.rb for integration/E2E tests (BrowserStack). +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +debug_module_paths = Dir[File.join(project_root, 'modules/debug/**/module.rb')].sort + +debug_module_paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:bind_redirect).with(anything, anything).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end +end diff --git a/spec/beef/modules/exploits/exploits_spec.rb b/spec/beef/modules/exploits/exploits_spec.rb new file mode 100644 index 000000000..2690be841 --- /dev/null +++ b/spec/beef/modules/exploits/exploits_spec.rb @@ -0,0 +1,110 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/exploits/. +# Branch coverage: extra post_execute for modules with conditional branches +# (driven by BeefTestConfig.branch_coverage_for(:exploits) in spec_helper.rb). +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +exploit_module_paths = Dir[File.join(project_root, 'modules/exploits/**/module.rb')].sort + +BRANCH_COVERAGE_EXPLOITS = BeefTestConfig.branch_coverage_for(:exploits) + +exploit_module_paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + branch_key = File.dirname(path).sub("#{project_root}/", '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, {}) + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + + instance = build_command_instance(described_class, {}) + expect { run_post_execute(instance) }.not_to raise_error + end + + if BRANCH_COVERAGE_EXPLOITS[branch_key] + it 'runs branch path with realistic datastore' do + raw = BRANCH_COVERAGE_EXPLOITS[branch_key] + branches = raw.is_a?(Array) ? raw : [raw] + + # NetworkService / NetworkHost belong to the network extension and may not be + # loaded during a modules-only spec run. Provide class doubles per-example + # so `allow(...).to receive(:create)` resolves regardless. + stub_const('BeEF::Core::Models::NetworkService', Class.new { def self.create(*); end }) unless defined?(BeEF::Core::Models::NetworkService) + stub_const('BeEF::Core::Models::NetworkHost', Class.new { def self.create(*); end }) unless defined?(BeEF::Core::Models::NetworkHost) + + branches.each do |branch| + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + allow(BeEF::Core::Models::NetworkService).to receive(:create) + allow(BeEF::Core::Models::NetworkHost).to receive(:create) + allow(BeEF::Filters).to receive(:is_valid_ip?).and_return(true) + if branch[:config_get] + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:get).with(anything) do |key| + branch[:config_get].key?(key) ? branch[:config_get][key] : '127.0.0.1' + end + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + end + instance = build_command_instance(described_class, branch[:datastore]) + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end + end + end +end diff --git a/spec/beef/modules/host/host_spec.rb b/spec/beef/modules/host/host_spec.rb new file mode 100644 index 000000000..9b865315e --- /dev/null +++ b/spec/beef/modules/host/host_spec.rb @@ -0,0 +1,131 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/host/. +# Each directory is tested for .options (when present), #pre_send, and #post_execute. +# Branch coverage: extra post_execute runs for modules listed in +# BeefTestConfig.branch_coverage_for(:host) (defined in spec_helper.rb). +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +host_module_paths = Dir[File.join(project_root, 'modules/host/**/module.rb')].sort + +BRANCH_COVERAGE_HOST = BeefTestConfig.branch_coverage_for(:host) + +host_module_paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + branch_key = File.dirname(path).sub("#{project_root}/", '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_url_str).and_return('http://127.0.0.1:3000') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + # Stub File.open so Hook_default_browser#pre_send doesn't read/write the real + # bounce_to_ie_configured.pdf in modules/host/hook_default_browser/. + # The source calls File.open(path, 'w') without a block and File.open(path, 'r') { |f| ... } with one. + file_double = double('File').as_null_object + allow(File).to receive(:open) do |*_args, &block| + block ? block.call(file_double) : file_double + end + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + + # Minimal datastore so modules that call .sub/.to_s on keys don't get nil + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + + if BRANCH_COVERAGE_HOST[branch_key] + it 'runs branch path with realistic datastore' do + branch = BRANCH_COVERAGE_HOST[branch_key] + + # NetworkService / NetworkHost belong to the network extension and may not be + # loaded during a modules-only spec run. Provide class doubles per-example + # so `allow(...).to receive(:create)` resolves regardless. + stub_const('BeEF::Core::Models::NetworkService', Class.new { def self.create(*); end }) unless defined?(BeEF::Core::Models::NetworkService) + stub_const('BeEF::Core::Models::NetworkHost', Class.new { def self.create(*); end }) unless defined?(BeEF::Core::Models::NetworkHost) + + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + allow(BeEF::Core::Models::NetworkService).to receive(:create) + allow(BeEF::Core::Models::NetworkHost).to receive(:create) + allow(BeEF::Core::Models::BrowserDetails).to receive(:get).and_return('Linux') + allow(BeEF::Filters).to receive(:is_valid_ip?).and_return(true) + + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_url_str).and_return('http://127.0.0.1:3000') + allow(config).to receive(:get).with(anything) do |key| + branch[:config_get].key?(key) ? branch[:config_get][key] : '127.0.0.1' + end + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + instance = build_command_instance(described_class, branch[:datastore]) + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end + end +end diff --git a/spec/beef/modules/ipec/ipec_spec.rb b/spec/beef/modules/ipec/ipec_spec.rb new file mode 100644 index 000000000..b86116849 --- /dev/null +++ b/spec/beef/modules/ipec/ipec_spec.rb @@ -0,0 +1,113 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/ipec/. +# +# Some ipec modules reference BeEF::Extension::ETag and +# BeEF::Extension::ServerClientDnsTunnel which are not loaded in unit tests. +# We stub these per-example with stub_const so the stubs don't leak across +# the suite or shadow the real extensions when they happen to be loaded. +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +paths = Dir[File.join(project_root, 'modules/ipec/**/module.rb')].sort + +# Lightweight stand-ins for the extension singletons; no real behaviour required. +def stub_etag_messages_class + Class.new do + def self.instance + @instance ||= Struct.new(:messages).new({}) + end + end +end + +def stub_dns_tunnel_server_class + Class.new do + def self.instance + @instance ||= Struct.new(:messages).new({}) + end + end +end + +paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + before(:each) do + # Per-example stubs (auto-removed after each example). + BeEF::Extension.const_set(:ETag, Module.new) unless BeEF::Extension.const_defined?(:ETag) + BeEF::Extension.const_set(:ServerClientDnsTunnel, Module.new) unless BeEF::Extension.const_defined?(:ServerClientDnsTunnel) + stub_const('BeEF::Extension::ETag::ETagMessages', stub_etag_messages_class) unless BeEF::Extension::ETag.const_defined?(:ETagMessages) + stub_const('BeEF::Extension::ServerClientDnsTunnel::Server', stub_dns_tunnel_server_class) unless BeEF::Extension::ServerClientDnsTunnel.const_defined?(:Server) + end + + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything) do |key| + case key + when 'beef.extension.etag.enable' then true + when 'beef.extension.s2c_dns_tunnel.enable' then true + when 'beef.extension.s2c_dns_tunnel.zone' then 'example.com' + else '127.0.0.1' + end + end + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, [{ 'name' => 'data', 'value' => 'test' }]) + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end +end diff --git a/spec/beef/modules/misc/misc_spec.rb b/spec/beef/modules/misc/misc_spec.rb new file mode 100644 index 000000000..afb4499ea --- /dev/null +++ b/spec/beef/modules/misc/misc_spec.rb @@ -0,0 +1,163 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/misc/ (including subdirs). +# Branch coverage: extra post_execute runs for modules in +# BeefTestConfig.branch_coverage_for(:misc) (defined in spec_helper.rb). +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +paths = Dir[File.join(project_root, 'modules/misc/**/module.rb')].sort + +BRANCH_COVERAGE_MISC = BeefTestConfig.branch_coverage_for(:misc) + +paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + branch_key = File.dirname(path).sub("#{project_root}/", '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+(?:\w+|BeEF::\w+(?:::\w+)*)/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+(?:\w+|BeEF::\w+(?:::\w+)*)/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + expect(described_class.options).to be_an(Array) + end + end + + # Targeted structural assertions for selected misc modules. Higher value than + # the generic smoke test: checks option count, names, types and shape. + if klass_name == 'Wordpress_add_user' + describe '.options (Wordpress_add_user specifics)' do + it 'includes wordpress path and user options' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + options = described_class.options + expect(options).to be_an(Array) + expect(options.length).to eq(5) # wp_path (parent) + username, password, email, role + + wp_path_option = options.find { |opt| opt['name'] == 'wp_path' } + expect(wp_path_option).not_to be_nil + expect(wp_path_option['value']).to eq('/') + + username_option = options.find { |opt| opt['name'] == 'username' } + expect(username_option).not_to be_nil + expect(username_option['value']).to eq('beef') + + password_option = options.find { |opt| opt['name'] == 'password' } + expect(password_option).not_to be_nil + expect(password_option['value']).to be_a(String) + expect(password_option['value'].length).to eq(10) # SecureRandom.hex(5) = 10 chars + + role_option = options.find { |opt| opt['name'] == 'role' } + expect(role_option).not_to be_nil + expect(role_option['value']).to eq('administrator') + end + end + end + + if klass_name == 'Test_get_variable' + describe '.options (Test_get_variable specifics)' do + it 'returns payload_name option with correct structure' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + options = described_class.options + expect(options).to be_an(Array) + expect(options.length).to eq(1) + + option = options.first + expect(option['name']).to eq('payload_name') + expect(option['ui_label']).to eq('Payload Name') + expect(option['type']).to eq('text') + expect(option['value']).to eq('message') + expect(option['width']).to eq('400px') + end + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + allow(IO).to receive(:popen).and_return(StringIO.new('')) + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_raw) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + + if BRANCH_COVERAGE_MISC[branch_key] + it 'runs branch path with realistic datastore' do + branch = BRANCH_COVERAGE_MISC[branch_key] + + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_raw) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + instance = build_command_instance(described_class, branch[:datastore]) + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end + end +end diff --git a/spec/beef/modules/network/network_spec.rb b/spec/beef/modules/network/network_spec.rb new file mode 100644 index 000000000..cf79799e4 --- /dev/null +++ b/spec/beef/modules/network/network_spec.rb @@ -0,0 +1,172 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/network/ (including ADC). +# +# Branch coverage data lives in BeefTestConfig.branch_coverage_for(:network) +# (see spec_helper.rb). Per-example stub_const calls inject a Dns::Server +# stand-in only when the real Dns extension is not loaded, avoiding the +# "superclass mismatch for class Server" hazard that arises if the stub +# leaks into the suite before the real extension's spec loads. +# + +require_relative '../../../spec_helper' + +BRANCH_COVERAGE_NETWORK = BeefTestConfig.branch_coverage_for(:network) + +# Lightweight Dns server stand-in used when the real BeEF::Extension::Dns is +# not loaded (e.g. when running modules-only specs). +DNS_SERVER_STUB = Class.new do + def self.instance + @instance ||= Object.new.tap do |o| + def o.add_rule(*) 1 end + def o.remove_rule!(*) nil end + end + end +end + +project_root = File.expand_path('../../../../', __dir__) +paths = Dir[File.join(project_root, 'modules/network/**/module.rb')].sort + +paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + branch_key = File.dirname(path).sub("#{project_root}/", '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + before(:each) do + # Irc_nat_pinning calls sleep 30 in post_execute; suppress so the suite doesn't block. + allow(Kernel).to receive(:sleep) + allow_any_instance_of(described_class).to receive(:sleep).and_return(nil) + + # Provide a per-example Dns::Server stand-in only if the real extension hasn't been loaded. + # Using stub_const keeps the stub scoped to this example only. + unless defined?(BeEF::Extension::Dns) && BeEF::Extension::Dns.const_defined?(:Server) + BeEF::Extension.const_set(:Dns, Module.new) unless BeEF::Extension.const_defined?(:Dns) + stub_const('BeEF::Extension::Dns::Server', DNS_SERVER_STUB) + end + end + + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + # If the real Dns extension is loaded (e.g. rake short), stub Server.instance so Dns_rebinding works. + if defined?(BeEF::Extension::Dns) && BeEF::Extension::Dns.const_defined?(:Server) + dns_instance = double('DnsServer', add_rule: 1, remove_rule!: nil) + allow(BeEF::Extension::Dns::Server).to receive(:instance).and_return(dns_instance) + end + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:bind_socket).and_return(nil) + allow(handler).to receive(:unbind_socket).and_return(nil) + allow(handler).to receive(:bind_cached).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything) do |key| + case key + when 'beef.extension.dns_rebinding' then { 'address_http_external' => '127.0.0.1', 'port_proxy' => '1234' } + when 'beef.module.dns_rebinding.domain' then 'example.com' + else '127.0.0.1' + end + end + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + # Dns_rebinding expects @datastore as Array of option hashes (target, domain, url_callback). + datastore = described_class.name.include?('Dns_rebinding') ? [{ 'value' => '192.168.0.1' }, { 'value' => 'example.com' }, {}] : { 'result' => '', 'results' => '', 'cid' => '0' } + instance = build_command_instance(described_class, datastore) + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:bind_socket) + allow(handler).to receive(:unbind_socket) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_cached) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + + if BRANCH_COVERAGE_NETWORK[branch_key] + it 'runs branch path with realistic datastore' do + raw = BRANCH_COVERAGE_NETWORK[branch_key] + branches = raw.is_a?(Array) ? raw : [raw] + + # NetworkService / NetworkHost belong to the network extension and may not be + # loaded during a modules-only spec run. Provide class doubles per-example + # so `allow(...).to receive(:create)` resolves regardless. + stub_const('BeEF::Core::Models::NetworkService', Class.new { def self.create(*); end }) unless defined?(BeEF::Core::Models::NetworkService) + stub_const('BeEF::Core::Models::NetworkHost', Class.new { def self.create(*); end }) unless defined?(BeEF::Core::Models::NetworkHost) + + branches.each do |branch| + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:bind_socket) + allow(handler).to receive(:unbind_socket) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_cached) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + allow_any_instance_of(described_class).to receive(:ip).and_return('0.0.0.0') + allow_any_instance_of(described_class).to receive(:timestamp).and_return('0') + allow(BeEF::Core::Models::NetworkService).to receive(:create) + allow(BeEF::Core::Models::NetworkHost).to receive(:create) + allow(BeEF::Filters).to receive(:is_valid_ip?).and_return(true) + + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything) do |key| + branch[:config_get] && branch[:config_get].key?(key) ? branch[:config_get][key] : '127.0.0.1' + end + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + + instance = build_command_instance(described_class, branch[:datastore]) + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end + end + end +end diff --git a/spec/beef/modules/persistence/persistence_spec.rb b/spec/beef/modules/persistence/persistence_spec.rb new file mode 100644 index 000000000..b05f990f2 --- /dev/null +++ b/spec/beef/modules/persistence/persistence_spec.rb @@ -0,0 +1,75 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/persistence/. +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +paths = Dir[File.join(project_root, 'modules/persistence/**/module.rb')].sort + +paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('/hook.js') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(handler).to receive(:bind_raw) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + file_double = double('File').as_null_object + allow(File).to receive(:open).and_return(file_double) + allow(BeEF::Core::Models::Command).to receive(:save_result) + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end +end diff --git a/spec/beef/modules/phonegap/phonegap_spec.rb b/spec/beef/modules/phonegap/phonegap_spec.rb new file mode 100644 index 000000000..9d9956714 --- /dev/null +++ b/spec/beef/modules/phonegap/phonegap_spec.rb @@ -0,0 +1,72 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/phonegap/. +# Some modules use #callback instead of #post_execute; only post_execute is tested here. +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +paths = Dir[File.join(project_root, 'modules/phonegap/**/module.rb')].sort + +paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:hook_file_path).and_return('/hook.js') + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + instance = build_command_instance(described_class, 'result' => '', 'Result' => '', 'cid' => '0') + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + instance = build_command_instance(described_class, 'result' => '', 'Result' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end +end diff --git a/spec/beef/modules/social_engineering/social_engineering_spec.rb b/spec/beef/modules/social_engineering/social_engineering_spec.rb new file mode 100644 index 000000000..628737860 --- /dev/null +++ b/spec/beef/modules/social_engineering/social_engineering_spec.rb @@ -0,0 +1,103 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Unit tests for every module under modules/social_engineering/. +# Branch coverage data and pre_send skip list live in BeefTestConfig +# (see spec_helper.rb). +# + +require_relative '../../../spec_helper' + +project_root = File.expand_path('../../../../', __dir__) +paths = Dir[File.join(project_root, 'modules/social_engineering/**/module.rb')].sort + +BRANCH_COVERAGE_SOCENG = BeefTestConfig.branch_coverage_for(:social_engineering) +PRE_SEND_SKIP_SOCENG = BeefTestConfig.pre_send_skip_for(:social_engineering) + +paths.each do |path| + rel = path.sub("#{project_root}/", '').sub(/\.rb$/, '') + branch_key = File.dirname(path).sub("#{project_root}/", '') + require_path = File.join('../../../../', rel) + class_line = File.read(path).lines.find { |l| l =~ /\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/ } + next unless class_line + + klass_name = class_line.match(/\bclass\s+(\w+)\s+<\s+BeEF::Core::Command/)[1] + require_relative require_path + mod = Object.const_get(klass_name) + + RSpec.describe mod do + if described_class.respond_to?(:options) + describe '.options' do + it 'returns an Array' do + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:beef_host).and_return('127.0.0.1') + allow(config).to receive(:beef_proto).and_return('http') + allow(config).to receive(:beef_port).and_return('3000') + allow(config).to receive(:beef_url_str).and_return('http://127.0.0.1:3000') + allow(config).to receive(:get).with(anything) do |key| + key == 'beef.module.simple_hijacker.templates' ? [] : '127.0.0.1' + end + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + expect(described_class.options).to be_an(Array) + end + end + end + + if described_class.method_defined?(:pre_send) && !PRE_SEND_SKIP_SOCENG.include?(klass_name) + describe '#pre_send' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind).and_return(nil) + allow(handler).to receive(:bind).and_return(nil) + allow(handler).to receive(:bind_raw).with(anything, anything, anything, anything, anything).and_return(nil) + allow(handler).to receive(:remap).and_return(nil) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + config = instance_double(BeEF::Core::Configuration) + allow(config).to receive(:get).with(anything).and_return('127.0.0.1') + allow(BeEF::Core::Configuration).to receive(:instance).and_return(config) + allow(IO).to receive(:popen).and_return(StringIO.new('')) + # Many pre_send implementations expect @datastore to be an Array of option hashes (name/value) + pre_send_datastore = [ + { 'name' => 'payload', 'value' => 'cmd' }, + { 'name' => 'payload_handler', 'value' => 'http://127.0.0.1:8080' }, + { 'name' => 'extension_name', 'value' => 'TestExt' }, + { 'name' => 'xpi_name', 'value' => 'test.xpi' }, + { 'name' => 'lport', 'value' => '4444' } + ] + instance = build_command_instance(described_class, pre_send_datastore) + expect { run_pre_send(instance) }.not_to raise_error + end + end + end + + if described_class.method_defined?(:post_execute) + describe '#post_execute' do + it 'runs without error' do + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + instance = build_command_instance(described_class, 'result' => '', 'results' => '', 'answer' => '', 'cid' => '0') + expect { run_post_execute(instance) }.not_to raise_error + end + + if BRANCH_COVERAGE_SOCENG[branch_key] + it 'runs branch path with realistic datastore' do + branch = BRANCH_COVERAGE_SOCENG[branch_key] + + handler = instance_double('AssetHandler') + allow(handler).to receive(:unbind) + allow(handler).to receive(:bind) + allow(handler).to receive(:remap) + allow(BeEF::Core::NetworkStack::Handlers::AssetHandler).to receive(:instance).and_return(handler) + instance = build_command_instance(described_class, branch[:datastore]) + expect { run_post_execute(instance) }.not_to raise_error + end + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b4a4e7df2..35d307118 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,14 +3,214 @@ # Browser Exploitation Framework (BeEF) - https://beefproject.com # See the file 'doc/COPYING' for copying permission # + # Coverage must start before loading application code. -require 'simplecov' -SimpleCov.start do - add_filter '/spec/' - add_group 'Core', 'core' - add_group 'Extensions', 'extensions' - add_group 'Modules', 'modules' - track_files '{core,extensions,modules}/**/*.rb' +require 'simplecov' and SimpleCov.start if ENV['COVERAGE'] + +# Branch-coverage datastore/config fixtures for the dynamic module spec loaders. +# Each spec file under spec/beef/modules/* uses BeefTestConfig.branch_coverage_for(:component) +# to drive an extra post_execute example with realistic data, hitting conditional branches +# that the generic smoke test misses. +# +# Centralising these here: +# - removes duplicate top-level BRANCH_COVERAGE constant definitions across spec files +# (which previously triggered "already initialized constant" warnings and silent overrides), +# - makes the test data discoverable from one place, and +# - matches what spec/README.md claims. +module BeefTestConfig + PRE_SEND_SKIP = { + # Modules whose pre_send needs Zip, real filesystem access, or speech tooling we + # don't want to invoke in unit tests; the dynamic loader skips the generic pre_send + # example for these described_class names. + social_engineering: %w[ + Firefox_extension_bindshell Firefox_extension_dropper Firefox_extension_reverse_shell + Text_to_voice Ui_abuse_ie + ].freeze + }.freeze + + def self.pre_send_skip_for(component) + PRE_SEND_SKIP[component] || [] + end + + def self.branch_coverage_for(component) + case component + when :browser + { + 'modules/browser/detect_wmp' => { datastore: { 'results' => 'wmp=Yes', 'wmp' => '', 'result' => '', 'cid' => '0', 'beefhook' => '1' } }, + 'modules/browser/detect_vlc' => { datastore: { 'results' => 'vlc=Yes', 'vlc' => '', 'result' => '', 'cid' => '0', 'beefhook' => '1' } }, + 'modules/browser/detect_office' => { datastore: { 'results' => 'office=Office 2016', 'result' => '', 'cid' => '0', 'beefhook' => '1' } }, + 'modules/browser/detect_foxit' => { datastore: { 'results' => 'foxit=Yes', 'result' => '', 'cid' => '0', 'beefhook' => '1' } }, + 'modules/browser/detect_activex' => { datastore: { 'results' => 'activex=Yes', 'activex' => '', 'result' => '', 'cid' => '0', 'beefhook' => '1' } } + } + when :exploits + { + 'modules/exploits/vtiger_crm_upload_exploit' => { datastore: { 'result' => 'ok', 'cid' => '0' } }, + 'modules/exploits/router/asus_rt_n12e_get_info' => { + datastore: { + 'result' => '', 'results' => 'ip=192.168.1.1&clients=192.168.1.2,00:11:22:33:44:55&wanip=1.2.3.4&netmask=255.255.255.0&gateway=192.168.1.1&dns=8.8.8.8 8.8.4.4', + 'beefhook' => '1', 'cid' => '0' + }, + config_get: { 'beef.extension.network.enable' => true } + } + } + when :host + { + 'modules/host/detect_cups' => { + datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=631&cups=Installed', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/host/detect_airdroid' => { + datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=8888&airdroid=Installed', 'airdroid' => '', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/host/get_internal_ip_java' => { + datastore: { 'results' => '192.168.1.1', 'Result' => '', 'result' => '', 'beefhook' => '1', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/host/get_internal_ip_webrtc' => { + datastore: { 'results' => 'IP is 192.168.1.1', 'Result' => '', 'result' => '', 'beefhook' => '1', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + } + } + when :misc + { + 'modules/misc/wordpress_post_auth_rce' => { datastore: { 'result' => 'ok', 'cid' => '0' } } + } + when :network + { + 'modules/network/port_scanner' => { + datastore: { 'results' => 'ip=1.2.3.4&port=HTTP: Port 80 is OPEN Apache', 'beefhook' => '1', 'result' => '', 'port' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/ping_sweep' => { + datastore: { 'results' => 'ip=192.168.1.1&ping=10ms', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/ping_sweep_ff' => { + datastore: { 'results' => 'host=192.168.1.1 is alive', 'beefhook' => '1', 'result' => '', 'host' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/get_http_servers' => { + datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=80&url=http://1.2.3.4/', 'beefhook' => '1', 'result' => '', 'url' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/cross_origin_scanner_cors' => { + datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=80&status=200', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/detect_burp' => { + datastore: { 'results' => 'has_burp=true&response=PROXY 127.0.0.1:8080', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/get_ntop_network_hosts' => { + datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=3000&data={"hostNumIpAddress":"192.168.1.1"}', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/internal_network_fingerprinting' => { + datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=80&discovered=Apache&url=http://test/', 'beefhook' => '1', 'result' => '', 'discovered' => '', 'url' => '', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/get_proxy_servers_wpad' => [ + { datastore: { 'results' => 'proxies=PROXY 192.168.1.1:3128', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, config_get: { 'beef.extension.network.enable' => true } }, + { datastore: { 'results' => 'proxies=SOCKS 192.168.1.1:1080', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, config_get: { 'beef.extension.network.enable' => true } } + ], + 'modules/network/jslanscanner' => [ + { datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=80&service=HTTP', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, config_get: { 'beef.extension.network.enable' => true } }, + { datastore: { 'results' => 'ip=1.2.3.4&device=Router', 'beefhook' => '1', 'result' => '', 'cid' => '0' }, config_get: { 'beef.extension.network.enable' => true } } + ], + 'modules/network/fetch_port_scanner' => { + datastore: { 'result' => 'ok', 'beefhook' => '1', 'cid' => '0' }, + config_get: { 'beef.extension.network.enable' => true } + }, + 'modules/network/cross_origin_scanner_flash' => [ + { datastore: { 'results' => 'ip=1.2.3.4&status=alive', 'result' => '', 'beefhook' => '1', 'cid' => '0' }, config_get: { 'beef.extension.network.enable' => true } }, + { datastore: { 'results' => 'proto=http&ip=1.2.3.4&port=80&title=Apache', 'result' => '', 'beefhook' => '1', 'cid' => '0' }, config_get: { 'beef.extension.network.enable' => true } } + ] + } + when :social_engineering + { + 'modules/social_engineering/fake_lastpass' => { datastore: { 'meta' => 'KILLFRAME', 'result' => '', 'results' => '', 'answer' => '', 'cid' => '0' } }, + 'modules/social_engineering/fake_evernote_clipper' => { datastore: { 'meta' => 'KILLFRAME', 'result' => '', 'results' => '', 'answer' => '', 'cid' => '0' } } + } + else + {} + end + end +end + +# Returns [total_lines, covered_lines] for a group, or nil if no group. +def group_coverage(r, group_name) + group = r.groups[group_name] + return nil unless group&.respond_to?(:each) + + total_lines = 0 + covered_lines = 0 + group.each do |src_file| + total_lines += src_file.lines_of_code + covered_lines += src_file.covered_lines.compact.size + end + [total_lines, covered_lines] +end + +# Print one group's coverage (skip forked children: very low coverage or zero). +def print_group_coverage(r, group_name, color: "\e[36m") + total_lines, covered_lines = group_coverage(r, group_name) + return unless total_lines && total_lines.positive? + return if covered_lines.zero? + + pct = covered_lines.to_f / total_lines * 100 + # Skip noise from forked children (e.g. 0.03% Core). + return if pct < 1.0 + + puts "#{color}#{group_name} coverage: #{pct.round(2)}% (#{covered_lines} / #{total_lines} lines)\e[0m" +end + +# Only print from the main process (skip forked children with tiny coverage). +MIN_COVERAGE_PCT = 5.0 + +def coverage_from_main_process?(r, focus) + case focus + when 'all' + %w[Core Extensions Modules].any? do |name| + total, covered = group_coverage(r, name) + next false unless total && total.positive? + (covered.to_f / total * 100) >= MIN_COVERAGE_PCT + end + when 'core' + total, covered = group_coverage(r, 'Core') + total && total.positive? && (covered.to_f / total * 100) >= MIN_COVERAGE_PCT + when 'extensions' + total, covered = group_coverage(r, 'Extensions') + total && total.positive? && (covered.to_f / total * 100) >= MIN_COVERAGE_PCT + when 'modules', nil + total, covered = group_coverage(r, 'Modules') + total && total.positive? && (covered.to_f / total * 100) >= MIN_COVERAGE_PCT + else + false + end +end + +at_exit do + if defined?(SimpleCov) && SimpleCov.respond_to?(:result) && (r = SimpleCov.result) + focus = ENV['COVERAGE'] + next unless coverage_from_main_process?(r, focus) + + case focus + when 'core' + print_group_coverage(r, 'Core') + when 'extensions' + print_group_coverage(r, 'Extensions') + when 'modules' + print_group_coverage(r, 'Modules') + when 'all' + puts + print_group_coverage(r, 'Core') + print_group_coverage(r, 'Extensions') + print_group_coverage(r, 'Modules') + else + print_group_coverage(r, 'Modules') + end + end end # Set external and internal character encodings to UTF-8 diff --git a/spec/support/module_spec_helper.rb b/spec/support/module_spec_helper.rb new file mode 100644 index 000000000..552ee7915 --- /dev/null +++ b/spec/support/module_spec_helper.rb @@ -0,0 +1,59 @@ +# +# Copyright (c) 2006-2026 Wade Alcorn - wade@bindshell.net +# Browser Exploitation Framework (BeEF) - https://beefproject.com +# See the file 'doc/COPYING' for copying permission +# +# Helpers for unit specs of command modules (modules/*/module.rb). +# Use with stubbed config; no REST/API or real server. +# + +module ModuleSpecHelper + # + # Build a command instance for testing post_execute (or other instance methods). + # Uses allocate so we avoid Command#initialize reading from config. + # Set @datastore before calling post_execute; results are stored in @results via save(). + # + # @param klass [Class] The command module class (e.g. Test_beef_debug) + # @param datastore [Hash] Data to set on @datastore (simulates callback data) + # @return [Object] Instance with @datastore set; call post_execute then read instance_variable_get(:@results) + # + def build_command_instance(klass, datastore = {}) + instance = klass.allocate + instance.instance_variable_set(:@datastore, datastore) + instance + end + + # + # Call post_execute on a command instance and return the saved results. + # Use after build_command_instance(klass, datastore). + # + # @param instance [Object] Command instance from build_command_instance + # @return [Hash, nil] The value passed to save() (stored in @results) + # + def run_post_execute(instance) + instance.post_execute + instance.instance_variable_get(:@results) + end + + # + # Call pre_send on a command instance (e.g. before sending the command to the hooked browser). + # Use after build_command_instance(klass, datastore). Stub AssetHandler, Configuration, etc. before calling. + # + # @param instance [Object] Command instance from build_command_instance + # + def run_pre_send(instance) + instance.pre_send + end +end + +RSpec.configure do |config| + config.include ModuleSpecHelper + + # Stub Kernel.sleep for all module specs so modules that call sleep + # (e.g. Irc_nat_pinning's post_execute sleep 30) don't slow the suite. + config.before(:each) do |example| + if example.metadata[:file_path]&.include?('spec/beef/modules') + allow(Kernel).to receive(:sleep) + end + end +end