├── VERSION ├── .github ├── lock.yml ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── SUPPORT_QUESTION.md │ ├── BUG_TEMPLATE.md │ ├── ENHANCEMENT_REQUEST_TEMPLATE.md │ └── DESIGN_PROPOSAL.md ├── dependabot.yml └── workflows │ └── ci-main-pull-request-stub-trufflehog-only.yml ├── test ├── integration │ ├── chefignore │ ├── cookbooks │ │ └── test │ │ │ ├── metadata.rb │ │ │ └── recipes │ │ │ ├── prep_files.rb │ │ │ └── default.rb │ ├── Berksfile │ ├── test-travis-debian.yml │ ├── test-travis-centos.yml │ ├── test-travis-oel.yml │ ├── test-travis-fedora.yml │ ├── test-travis-ubuntu.yml │ ├── sudo │ │ ├── run_as.rb │ │ ├── nopasswd.rb │ │ ├── customcommand.rb │ │ ├── reqtty.rb │ │ └── passwd.rb │ ├── test_local.rb │ ├── docker_test_container.rb │ ├── .kitchen.yml │ ├── bootstrap.sh │ ├── podman_test_container.rb │ ├── docker_test.rb │ ├── test_ssh.rb │ ├── tests │ │ ├── path_pipe_test.rb │ │ ├── run_command_test.rb │ │ ├── path_missing_test.rb │ │ ├── path_block_device_test.rb │ │ ├── path_character_device_test.rb │ │ ├── path_folder_test.rb │ │ ├── path_symlink_test.rb │ │ └── path_file_test.rb │ ├── helper.rb │ └── docker_run.rb ├── fixtures │ ├── PowerShell │ │ ├── exit_zero.ps1 │ │ ├── exit_fortytwo.ps1 │ │ └── throws.ps1 │ ├── plugins │ │ └── train-test-fixture │ │ │ ├── lib │ │ │ ├── train-test-fixture │ │ │ │ ├── version.rb │ │ │ │ ├── transport.rb │ │ │ │ ├── platform.rb │ │ │ │ └── connection.rb │ │ │ └── train-test-fixture.rb │ │ │ ├── pkg │ │ │ └── train-test-fixture-0.1.0.gem │ │ │ ├── README.md │ │ │ └── train-test-fixture.gemspec │ └── ssh_config ├── unit │ ├── version_test.rb │ ├── plugins_test.rb │ ├── platforms │ │ ├── family_test.rb │ │ ├── platforms_test.rb │ │ └── detect │ │ │ ├── scanner_test.rb │ │ │ ├── os_common_test.rb │ │ │ └── os_linux_test.rb │ ├── file │ │ ├── remote_test.rb │ │ ├── remote │ │ │ ├── qnx_test.rb │ │ │ ├── windows_test.rb │ │ │ ├── aix_test.rb │ │ │ └── unix_test.rb │ │ ├── local │ │ │ └── windows_test.rb │ │ └── local_test.rb │ ├── transports │ │ ├── ssh_connection_test.rb │ │ ├── cisco_ios_connection_test.rb │ │ └── helpers │ │ │ └── azure │ │ │ └── file_credentials_test.rb │ ├── plugins │ │ └── transport_test.rb │ └── file_test.rb └── helper.rb ├── examples └── plugins │ └── train-local-rot13 │ ├── test │ ├── fixtures │ │ ├── hello │ │ └── README.md │ ├── helper.rb │ ├── unit │ │ ├── connection_test.rb │ │ └── transport_test.rb │ └── functional │ │ └── local-rot13_test.rb │ ├── lib │ ├── train-local-rot13 │ │ ├── version.rb │ │ ├── file_content_rotator.rb │ │ ├── transport.rb │ │ ├── platform.rb │ │ └── connection.rb │ └── train-local-rot13.rb │ ├── LICENSE │ ├── Gemfile │ ├── Rakefile │ ├── train-local-rot13.gemspec │ └── README.md ├── lib └── train │ ├── globals.rb │ ├── version.rb │ ├── logger_ext.rb │ ├── platforms │ ├── detect.rb │ ├── detect │ │ ├── specifications │ │ │ └── api.rb │ │ ├── uuid.rb │ │ ├── scanner.rb │ │ └── helpers │ │ │ └── os_linux.rb │ ├── family.rb │ ├── common.rb │ └── platform.rb │ ├── extras.rb │ ├── file │ ├── remote │ │ ├── linux.rb │ │ ├── aix.rb │ │ ├── qnx.rb │ │ ├── windows.rb │ │ └── unix.rb │ ├── remote.rb │ ├── local │ │ ├── windows.rb │ │ └── unix.rb │ └── local.rb │ ├── transports │ ├── helpers │ │ └── azure │ │ │ ├── subscription_id_file_parser.rb │ │ │ ├── file_parser.rb │ │ │ ├── subscription_number_file_parser.rb │ │ │ └── file_credentials.rb │ ├── clients │ │ └── azure │ │ │ ├── graph_rbac.rb │ │ │ └── vault.rb │ ├── cisco_ios_connection.rb │ └── gcp.rb │ ├── audit_log.rb │ ├── plugins.rb │ ├── errors.rb │ ├── plugins │ └── transport.rb │ ├── plugin_test_helper.rb │ ├── platforms.rb │ └── options.rb ├── .gitignore ├── .codeclimate.yml ├── .expeditor ├── buildkite │ ├── verify.sh │ ├── verify.ps1 │ └── coverage.sh ├── coverage.pipeline.yml ├── update_version.sh ├── templates │ └── pull_request.mustache ├── verify.pipeline.yml └── config.yml ├── sonar-project.properties ├── Gemfile ├── train-core.gemspec ├── contrib └── fixup_requiretty.rb ├── Rakefile ├── train.gemspec └── docs └── audit_log.md /VERSION: -------------------------------------------------------------------------------- 1 | 3.14.1 -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | daysUntilLock: 60 2 | -------------------------------------------------------------------------------- /test/integration/chefignore: -------------------------------------------------------------------------------- 1 | .kitchen 2 | -------------------------------------------------------------------------------- /test/fixtures/PowerShell/exit_zero.ps1: -------------------------------------------------------------------------------- 1 | echo 'Hello' -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/test/fixtures/hello: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /test/fixtures/PowerShell/exit_fortytwo.ps1: -------------------------------------------------------------------------------- 1 | echo 'Goodbye' 2 | exit 42 -------------------------------------------------------------------------------- /test/fixtures/PowerShell/throws.ps1: -------------------------------------------------------------------------------- 1 | echo 'Next line throws' 2 | Throw 'Oh dear.' -------------------------------------------------------------------------------- /test/integration/cookbooks/test/metadata.rb: -------------------------------------------------------------------------------- 1 | name "test" 2 | 3 | depends "sudo", ">= 5.0" -------------------------------------------------------------------------------- /test/integration/Berksfile: -------------------------------------------------------------------------------- 1 | source "https://supermarket.chef.io" 2 | 3 | cookbook "test", path: "cookbooks/test" 4 | -------------------------------------------------------------------------------- /lib/train/globals.rb: -------------------------------------------------------------------------------- 1 | module Train 2 | def self.src_root 3 | File.expand_path(File.join(__FILE__, "..", "..", "..")) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/integration/test-travis-debian.yml: -------------------------------------------------------------------------------- 1 | images: 2 | - debian:6.0.10 3 | - debian:7.11 4 | - debian:8.5 5 | provision: 6 | - script: bootstrap.sh 7 | -------------------------------------------------------------------------------- /lib/train/version.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Dominik Richter () 3 | 4 | module Train 5 | VERSION = "3.14.1".freeze 6 | end 7 | -------------------------------------------------------------------------------- /test/integration/test-travis-centos.yml: -------------------------------------------------------------------------------- 1 | images: 2 | - centos:5.11 3 | - centos:6.8 4 | - centos:7.2.1511 5 | provision: 6 | - script: bootstrap.sh 7 | -------------------------------------------------------------------------------- /test/integration/test-travis-oel.yml: -------------------------------------------------------------------------------- 1 | images: 2 | - oraclelinux:5.11 3 | - oraclelinux:6.8 4 | - oraclelinux:7.2 5 | provision: 6 | - script: bootstrap.sh 7 | -------------------------------------------------------------------------------- /test/integration/test-travis-fedora.yml: -------------------------------------------------------------------------------- 1 | images: 2 | - fedora:20 3 | - fedora:21 4 | - fedora:22 5 | - fedora:23 6 | - fedora:24 7 | provision: 8 | - script: bootstrap.sh 9 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/lib/train-test-fixture/version.rb: -------------------------------------------------------------------------------- 1 | module TrainPlugins 2 | module TestFixture 3 | VERSION = "0.1.0".freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/unit/version_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Train do 4 | it "defines a version" do 5 | _(Train::VERSION).must_be_instance_of String 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/integration/test-travis-ubuntu.yml: -------------------------------------------------------------------------------- 1 | images: 2 | - ubuntu:10.04 3 | - ubuntu:12.04 4 | - ubuntu:14.04 5 | - ubuntu:16.04 6 | - ubuntu:16.10 7 | provision: 8 | - script: bootstrap.sh 9 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/pkg/train-test-fixture-0.1.0.gem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inspec/train/HEAD/test/fixtures/plugins/train-test-fixture/pkg/train-test-fixture-0.1.0.gem -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | train-*.gem 2 | r-train-*.gem 3 | Gemfile.lock 4 | Gemfile.local 5 | .kitchen/ 6 | TAGS 7 | terraform.tfstate.backup 8 | .terraform 9 | .bundle 10 | .gems 11 | coverage/ 12 | Berksfile.lock -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Order is important. The last matching pattern has the most precedence. 2 | 3 | * @inspec/inspec-core-team @chef/foundation-team-reviewers 4 | .expeditor/ @chef/infra-packages 5 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/lib/train-test-fixture.rb: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require "train-test-fixture/transport" 5 | -------------------------------------------------------------------------------- /test/fixtures/ssh_config: -------------------------------------------------------------------------------- 1 | Host localhost1 2 | HostName localhost1 3 | User dummy 4 | Port 2222 5 | IdentityFile /Users/dummy/private_key 6 | Host localhost2 7 | HostName localhost2 8 | User dummy 9 | Port 2222 10 | -------------------------------------------------------------------------------- /test/integration/sudo/run_as.rb: -------------------------------------------------------------------------------- 1 | # author: Dominik Richter 2 | # author: Christoph Hartmann 3 | 4 | require_relative "../helper" 5 | require "train" 6 | 7 | def run_as(cmd, opts = {}) 8 | Train.create("local", opts) 9 | .connection 10 | .run_command(cmd) 11 | end 12 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/test/helper.rb: -------------------------------------------------------------------------------- 1 | # Test helper file for example Train plugins 2 | 3 | # This file's job is to collect any libraries needed for testing, as well as provide 4 | # any utilities to make testing a plugin easier. 5 | 6 | require "train/plugin_test_helper" 7 | -------------------------------------------------------------------------------- /lib/train/logger_ext.rb: -------------------------------------------------------------------------------- 1 | 2 | # Part of Audit Log. 3 | # The default logger implementation injects a comment as the first line of the 4 | # log file, which makes it an invalid JSON file. No way to disable that other than monkey-patching. 5 | 6 | class Logger::LogDevice 7 | def add_log_header(file); end 8 | end -------------------------------------------------------------------------------- /lib/train/platforms/detect.rb: -------------------------------------------------------------------------------- 1 | module Train::Platforms 2 | module Detect 3 | # Main detect method to scan all platforms for a match 4 | # 5 | # @return Train::Platform instance or error if none found 6 | def self.scan(backend) 7 | Scanner.new(backend).scan 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/test/fixtures/README.md: -------------------------------------------------------------------------------- 1 | # Test Fixtures Area 2 | 3 | In this directory, you would place things that you need during testing. 4 | 5 | When writing your functional tests, you can point Train at the various test fixture files. 6 | 7 | To use them, see the helper.rb file included in the example at test/helper.rb . -------------------------------------------------------------------------------- /lib/train/extras.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Dominik Richter () 3 | 4 | module Train::Extras 5 | require_relative "extras/command_wrapper" 6 | require_relative "extras/stat" 7 | 8 | CommandResult = Struct.new(:stdout, :stderr, :exit_status) 9 | LoginCommand = Struct.new(:command, :arguments) 10 | end 11 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | file-lines: 4 | enabled: false 5 | plugins: 6 | fixme: 7 | enabled: true 8 | config: 9 | strings: 10 | - TODO 11 | - rubocop:disable 12 | flog: 13 | enabled: true 14 | markdownlint: 15 | enabled: true 16 | rubocop: 17 | enabled: true 18 | -------------------------------------------------------------------------------- /test/integration/sudo/nopasswd.rb: -------------------------------------------------------------------------------- 1 | require_relative "run_as" 2 | 3 | describe "run_command" do 4 | it "is running as non-root without sudo" do 5 | run_as("whoami").stdout.wont_match(/root/i) 6 | end 7 | 8 | it "is running nopasswd sudo" do 9 | run_as("whoami", { sudo: true }) 10 | .stdout.must_match(/root/i) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.expeditor/buildkite/verify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "--- system details" 4 | uname -a 5 | ruby -v 6 | bundle --version 7 | 8 | echo "--- bundle install" 9 | bundle config --local path vendor/bundle 10 | bundle config --local without integration tools 11 | bundle install --jobs=7 --retry=3 12 | 13 | echo "+++ bundle exec rake" 14 | bundle exec rake ${RAKE_TASK:-} 15 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/README.md: -------------------------------------------------------------------------------- 1 | This is a very simple train transport plugin. It provided fixed responses to file.content and command.stdout/stderr/exit_status. 2 | 3 | It is not a good example to use for learning, nor a good base for starting your own plugin - it's intended for for use during the testing of Train. 4 | 5 | For good examples of plugin development, see train/examples/plugin. -------------------------------------------------------------------------------- /test/integration/test_local.rb: -------------------------------------------------------------------------------- 1 | # author: Dominik Richter 2 | 3 | require_relative "helper" 4 | require "train" 5 | 6 | backends = {} 7 | 8 | backends[:local] = proc { |*opts| 9 | Train.create("local", {}).connection(opts[0]) 10 | } 11 | 12 | tests = ARGV 13 | 14 | backends.each do |type, get_backend| 15 | tests.each do |test| 16 | instance_eval(File.read(test), test, 1) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/lib/train-test-fixture/transport.rb: -------------------------------------------------------------------------------- 1 | require "train-test-fixture/connection" 2 | 3 | module TrainPlugins 4 | module TestFixture 5 | class Transport < Train.plugin(1) 6 | name "test-fixture" 7 | 8 | def connection(_ = nil) 9 | @connection ||= TrainPlugins::TestFixture::Connection.new(@options) 10 | end 11 | 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/integration/sudo/customcommand.rb: -------------------------------------------------------------------------------- 1 | # author: Jeremy Miller 2 | 3 | require_relative "run_as" 4 | 5 | describe "run custom sudo command" do 6 | it "is running as non-root without sudo" do 7 | run_as("whoami").stdout.wont_match(/root/i) 8 | end 9 | 10 | it "is running nopasswd custom sudo command" do 11 | run_as("whoami", { sudo: true, sudo_command: "allyourbase" }) 12 | .stdout.must_match(/root/i) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/lib/train-local-rot13/version.rb: -------------------------------------------------------------------------------- 1 | # This file exists simply to record the version number of the plugin. 2 | # It is kept in a separate file, so that your gemspec can load it and 3 | # learn the current version without loading the whole plugin. Also, 4 | # many CI servers can update this file when "version bumping". 5 | 6 | module TrainPlugins 7 | module LocalRot13 8 | VERSION = "0.1.0".freeze 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/integration/sudo/reqtty.rb: -------------------------------------------------------------------------------- 1 | require_relative "run_as" 2 | 3 | describe "run_command" do 4 | it "is running as non-root without sudo" do 5 | run_as("whoami").stdout.wont_match(/root/i) 6 | end 7 | 8 | it "is throwing an error trying to use sudo" do 9 | err = -> { run_as("whoami", { sudo: true }) }.must_raise Train::UserError 10 | err.message.must_match(/Sudo failed: Sudo requires a TTY. Please see the README/i) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.expeditor/coverage.pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | expeditor: 3 | defaults: 4 | buildkite: 5 | timeout_in_minutes: 45 6 | retry: 7 | automatic: 8 | limit: 1 9 | 10 | steps: 11 | 12 | - label: coverage-ruby-3.1 13 | command: 14 | - CI_ENABLE_COVERAGE=1 RAKE_TASK=test /workdir/.expeditor/buildkite/coverage.sh 15 | expeditor: 16 | secrets: true 17 | executor: 18 | docker: 19 | image: ruby:3.1-bullseye -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/SUPPORT_QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Support Question 3 | about: If you have a question 💬, please check out our Slack! 4 | --- 5 | 6 | We use GitHub issues to track bugs and feature requests. If you need help please post to our Mailing List or join the Chef Community Slack. 7 | 8 | * Chef Community Slack at http://community-slack.chef.io/. 9 | * Chef Mailing List https://discourse.chef.io/ 10 | 11 | Support issues opened here will be closed and redirected to Slack or Discourse. 12 | -------------------------------------------------------------------------------- /.expeditor/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # After a PR merge, Chef Expeditor will bump the PATCH version in the VERSION file. 4 | # It then executes this file to update any other files/components with that new version. 5 | # 6 | 7 | set -evx 8 | 9 | sed -i -r "s/VERSION = \".*\"/VERSION = \"$(cat VERSION)\"/" lib/train/version.rb 10 | 11 | # Once Expeditor finshes executing this script, it will commit the changes and push 12 | # the commit as a new tag corresponding to the value in the VERSION file. 13 | -------------------------------------------------------------------------------- /.expeditor/buildkite/verify.ps1: -------------------------------------------------------------------------------- 1 | echo "--- system details" 2 | $Properties = 'Caption', 'CSName', 'Version', 'BuildType', 'OSArchitecture' 3 | Get-CimInstance Win32_OperatingSystem | Select-Object $Properties | Format-Table -AutoSize 4 | ruby -v 5 | bundle --version 6 | 7 | echo "--- bundle install" 8 | bundle config --local path vendor/bundle 9 | bundle config set --local without tools integration 10 | bundle install --jobs=7 --retry=3 11 | 12 | echo "+++ bundle exec rake" 13 | bundle exec rake 14 | 15 | exit $LASTEXITCODE 16 | -------------------------------------------------------------------------------- /lib/train/file/remote/linux.rb: -------------------------------------------------------------------------------- 1 | require_relative "unix" 2 | 3 | module Train 4 | class File 5 | class Remote 6 | class Linux < Train::File::Remote::Unix 7 | def content 8 | return @content if defined?(@content) 9 | 10 | @content = @backend.run_command("cat #{@spath} || echo -n").stdout 11 | return @content unless @content.empty? 12 | 13 | @content = nil if directory? || size.nil? || (size > 0) 14 | @content 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | if ENV["CI_ENABLE_COVERAGE"] 2 | require "simplecov" 3 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([ 4 | SimpleCov::Formatter::HTMLFormatter, 5 | ]) 6 | 7 | SimpleCov.start do 8 | add_filter "/test/" 9 | end 10 | end 11 | 12 | require "minitest/autorun" 13 | require "minitest/spec" 14 | require "mocha/minitest" 15 | require "train" 16 | 17 | class Minitest::Spec 18 | before do 19 | Train::Platforms.__reset 20 | Train::Platforms::Detect::Specifications::OS.load 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/train/platforms/detect/specifications/api.rb: -------------------------------------------------------------------------------- 1 | module Train::Platforms::Detect::Specifications 2 | class Api 3 | def self.load 4 | plat = Train::Platforms 5 | 6 | plat.family("api") 7 | 8 | plat.family("cloud").in_family("api") 9 | plat.name("aws").in_family("cloud") 10 | plat.name("azure").in_family("cloud") 11 | plat.name("gcp").in_family("cloud") 12 | plat.name("vmware").in_family("cloud") 13 | 14 | plat.family("iaas").in_family("api") 15 | plat.name("oneview").in_family("iaas") 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/integration/sudo/passwd.rb: -------------------------------------------------------------------------------- 1 | require_relative "run_as" 2 | 3 | describe "run_command" do 4 | it "is running as non-root without sudo" do 5 | run_as("whoami").stdout.wont_match(/root/i) 6 | end 7 | 8 | it "is not running sudo without password" do 9 | err = -> { Train.create("local", { sudo: true }).connection }.must_raise Train::UserError 10 | err.message.must_match(/Sudo requires a password/) 11 | end 12 | 13 | it "is running passwd sudo" do 14 | run_as("whoami", { sudo: true, sudo_password: "password" }) 15 | .stdout.must_match(/root/i) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | # must be unique in a given SonarQube instance 2 | sonar.projectKey=inspec_train_AYvrnSZVG2RNgd1H9han 3 | 4 | sonar.projectName=Chef-Inspec-train 5 | 6 | # TODO: provide path to test coverage report generated by simplecov or any other code coverage tool 7 | #sonar.ruby.coverage.reportPaths=coverage/coverage.json 8 | 9 | # exclude test directories from coverage 10 | sonar.coverage.exclusions=test/* 11 | 12 | sonar.exclusions=**/*.java,**/*.js,vendor/* 13 | 14 | # skip C-language processor 15 | sonar.c.file.suffixes=- 16 | sonar.cpp.file.suffixes=- 17 | sonar.objc.file.suffixes=- -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Chef Software Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /test/integration/docker_test_container.rb: -------------------------------------------------------------------------------- 1 | # author: Dominik Richter 2 | 3 | require "train" 4 | require_relative "helper" 5 | 6 | (container_id = ENV["CONTAINER"]) || 7 | raise("You must provide a container ID via CONTAINER env") 8 | 9 | tests = ARGV 10 | puts ["Running tests:", tests].flatten.join("\n- ") 11 | puts "" 12 | 13 | backends = {} 14 | backends[:docker] = proc { |*args| 15 | opt = Train.target_config({ host: container_id }) 16 | Train.create("docker", opt).connection(args[0]) 17 | } 18 | 19 | backends.each do |type, get_backend| 20 | tests.each do |test| 21 | instance_eval(File.read(test), test, 1) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/integration/.kitchen.yml: -------------------------------------------------------------------------------- 1 | --- 2 | driver: 3 | name: vagrant 4 | 5 | provisioner: 6 | name: chef_solo 7 | data_path: ../../. 8 | 9 | platforms: 10 | - name: centos-8 11 | - name: centos-7 12 | - name: centos-6 13 | - name: centos-6-i386 14 | - name: debian-10 15 | - name: debian-10-i386 16 | - name: debian-9 17 | - name: debian-9-i386 18 | - name: fedora-latest 19 | - name: freebsd-11 20 | - name: freebsd-12 21 | - name: opensuse-leap-15 22 | - name: ubuntu-20.04 23 | - name: ubuntu-18.04 24 | - name: ubuntu-16.04 25 | - name: ubuntu-16.04-i386 26 | 27 | suites: 28 | - name: default 29 | run_list: 30 | - recipe[test] -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/lib/train-test-fixture/platform.rb: -------------------------------------------------------------------------------- 1 | require "train-test-fixture/version" 2 | 3 | module TrainPlugins 4 | module TestFixture 5 | module Platform 6 | def platform 7 | # Build this platform's family declarations. 8 | # You'll need at least unix and windows to make the file() resource work. 9 | Train::Platforms.name("test-fixture").in_family("unix") 10 | Train::Platforms.name("test-fixture").in_family("windows") 11 | force_platform!("test-fixture", 12 | release: TrainPlugins::TestFixture::VERSION, 13 | arch: "mock") 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/unit/plugins_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | # rubocop:disable Style/BlockDelimiters 4 | 5 | describe Train::Plugins do 6 | it "provides a method to create new v1 transport plugins" do 7 | _(Train.plugin(1)).must_equal Train::Plugins::Transport 8 | end 9 | 10 | it "fails when called with an unsupported plugin version" do 11 | _ { 12 | Train.plugin(2) 13 | }.must_raise Train::ClientError 14 | end 15 | 16 | it "defaults to v1 plugins" do 17 | _(Train.plugin).must_equal Train::Plugins::Transport 18 | end 19 | 20 | it "provides a registry of plugins" do 21 | _(Train::Plugins.registry).must_be_instance_of(Hash) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/integration/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | tmpdir=${TMPDIR:-/tmp} 4 | 5 | test ! -e $tmpdir/folder && \ 6 | mkdir $tmpdir/folder 7 | chmod 0567 $tmpdir/folder 8 | 9 | echo -n 'hello world' > $tmpdir/file 10 | test ! -e $tmpdir/symlink && \ 11 | ln -s $tmpdir/file /tmp/symlink 12 | chmod 0777 $tmpdir/symlink 13 | chmod 0765 $tmpdir/file 14 | 15 | echo -n 'hello suid/sgid/sticky' > $tmpdir/sfile 16 | chmod 7765 $tmpdir/sfile 17 | 18 | echo -n 'hello space' > $tmpdir/spaced\ file 19 | 20 | test ! -e $tmpdir/pipe && \ 21 | mkfifo $tmpdir/pipe 22 | 23 | test ! -e $tmpdir/block_device && \ 24 | mknod $tmpdir/block_device b 7 7 25 | chmod 0666 $tmpdir/block_device 26 | -------------------------------------------------------------------------------- /test/integration/podman_test_container.rb: -------------------------------------------------------------------------------- 1 | require "train" 2 | require_relative "helper" 3 | 4 | (container_id = ENV["CONTAINER"]) || 5 | raise("You must provide a container ID via CONTAINER env") 6 | 7 | podman_url = ENV["CONTAINER_HOST"] 8 | 9 | tests = ARGV 10 | puts ["Running tests:", tests].flatten.join("\n- ") 11 | puts "" 12 | 13 | backends = {} 14 | backends[:podman] = proc { |*args| 15 | opt = Train.target_config({ host: container_id, podman_url: podman_url }) 16 | Train.create("podman", opt).connection(args[0]) 17 | } 18 | 19 | backends.each do |type, get_backend| 20 | tests.each do |test| 21 | instance_eval(File.read(test), test, 1) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/train/transports/helpers/azure/subscription_id_file_parser.rb: -------------------------------------------------------------------------------- 1 | module Train::Transports 2 | module Helpers 3 | module Azure 4 | class SubscriptionIdFileParser 5 | attr_reader :subscription_id 6 | 7 | def initialize(subscription_id, credentials) 8 | @subscription_id = subscription_id 9 | @credentials = credentials 10 | 11 | validate! 12 | end 13 | 14 | def validate! 15 | if @credentials.sections.empty? || @credentials[subscription_id].empty? 16 | raise "No credentials found for subscription number #{subscription_id}" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/train/transports/helpers/azure/file_parser.rb: -------------------------------------------------------------------------------- 1 | module Train::Transports 2 | module Helpers 3 | module Azure 4 | class FileParser 5 | def initialize(credentials) 6 | @credentials = credentials 7 | 8 | validate! 9 | end 10 | 11 | def validate! 12 | return if @credentials.sections.count == 1 13 | 14 | raise "Credentials file must have one entry. Check your credentials file. If you have more than one entry set AZURE_SUBSCRIPTION_ID environment variable." 15 | end 16 | 17 | def subscription_id 18 | @subscription_id ||= @credentials.sections[0] 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/integration/docker_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "docker_run" 2 | tests = ARGV 3 | 4 | def test_container(container, tests) 5 | puts "--> run test on docker #{container.id}" 6 | pid = Process.fork do 7 | ENV["CONTAINER"] = container.id 8 | require_relative "docker_test_container" 9 | Process.exit 10 | end 11 | 12 | _, status = Process.waitpid2(pid) 13 | status.exitstatus == 0 14 | end 15 | 16 | results = DockerRunner.new.run_all do |name, container| 17 | status = test_container(container, tests) 18 | status ? nil : "Failed to run tests on #{name}" 19 | end 20 | 21 | failures = results.compact 22 | failures.each { |f| puts "\033[31;1m#{f}\033[0m\n\n" } 23 | failures.empty? || raise("Test failures") 24 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # This is Gemfile, which is used by bundler 4 | # to ensure a coherent set of gems is installed. 5 | # This file lists dependencies needed when outside 6 | # of a gem (the gemspec lists deps for gem deployment) 7 | 8 | # Bundler should refer to the gemspec for any dependencies. 9 | gemspec 10 | 11 | # Remaining group is only used for development. 12 | group :development do 13 | gem "bundler" 14 | gem "byebug" 15 | gem "inspec", ">= 2.2.112" # We need InSpec for the test harness while developing. 16 | gem "minitest" 17 | gem "rake" 18 | gem "rubocop", "= 0.49.1" # Need to keep in sync with main InSpec project, so config files will work 19 | end 20 | -------------------------------------------------------------------------------- /lib/train/platforms/family.rb: -------------------------------------------------------------------------------- 1 | module Train::Platforms 2 | class Family 3 | include Train::Platforms::Common 4 | attr_accessor :children, :condition, :families, :name 5 | 6 | def initialize(name, condition) 7 | @name = name 8 | @condition = condition 9 | @families = {} 10 | @children = {} 11 | @detect = nil 12 | @title = "#{name.to_s.capitalize} Family" 13 | 14 | # add itself to the families list 15 | Train::Platforms.families[@name.to_s] = self 16 | end 17 | 18 | def title(title = nil) 19 | return @title if title.nil? 20 | 21 | @title = title 22 | self 23 | end 24 | 25 | def inspect 26 | "%p[%s]" % [self.class, name] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.expeditor/templates/pull_request.mustache: -------------------------------------------------------------------------------- 1 | Hello {{author}}! Thanks for the pull request! 2 | 3 | Here is what will happen next: 4 | 5 | 1. Your PR will be reviewed by the maintainers. 6 | 2. Possible Outcomes 7 | a. If everything looks good, one of them will approve it, and your PR will be merged. 8 | b. The maintainer may request follow-on work (e.g. code fix, linting, etc). We would encourage you to address this work in 2-3 business days to keep the conversation going and to get your contribution in sooner. 9 | c. Cases exist where a PR is neither aligned to Chef InSpec's product roadmap, or something the team can own or maintain long-term. In these cases, the maintainer will provide justification and close out the PR. 10 | 11 | Thank you for contributing! 12 | -------------------------------------------------------------------------------- /lib/train/file/remote/aix.rb: -------------------------------------------------------------------------------- 1 | require_relative "unix" 2 | 3 | module Train 4 | class File 5 | class Remote 6 | class Aix < Train::File::Remote::Unix 7 | def link_path 8 | return nil unless symlink? 9 | 10 | @link_path ||= 11 | @backend.run_command("perl -e 'print readlink shift' #{@spath}").stdout.chomp 12 | end 13 | 14 | def shallow_link_path 15 | return nil unless symlink? 16 | 17 | @shallow_link_path ||= 18 | @backend.run_command("perl -e 'print readlink shift' #{@spath}").stdout.chomp 19 | end 20 | 21 | def mounted 22 | @mounted ||= @backend.run_command("lsfs -c #{@spath}") 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: � Bug Report 3 | about: If something isn't working as expected �. 4 | labels: "Status: Untriaged" 5 | --- 6 | 7 | # Version: 8 | 9 | [Version of the project installed] 10 | 11 | # Environment: 12 | 13 | [Details about the environment such as the Operating System, cookbook details, etc...] 14 | 15 | # Scenario: 16 | 17 | [What you are trying to achieve and you can't?] 18 | 19 | # Steps to Reproduce: 20 | 21 | [If you are filing an issue what are the things we need to do in order to repro your problem?] 22 | 23 | # Expected Result: 24 | 25 | [What are you expecting to happen as the consequence of above reproduction steps?] 26 | 27 | # Actual Result: 28 | 29 | [What actually happens after the reproduction steps?] 30 | -------------------------------------------------------------------------------- /lib/train/audit_log.rb: -------------------------------------------------------------------------------- 1 | module Train 2 | class AuditLog 3 | # Default values for audit log options are set in the options.rb 4 | def self.create(options = {}) 5 | # Load monkey-patch to disable leading comment in logfiles 6 | require_relative "logger_ext" 7 | 8 | logger = Logger.new(options[:audit_log_location], options[:audit_log_frequency], options[:audit_log_size]) 9 | logger.level = options[:level] || Logger::INFO 10 | logger.progname = options[:audit_log_app_name] 11 | logger.datetime_format = "%Y-%m-%d %H:%M:%S" 12 | logger.formatter = proc do |severity, datetime, progname, msg| 13 | { 14 | timestamp: datetime.to_s, 15 | app: progname, 16 | }.merge(msg).compact.to_json + $/ 17 | end 18 | logger 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/ENHANCEMENT_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Enhancement Request 3 | about: I have a suggestion (and may want to implement it 🙂)! 4 | labels: "Status: Untriaged" 5 | --- 6 | 7 | ### Describe the Enhancement: 8 | 9 | 10 | ### Describe the Need: 11 | 12 | 13 | ### Current Alternative 14 | 15 | 16 | ### Can We Help You Implement This?: 17 | 18 | -------------------------------------------------------------------------------- /lib/train/transports/helpers/azure/subscription_number_file_parser.rb: -------------------------------------------------------------------------------- 1 | module Train::Transports 2 | module Helpers 3 | module Azure 4 | class SubscriptionNumberFileParser 5 | def initialize(index, credentials) 6 | @index = index 7 | @credentials = credentials 8 | 9 | validate! 10 | end 11 | 12 | def validate! 13 | if @index == 0 14 | raise "Index must be greater than 0." 15 | end 16 | 17 | if @index > @credentials.sections.length 18 | raise "Your credentials file only contains #{@credentials.sections.length} subscriptions. You specified number #{@index}." 19 | end 20 | end 21 | 22 | def subscription_id 23 | @subscription_id ||= @credentials.sections[@index - 1] 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/lib/train-local-rot13/file_content_rotator.rb: -------------------------------------------------------------------------------- 1 | # Here's our helper class for the file object. This is just some 2 | # silliness specific to the task of applying rot13 to the file content. 3 | 4 | require "rot13" 5 | 6 | module TrainPlugins 7 | module LocalRot13 8 | class FileContentRotator 9 | # The FileContentRotator has-a Train::File 10 | def initialize(train_file) 11 | @train_file = train_file 12 | end 13 | 14 | # We implement content ourselves, rotating the contents of the file 15 | def content 16 | Rot13.rotate(@train_file.content) 17 | end 18 | 19 | # Everything else, we delegate to the Train::File object. 20 | # This is not a safe or efficient implementation. 21 | def method_missing(meth, *args, &block) 22 | @train_file.send(meth, *args, &block) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "bundler" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | ignore: 9 | - dependency-name: "activesupport" 10 | versions: 11 | - ">=6.0.0" 12 | - dependency-name: "google-api-client" 13 | versions: 14 | - ">=0.36" 15 | - dependency-name: "googleauth" 16 | versions: 17 | - ">=0.12" 18 | - dependency-name: "azure_mgmt_resources" 19 | versions: 20 | - ">=0.16" 21 | - dependency-name: "azure_mgmt_security" 22 | versions: 23 | - ">=0.19" 24 | - dependency-name: "azure_mgmt_storage" 25 | versions: 26 | - ">=0.19" 27 | - dependency-name: "azure_mgmt_key_vault" 28 | versions: 29 | - ">=0.18" 30 | - dependency-name: "azure_graph_rbac" 31 | versions: 32 | - ">=0.17" 33 | -------------------------------------------------------------------------------- /test/integration/test_ssh.rb: -------------------------------------------------------------------------------- 1 | # author: Dominik Richter 2 | 3 | require_relative "helper" 4 | require "train" 5 | require "logger" 6 | 7 | backends = {} 8 | backend_conf = { 9 | "target" => ENV["target"] || "vagrant@localhost", 10 | "key_files" => ENV["key_files"] || "/root/.ssh/id_rsa", 11 | "logger" => Logger.new($stdout), 12 | } 13 | 14 | backend_conf["target"] = "ssh://" + backend_conf["target"] 15 | backend_conf["logger"].level = \ 16 | if ENV.key?("debug") 17 | case ENV["debug"].to_s 18 | when /^false$/i, /^0$/i 19 | Logger::INFO 20 | else 21 | Logger::DEBUG 22 | end 23 | else 24 | Logger::INFO 25 | end 26 | 27 | backends[:ssh] = proc { |*args| 28 | conf = Train.target_config(backend_conf) 29 | Train.create("ssh", conf).connection(args[0]) 30 | } 31 | 32 | tests = ARGV 33 | 34 | backends.each do |type, get_backend| 35 | tests.each do |test| 36 | instance_eval(File.read(test), test, 1) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/lib/train-local-rot13.rb: -------------------------------------------------------------------------------- 1 | # This file is known as the "entry point." 2 | # This is the file Train will try to load if it 3 | # thinks your plugin is needed. 4 | 5 | # The *only* thing this file should do is setup the 6 | # load path, then load plugin files. 7 | 8 | # Next two lines simply add the path of the gem to the load path. 9 | # This is not needed when being loaded as a gem; but when doing 10 | # plugin development, you may need it. Either way, it's harmless. 11 | libdir = __dir__ 12 | $LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir) 13 | 14 | # It's traditional to keep your gem version in a separate file, so CI can find it easier. 15 | require "train-local-rot13/version" 16 | 17 | # A train plugin has three components: Transport, Connection, and Platform. 18 | # Transport acts as the glue. 19 | require "train-local-rot13/transport" 20 | require "train-local-rot13/platform" 21 | require "train-local-rot13/connection" 22 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/lib/train-test-fixture/connection.rb: -------------------------------------------------------------------------------- 1 | require "train-test-fixture/platform" 2 | require "train/transports/local" 3 | 4 | module TrainPlugins 5 | module TestFixture 6 | class Connection < Train::Plugins::Transport::BaseConnection 7 | include TrainPlugins::TestFixture::Platform 8 | 9 | def initialize(options) 10 | super(options) 11 | end 12 | 13 | private 14 | 15 | def run_command_via_connection(cmd) 16 | Train::Transports::Local::CommandResult.new( 17 | "Mock Command Result stdout", 18 | "Mock Command Result stderr", 19 | 17 20 | ) 21 | end 22 | 23 | def file_via_connection(path, *args) 24 | MockFile.new(self, path) 25 | end 26 | 27 | class MockFile < Train::File 28 | def content 29 | # Remarkably, the content is always the same. 30 | "Lorem Ipsum" 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/unit/platforms/family_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe "platform family" do 4 | def mock_family(x) 5 | Train::Platforms.families[x] = nil if x == "mock" 6 | Train::Platforms.family(x) 7 | end 8 | 9 | it "set family title" do 10 | plat = mock_family("mock") 11 | _(plat.title).must_equal("Mock Family") 12 | plat.title("The Best Mock Family") 13 | _(plat.title).must_equal("The Best Mock Family") 14 | end 15 | 16 | it "set family in a family" do 17 | plat = mock_family("family1") 18 | plat.in_family("family2") 19 | _(plat.families.keys[0].name).must_equal("family2") 20 | 21 | plat = mock_family("family2") 22 | _(plat.children.keys[0].name).must_equal("family1") 23 | end 24 | 25 | it "set family in a family with condition" do 26 | plat = Train::Platforms.family("family4", arch: "= x68_64").in_family("family5") 27 | _(plat.families.keys[0].name).must_equal("family5") 28 | _(plat.families.values[0]).must_equal({ arch: "= x68_64" }) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/train/platforms/common.rb: -------------------------------------------------------------------------------- 1 | module Train::Platforms 2 | module Common 3 | # Add a family connection. This will create a family 4 | # if it does not exist and add a child relationship. 5 | def in_family(family) 6 | if self.class == Train::Platforms::Family && @name == family 7 | raise "Unable to add family #{@name} to itself: '#{@name}.in_family(#{family})'" 8 | end 9 | 10 | # add family to the family list 11 | family = Train::Platforms.family(family) 12 | family.children[self] = @condition 13 | 14 | @families[family] = @condition 15 | @condition = nil 16 | self 17 | end 18 | 19 | def detect(&block) 20 | @detect = block if block 21 | 22 | # TODO: detect shouldn't be a setter and getter at the same time 23 | @detect ||= ->(_) { false } 24 | end 25 | 26 | def to_s 27 | be = backend ? backend.backend_type : "unknown" 28 | "%s:%s:%s" % [self.class, be, name] 29 | end 30 | 31 | def inspect 32 | to_s 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/train/file/remote.rb: -------------------------------------------------------------------------------- 1 | module Train 2 | class File 3 | class Remote < Train::File 4 | def basename(suffix = nil, sep = "/") 5 | raise "Not yet supported: Suffix in file.basename" unless suffix.nil? 6 | 7 | @basename ||= detect_filename(path, sep || "/") 8 | end 9 | 10 | def stat 11 | return @stat if defined?(@stat) 12 | 13 | @stat = Train::Extras::Stat.stat(@spath, @backend, @follow_symlink) 14 | end 15 | 16 | # helper methods provided to any implementing class 17 | private 18 | 19 | def detect_filename(path, sep) 20 | idx = path.rindex(sep) 21 | return path if idx.nil? 22 | 23 | idx += 1 24 | return detect_filename(path[0..-2], sep) if idx == path.length 25 | 26 | path[idx..-1] 27 | end 28 | end 29 | end 30 | end 31 | 32 | # subclass requires are loaded after Train::File::Remote is defined 33 | # to avoid superclass mismatch errors 34 | require_relative "remote/aix" 35 | require_relative "remote/linux" 36 | require_relative "remote/qnx" 37 | require_relative "remote/unix" 38 | require_relative "remote/windows" 39 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/lib/train-local-rot13/transport.rb: -------------------------------------------------------------------------------- 1 | # Train Plugins v1 are usually declared under the TrainPlugins namespace. 2 | # Each plugin has three components: Transport, Connection, and Platform. 3 | # We'll only define the Transport here, but we'll refer to the others. 4 | require "train-local-rot13/connection" 5 | 6 | module TrainPlugins 7 | module LocalRot13 8 | class Transport < Train.plugin(1) 9 | name "local-rot13" 10 | 11 | # The only thing you MUST do in a transport is a define a 12 | # connection() method that returns a instance that is a 13 | # subclass of BaseConnection. 14 | 15 | # The options passed to this are undocumented and rarely used. 16 | def connection(_instance_opts = nil) 17 | # Typical practice is to cache the connection as an instance variable. 18 | # Do what makes sense for your platform. 19 | # @options here is the parsed options that the calling 20 | # app handed to us at process invocation. See the Connection class 21 | # for more details. 22 | @connection ||= TrainPlugins::LocalRot13::Connection.new(@options) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/train/file/remote/qnx.rb: -------------------------------------------------------------------------------- 1 | # 2 | # author: Christoph Hartmann 3 | # author: Dominik Richter 4 | 5 | require_relative "unix" 6 | 7 | module Train 8 | class File 9 | class Remote 10 | class Qnx < Train::File::Remote::Unix 11 | def content 12 | cat = "cat" 13 | cat = "/proc/boot/cat" if @backend.os[:release].to_i >= 7 14 | @content ||= case 15 | when !exist? 16 | nil 17 | else 18 | @backend.run_command("#{cat} #{@spath}").stdout || "" 19 | end 20 | end 21 | 22 | def type 23 | if @backend.run_command("file #{@spath}").stdout.include?("directory") 24 | :directory 25 | else 26 | :file 27 | end 28 | end 29 | 30 | %w{ 31 | mode owner group uid gid mtime size selinux_label link_path mounted stat 32 | }.each do |field| 33 | define_method field.to_sym do 34 | raise NotImplementedError, "QNX does not implement the #{field}() method yet." 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/train/platforms/detect/uuid.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" unless defined?(Digest::SHA1) 2 | require "securerandom" unless defined?(SecureRandom) 3 | require "json" unless defined?(JSON) 4 | 5 | module Train::Platforms::Detect 6 | class UUID 7 | include Train::Platforms::Detect::Helpers::OSCommon 8 | 9 | def initialize(platform) 10 | @platform = platform 11 | @backend = @platform.backend 12 | end 13 | 14 | def find_or_create_uuid 15 | # for api transports uuid is defined on the connection 16 | if defined?(@backend.unique_identifier) 17 | uuid_from_string(@backend.unique_identifier) 18 | elsif @platform.unix? 19 | unix_uuid 20 | elsif @platform.windows? 21 | windows_uuid 22 | else 23 | # Checking "unknown" :uuid_command which is set for mock transport. 24 | if @platform[:uuid_command] && !@platform[:uuid_command] == "unknown" 25 | result = @backend.run_command(@platform[:uuid_command]) 26 | return uuid_from_string(result.stdout.chomp) if result.exit_status == 0 && !result.stdout.empty? 27 | end 28 | 29 | raise Train::PlatformUuidDetectionFailed.new("Could not find platform uuid! Please set a uuid_command for your platform.") 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/integration/cookbooks/test/recipes/prep_files.rb: -------------------------------------------------------------------------------- 1 | # author: Dominik Richter 2 | # author: Christoph Hartmann 3 | # 4 | # Helper recipe to create create a few files in the operating 5 | # systems, which the runner will test against. 6 | 7 | gid = platform_family?("aix") ? "system" : node["root_group"] 8 | 9 | file "/tmp/file" do 10 | mode "0765" 11 | owner "root" 12 | group gid 13 | content "hello world" 14 | end 15 | 16 | file "/tmp/sfile" do 17 | mode "7765" 18 | owner "root" 19 | group gid 20 | content "hello suid/sgid/sticky" 21 | end 22 | 23 | file "/tmp/spaced file" do 24 | content "hello space" 25 | end 26 | 27 | directory "/tmp/folder" do 28 | mode "0567" 29 | owner "root" 30 | group gid 31 | end 32 | 33 | link "/tmp/symlink" do 34 | to "/tmp/file" 35 | owner "root" 36 | group gid 37 | mode "0777" 38 | end 39 | 40 | link "/usr/bin/allyourbase" do 41 | to "/usr/bin/sudo" 42 | owner "root" 43 | group gid 44 | mode "0777" 45 | end 46 | 47 | execute "create pipe/fifo" do 48 | command "mkfifo /tmp/pipe" 49 | not_if "test -e /tmp/pipe" 50 | end 51 | 52 | execute "create block_device" do 53 | command "mknod /tmp/block_device b 7 7 && chmod 0666 /tmp/block_device && chown root:#{gid} /tmp/block_device" 54 | not_if "test -e /tmp/block_device" 55 | end 56 | -------------------------------------------------------------------------------- /lib/train/transports/clients/azure/graph_rbac.rb: -------------------------------------------------------------------------------- 1 | require "azure_graph_rbac" 2 | 3 | # Wrapper class for ::Azure::GraphRbac::Profiles::Latest::Client allowing custom configuration, 4 | # for example, defining additional settings for the ::MsRestAzure::ApplicationTokenProvider. 5 | class GraphRbac 6 | AUTH_ENDPOINT = MsRestAzure::AzureEnvironments::AzureCloud.active_directory_endpoint_url 7 | API_ENDPOINT = MsRestAzure::AzureEnvironments::AzureCloud.active_directory_graph_resource_id 8 | 9 | def self.client(credentials) 10 | credentials[:credentials] = ::MsRest::TokenCredentials.new(provider(credentials)) 11 | credentials[:base_url] = API_ENDPOINT 12 | 13 | ::Azure::GraphRbac::Profiles::Latest::Client.new(credentials) 14 | end 15 | 16 | def self.provider(credentials) 17 | ::MsRestAzure::ApplicationTokenProvider.new( 18 | credentials[:tenant_id], 19 | credentials[:client_id], 20 | credentials[:client_secret], 21 | settings 22 | ) 23 | end 24 | 25 | def self.settings 26 | client_settings = MsRestAzure::ActiveDirectoryServiceSettings.get_azure_settings 27 | client_settings.authentication_endpoint = AUTH_ENDPOINT 28 | client_settings.token_audience = API_ENDPOINT 29 | client_settings 30 | end 31 | 32 | private_class_method :provider, :settings 33 | end 34 | -------------------------------------------------------------------------------- /test/fixtures/plugins/train-test-fixture/train-test-fixture.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("lib", __dir__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "train-test-fixture" 7 | spec.version = "0.1.0" 8 | spec.authors = ["Inspec core engineering team"] 9 | spec.email = ["hello@chef.io"] 10 | spec.license = "Apache-2.0" 11 | 12 | spec.summary = %q{Test train plugin. Not intended for use as an example.} 13 | spec.description = <<-EOD 14 | Train plugin used in testing Train's plugin loader and InSpec's plugin manager. 15 | This plugin does things that a normal plugin should not. Do not use it as an 16 | example or as a starting point for plugin of your own. For that, please see 17 | https://github.com/inspec/train/tree/master/examples/plugins 18 | EOD 19 | spec.homepage = "https://github.com/inspec/train" 20 | 21 | spec.files = %w{ 22 | README.md 23 | LICENSE 24 | lib/train-test-fixture.rb 25 | lib/train-test-fixture/version.rb 26 | lib/train-test-fixture/transport.rb 27 | lib/train-test-fixture/connection.rb 28 | lib/train-test-fixture/platform.rb 29 | train-test-fixture.gemspec 30 | } 31 | spec.executables = [] 32 | spec.require_paths = ["lib"] 33 | 34 | # No deps 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/ci-main-pull-request-stub-trufflehog-only.yml: -------------------------------------------------------------------------------- 1 | # This stub runs only the TruffleHog scan as part of CI checks on pull requests to main branch. 2 | 3 | name: CI Pull Request – TruffleHog Only 4 | 5 | on: 6 | pull_request: 7 | branches: [ main ] 8 | push: 9 | branches: [ main ] 10 | 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | call-ci-main-pr-check-pipeline: 18 | uses: chef/common-github-actions/.github/workflows/ci-main-pull-request.yml@main 19 | secrets: inherit 20 | permissions: 21 | id-token: write 22 | contents: read 23 | with: 24 | visibility: ${{ github.event.repository.visibility }} 25 | 26 | # Enabled features 27 | perform-trufflehog-scan: true 28 | generate-sbom: true 29 | export-github-sbom: true 30 | 31 | # All other features 32 | perform-complexity-checks: false 33 | perform-language-linting: false 34 | perform-blackduck-polaris: false 35 | perform-blackduck-sca-scan: false 36 | build: false 37 | unit-tests: false 38 | perform-sonarqube-scan: false 39 | report-to-atlassian-dashboard: false 40 | package-binaries: false 41 | habitat-build: false 42 | publish-packages: false 43 | generate-blackduck-sbom: false 44 | generate-msft-sbom: false 45 | license_scout: false -------------------------------------------------------------------------------- /lib/train/plugins.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Dominik Richter () 3 | # Author:: Christoph Hartmann () 4 | 5 | require_relative "errors" 6 | 7 | module Train 8 | class Plugins 9 | require_relative "plugins/transport" 10 | 11 | class << self 12 | # Retrieve the current plugin registry, containing all plugin names 13 | # and their transport handlers. 14 | # 15 | # @return [Hash] map with plugin names and plugins 16 | def registry 17 | @registry ||= {} 18 | end 19 | end 20 | end 21 | 22 | # Create a new plugin by inheriting from the class returned by this method. 23 | # Create a versioned plugin by providing the transport layer plugin version 24 | # to this method. It will then select the correct class to inherit from. 25 | # 26 | # The plugin version determines what methods will be available to your plugin. 27 | # 28 | # @param [Int] version = 1 the plugin version to use 29 | # @return [Transport] the versioned transport base class 30 | def self.plugin(version = 1) 31 | if version != 1 32 | raise ClientError, 33 | "Only understand train plugin version 1. You are trying to "\ 34 | "initialize a train plugin #{version}, which is not supported "\ 35 | "in the current release of train." 36 | end 37 | ::Train::Plugins::Transport 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec name: "train" 3 | 4 | group :test do 5 | gem "minitest", "~> 5.8" 6 | gem "rake", "~> 13.0" 7 | gem "chefstyle", "2.2.3" 8 | gem "concurrent-ruby", "~> 1.0" 9 | if Gem.ruby_version < Gem::Version.new("3.1.0") 10 | # byebug 12.0.0+ requires Ruby 3.1+ 11 | gem "byebug", "~> 11.0" 12 | else 13 | gem "byebug" 14 | end 15 | gem "m" 16 | gem "ed25519" # ed25519 ssh key support 17 | gem "bcrypt_pbkdf" # ed25519 ssh key support 18 | gem "x25519" # curve25519-sha256 ssh key support done here as its a native gem we can't put in the gemspec 19 | # This is not a true gem installation 20 | # (Gem::Specification.find_by_path('train-gem-fixture') will return nil) 21 | # but it's close enough to show the gempath handler can find a plugin 22 | # See test/unit/ 23 | gem "train-test-fixture", path: "test/fixtures/plugins/train-test-fixture" 24 | gem "mocha", "~> 2.1" 25 | gem "simplecov", "~> 0.21" 26 | gem "simplecov_json_formatter" 27 | end 28 | # uncomment when you resume integration testing 29 | # if Gem.ruby_version >= Gem::Version.new("2.7.0") 30 | # group :integration do 31 | # gem "berkshelf", ">= 6.0" 32 | # gem "test-kitchen", ">= 2" 33 | # gem "kitchen-vagrant" 34 | # end 35 | # end 36 | 37 | group :tools do 38 | gem "pry", "~> 0.10" 39 | gem "rb-readline" 40 | gem "license_finder" 41 | end 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DESIGN_PROPOSAL.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Design Proposal 3 | about: I have a significant change I would like to propose and discuss before starting 4 | labels: "Status: Untriaged" 5 | --- 6 | 7 | ### When a Change Needs a Design Proposal 8 | 9 | A design proposal should be opened any time a change meets one of the following qualifications: 10 | 11 | - Significantly changes the user experience of a project in a way that impacts users. 12 | - Significantly changes the underlying architecture of the project in a way that impacts other developers. 13 | - Changes the development or testing process of the project such as a change of CI systems or test frameworks. 14 | 15 | ### Why We Use This Process 16 | 17 | - Allows all interested parties (including any community member) to discuss large impact changes to a project. 18 | - Serves as a durable paper trail for discussions regarding project architecture. 19 | - Forces design discussions to occur before PRs are created. 20 | - Reduces PR refactoring and rejected PRs. 21 | 22 | --- 23 | 24 | 25 | 26 | ## Motivation 27 | 28 | 33 | 34 | ## Specification 35 | 36 | 37 | 38 | ## Downstream Impact 39 | 40 | 41 | -------------------------------------------------------------------------------- /train-core.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "train/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "train-core" 7 | spec.version = Train::VERSION 8 | spec.authors = ["Chef InSpec Team"] 9 | spec.email = ["inspec@chef.io"] 10 | spec.summary = "Transport interface to talk to a selected set of backends." 11 | spec.description = "A minimal Train with a backends for ssh and winrm." 12 | spec.license = "Apache-2.0" 13 | 14 | spec.metadata = { 15 | "homepage_uri" => "https://github.com/inspec/train", 16 | "changelog_uri" => "https://github.com/inspec/train/blob/master/CHANGELOG.md", 17 | "source_code_uri" => "https://github.com/inspec/train", 18 | "bug_tracker_uri" => "https://github.com/inspec/train/issues", 19 | } 20 | 21 | spec.required_ruby_version = ">= 3.1.0" 22 | 23 | spec.files = Dir.glob("{LICENSE,lib/**/*}") 24 | .grep_v(%r{transports/(azure|clients|docker|podman|gcp|helpers|vmware)}) 25 | .reject { |f| File.directory?(f) } 26 | 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_dependency "addressable", "~> 2.5" 30 | spec.add_dependency "ffi", ">= 1.16.0", "< 1.18" 31 | spec.add_dependency "json", ">= 1.8", "< 3.0" 32 | spec.add_dependency "mixlib-shellout", ">= 2.0", "< 4.0" 33 | spec.add_dependency "net-scp", ">= 1.2", "< 5.0" 34 | spec.add_dependency "net-ssh", ">= 2.9", "< 8.0" 35 | end 36 | -------------------------------------------------------------------------------- /contrib/fixup_requiretty.rb: -------------------------------------------------------------------------------- 1 | # author: Stephan Renatus 2 | # 3 | # chef-apply script to fix sudoers configuration 4 | # 5 | # This script can be used to setup a user's sudoers configuration to allow for 6 | # using non-interactive sessions. It's main use case is fixing the default 7 | # configuration on RHEL and SEL distributions. 8 | # 9 | # The user name has to be provided in the env variable "TRAIN_SUDO_USER". 10 | # If any configuration for the user is present (user is in /etc/sudoers or 11 | # /etc/sudoers.d/user exists), this script will do nothing 12 | # (unless you set TRAIN_SUDO_VERY_MUCH=yes) 13 | 14 | # FIXME 15 | user = ENV["TRAIN_SUDO_USER"] || "todo-some-clever-default-maybe-current-user" 16 | sudoer = "/etc/sudoers.d/#{user}" 17 | 18 | log "Warning: a sudoers configuration for user #{user} already exists, "\ 19 | "doing nothing (override with TRAIN_SUDO_VERY_MUCH=yes)" do 20 | only_if "test -f #{sudoer} || grep #{user} /etc/sudoers" 21 | end 22 | 23 | file sudoer do 24 | content "#{user} ALL=(root) NOPASSWD:ALL\n"\ 25 | "Defaults:#{user} !requiretty\n" 26 | mode 0600 27 | action ENV["TRAIN_SUDO_VERY_MUCH"] == "yes" ? :create : :create_if_missing 28 | 29 | # Do not add something here if the user is mentioned explicitly in /etc/sudoers 30 | not_if "grep #{user} /etc/sudoers" 31 | end 32 | 33 | # /!\ broken files in /etc/sudoers.d/ will break sudo for ALL USERS /!\ 34 | execute "revert: delete the file if it's broken" do 35 | command "rm #{sudoer}" 36 | not_if "visudo -c -f #{sudoer}" # file is ok 37 | end 38 | -------------------------------------------------------------------------------- /test/integration/tests/path_pipe_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "pipe / fifo" do 5 | let(:file) { backend.file("/tmp/pipe") } 6 | 7 | it "exists" do 8 | _(file.exist?).must_equal(true) 9 | end 10 | 11 | it "is a pipe" do 12 | _(file.pipe?).must_equal(true) 13 | end 14 | 15 | it "has type :pipe" do 16 | _(file.type).must_equal(:pipe) 17 | end 18 | 19 | it "has owner name root" do 20 | _(file.owner).must_equal("root") 21 | end 22 | 23 | it "has group name" do 24 | _(file.group).must_equal(Test.root_group(backend.os)) 25 | end 26 | 27 | it "has mode 0644" do 28 | _(file.mode).must_equal(00644) 29 | end 30 | 31 | it "checks mode? 0644" do 32 | _(file.mode?(00644)).must_equal(true) 33 | end 34 | 35 | it "has no link_path" do 36 | _(file.link_path).must_be_nil 37 | end 38 | 39 | it "has a modified time" do 40 | _(file.mtime).must_be_close_to(Time.now.to_i - Test.mtime / 2, Test.mtime) 41 | end 42 | 43 | it "has inode size of 0" do 44 | _(file.size).must_equal(0) 45 | end 46 | 47 | it "has selinux label handling" do 48 | res = Test.selinux_label(backend, file.path) 49 | _(file.selinux_label).must_equal(res) 50 | end 51 | 52 | it "has no product_version" do 53 | _(file.product_version).must_be_nil 54 | end 55 | 56 | it "has no file_version" do 57 | _(file.file_version).must_be_nil 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/train/transports/clients/azure/vault.rb: -------------------------------------------------------------------------------- 1 | require "azure_mgmt_key_vault" 2 | 3 | # Wrapper class for ::Azure::KeyVault::Profiles::Latest::Mgmt::Client allowing custom configuration, 4 | # for example, defining additional settings for the ::MsRestAzure::ApplicationTokenProvider. 5 | class Vault 6 | AUTH_ENDPOINT = MsRestAzure::AzureEnvironments::AzureCloud.active_directory_endpoint_url 7 | RESOURCE_ENDPOINT = "https://vault.azure.net".freeze 8 | 9 | def self.client(vault_name, credentials) 10 | raise ::Train::UserError, "Vault Name cannot be nil" if vault_name.nil? 11 | 12 | credentials[:credentials] = ::MsRest::TokenCredentials.new(provider(credentials)) 13 | credentials[:base_url] = api_endpoint(vault_name) 14 | 15 | ::Azure::KeyVault::Profiles::Latest::Mgmt::Client.new(credentials) 16 | end 17 | 18 | def self.provider(credentials) 19 | ::MsRestAzure::ApplicationTokenProvider.new( 20 | credentials[:tenant_id], 21 | credentials[:client_id], 22 | credentials[:client_secret], 23 | settings 24 | ) 25 | end 26 | 27 | def self.api_endpoint(vault_name) 28 | "https://#{vault_name}#{MsRestAzure::AzureEnvironments::AzureCloud.key_vault_dns_suffix}" 29 | end 30 | 31 | def self.settings 32 | client_settings = MsRestAzure::ActiveDirectoryServiceSettings.get_azure_settings 33 | client_settings.authentication_endpoint = AUTH_ENDPOINT 34 | client_settings.token_audience = RESOURCE_ENDPOINT 35 | client_settings 36 | end 37 | 38 | private_class_method :provider, :api_endpoint, :settings 39 | end 40 | -------------------------------------------------------------------------------- /test/integration/tests/run_command_test.rb: -------------------------------------------------------------------------------- 1 | describe "run_command" do 2 | let(:backend) { get_backend.call } 3 | 4 | it "can echo commands" do 5 | res = backend.run_command("echo hello world") 6 | _(res.stdout).must_equal("hello world\n") 7 | _(res.stderr).must_equal("") 8 | _(res.exit_status).must_equal(0) 9 | end 10 | 11 | it "can run frozen commands" do 12 | res = backend.run_command("echo hello world".freeze) 13 | _(res.stdout).must_equal("hello world\n") 14 | _(res.stderr).must_equal("") 15 | _(res.exit_status).must_equal(0) 16 | end 17 | 18 | it "can echo commands to stderr" do 19 | # TODO: Specinfra often fails on this test. 20 | # Fix and re-enable it. 21 | res = backend.run_command(">&2 echo hello world") 22 | _(res.stdout).must_equal("") 23 | _(res.stderr).must_equal("hello world\n") 24 | _(res.exit_status).must_equal(0) 25 | end 26 | 27 | it "prints a correct exit status" do 28 | res = backend.run_command("exit 123") 29 | _(res.stdout).must_equal("") 30 | _(res.stderr).must_equal("") 31 | _(res.exit_status).must_equal(123) 32 | end 33 | 34 | it "completes a command with ample timeout" do 35 | res = backend.run_command("echo hello world", timeout: 5) 36 | _(res.stdout).must_equal("hello world\n") 37 | _(res.stderr).must_equal("") 38 | _(res.exit_status).must_equal(0) 39 | end 40 | 41 | it "raises CommandTimeoutReached on timeout" do 42 | assert_raises Train::CommandTimeoutReached do 43 | backend.run_command("sleep 2", timeout: 1) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/train/transports/helpers/azure/file_credentials.rb: -------------------------------------------------------------------------------- 1 | require "inifile" 2 | require_relative "file_parser" 3 | require_relative "subscription_number_file_parser" 4 | require_relative "subscription_id_file_parser" 5 | 6 | module Train::Transports 7 | module Helpers 8 | module Azure 9 | class FileCredentials 10 | def self.parse(subscription_id: nil, credentials_file: nil, **_) 11 | return {} if credentials_file.nil? 12 | return {} unless ::File.readable?(credentials_file) 13 | 14 | credentials = IniFile.load(::File.expand_path(credentials_file)) 15 | subscription_id = parser(subscription_id, ENV["AZURE_SUBSCRIPTION_NUMBER"], credentials).subscription_id 16 | creds(subscription_id, credentials) 17 | end 18 | 19 | def self.parser(subscription_id, subscription_number, credentials) 20 | if subscription_id 21 | SubscriptionIdFileParser.new(subscription_id, credentials) 22 | elsif !subscription_number.nil? 23 | SubscriptionNumberFileParser.new(subscription_number.to_i, credentials) 24 | else 25 | FileParser.new(credentials) 26 | end 27 | end 28 | 29 | def self.creds(subscription_id, credentials) 30 | { 31 | subscription_id: subscription_id, 32 | tenant_id: credentials[subscription_id]["tenant_id"], 33 | client_id: credentials[subscription_id]["client_id"], 34 | client_secret: credentials[subscription_id]["client_secret"], 35 | } 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/Rakefile: -------------------------------------------------------------------------------- 1 | # A Rakefile defines tasks to help maintain your project. 2 | # Rake provides several task templates that are useful. 3 | 4 | #------------------------------------------------------------------# 5 | # Test Runner Tasks 6 | #------------------------------------------------------------------# 7 | 8 | # This task template will make a task named 'test', and run 9 | # the tests that it finds. 10 | require "rake/testtask" 11 | 12 | Rake::TestTask.new do |t| 13 | t.libs.push "lib" 14 | t.test_files = FileList[ 15 | "test/unit/*_test.rb", 16 | "test/integration/*_test.rb", 17 | "test/functional/*_test.rb", 18 | ] 19 | t.verbose = true 20 | # Ideally, we'd run tests with warnings enabled, 21 | # but the dependent gems have many warnings. As this 22 | # is an example, let's disable them so the testing 23 | # experience is cleaner. 24 | t.warning = false 25 | end 26 | 27 | #------------------------------------------------------------------# 28 | # Code Style Tasks 29 | #------------------------------------------------------------------# 30 | require "rubocop/rake_task" 31 | 32 | RuboCop::RakeTask.new(:lint) do |t| 33 | # Choices of rubocop rules to enforce are deeply personal. 34 | # Here, we set things up so that your plugin will use the Bundler-installed 35 | # train gem's copy of the Train project's rubocop.yml file (which 36 | # is indeed packaged with the train gem). 37 | require "train/globals" 38 | train_rubocop_yml = File.join(Train.src_root, ".rubocop.yml") 39 | 40 | t.options = ["--display-cop-names", "--config", train_rubocop_yml] 41 | end 42 | -------------------------------------------------------------------------------- /.expeditor/verify.pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | expeditor: 3 | defaults: 4 | buildkite: 5 | timeout_in_minutes: 20 6 | retry: 7 | automatic: 8 | limit: 1 9 | steps: 10 | 11 | # make sure lint runs on the oldest Ruby we support so we catch any new Ruby-isms here 12 | - label: lint-ruby-3.1 13 | command: 14 | - RAKE_TASK=lint /workdir/.expeditor/buildkite/verify.sh 15 | expeditor: 16 | executor: 17 | docker: 18 | image: ruby:3.1-bullseye 19 | 20 | 21 | - label: run-tests-ruby-3.1 22 | command: 23 | - /workdir/.expeditor/buildkite/verify.sh 24 | expeditor: 25 | executor: 26 | docker: 27 | image: ruby:3.1-bullseye 28 | 29 | - label: run-tests-ruby-3.4 30 | command: 31 | - /workdir/.expeditor/buildkite/verify.sh 32 | expeditor: 33 | executor: 34 | docker: 35 | image: ruby:3.4-bullseye 36 | 37 | 38 | - label: run-tests-ruby-3.1-windows 39 | command: 40 | - /workdir/.expeditor/buildkite/verify.ps1 41 | expeditor: 42 | executor: 43 | docker: 44 | environment: 45 | - BUILDKITE 46 | host_os: windows 47 | shell: ["powershell", "-Command"] 48 | image: rubydistros/windows-2019:3.1 49 | 50 | - label: run-tests-ruby-3.4-windows 51 | command: 52 | - /workdir/.expeditor/buildkite/verify.ps1 53 | expeditor: 54 | executor: 55 | docker: 56 | environment: 57 | - BUILDKITE 58 | host_os: windows 59 | shell: [ "powershell", "-Command" ] 60 | image: rubydistros/windows-2019:3.4 61 | 62 | -------------------------------------------------------------------------------- /lib/train/errors.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Fletcher Nichol () 3 | # Author:: Dominik Richter () 4 | # Author:: Christoph Hartmann () 5 | # 6 | # Copyright (C) 2013, Fletcher Nichol 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | 10 | module Train 11 | # Base exception for any exception explicitly raised by the Train library. 12 | class Error < ::StandardError 13 | attr_reader :reason 14 | 15 | def initialize(message = "", reason = :not_provided) 16 | super(message) 17 | @reason = reason 18 | end 19 | end 20 | 21 | # Base exception class for all exceptions that are caused by user input 22 | # errors. 23 | class UserError < Error; end 24 | 25 | # We could not load a plugin, because of a user error 26 | class PluginLoadError < UserError 27 | attr_accessor :transport_name 28 | end 29 | 30 | # Base exception class for all exceptions that are caused by incorrect use 31 | # of an API. 32 | class ClientError < Error; end 33 | 34 | # Base exception class for all exceptions that are caused by other failures 35 | # in the transport layer. 36 | class TransportError < Error; end 37 | 38 | # Exception for when no platform can be detected. 39 | class PlatformDetectionFailed < Error; end 40 | 41 | # Exception for when no uuid for the platform can be detected. 42 | class PlatformUuidDetectionFailed < Error; end 43 | 44 | # Exception for when a invalid cache type is passed. 45 | class UnknownCacheType < Error; end 46 | 47 | # Exception for when a command reaches configured timeout 48 | class CommandTimeoutReached < Error; end 49 | end 50 | -------------------------------------------------------------------------------- /test/integration/helper.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "minitest/spec" 3 | 4 | # Tests configuration: 5 | module Test 6 | class << self 7 | # MTime tracks the maximum range of modification time in seconds. 8 | # i.e. MTime == 60*60*1 is 1 hour of modification time range, 9 | # which translates to a modification time range of: 10 | # [ now-1hour, now ] 11 | def mtime 12 | 60 * 60 * 24 * 1 13 | end 14 | 15 | def dup(o) 16 | Marshal.load(Marshal.dump(o)) 17 | end 18 | 19 | def root_group(os) 20 | case os[:family] 21 | when "freebsd" 22 | "wheel" 23 | when "aix" 24 | "system" 25 | else 26 | "root" 27 | end 28 | end 29 | 30 | def selinux_label(backend, path = nil) 31 | return nil if backend.class.to_s =~ /docker/i 32 | 33 | os = backend.os 34 | labels = {} 35 | 36 | h = {} 37 | h.default = Hash.new(nil) 38 | h["redhat"] = {} 39 | h["redhat"].default = "unconfined_u:object_r:user_tmp_t:s0" 40 | h["redhat"]["5.11"] = "user_u:object_r:tmp_t" 41 | h["centos"] = h["fedora"] = h["redhat"] 42 | labels.default = dup(h) 43 | 44 | h["redhat"].default = "unconfined_u:object_r:tmp_t:s0" 45 | labels["/tmp/block_device"] = dup(h) 46 | 47 | h = {} 48 | h.default = Hash.new(nil) 49 | h["redhat"] = {} 50 | h["redhat"].default = "system_u:object_r:null_device_t:s0" 51 | h["redhat"]["5.11"] = "system_u:object_r:null_device_t" 52 | h["centos"] = h["fedora"] = h["redhat"] 53 | labels["/dev/null"] = dup(h) 54 | 55 | labels[path][os[:family]][os[:release]] 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/test/unit/connection_test.rb: -------------------------------------------------------------------------------- 1 | # This is a unit test for the example Train plugin, LocalRot13. 2 | # Its job is to verify that the Connection class is setup correctly. 3 | 4 | # Include our test harness 5 | require_relative "../helper" 6 | 7 | # Load the class under test, the Connection definition. 8 | require "train-local-rot13/connection" 9 | 10 | # Because InSpec is a Spec-style test suite, we're going to use MiniTest::Spec 11 | # here, for familiar look and feel. However, this isn't InSpec (or RSpec) code. 12 | describe TrainPlugins::LocalRot13::Connection do 13 | 14 | # When writing tests, you can use `let` to create variables that you 15 | # can reference easily. 16 | 17 | # This is a long name. Shorten it for clarity. 18 | let(:connection_class) { TrainPlugins::LocalRot13::Connection } 19 | 20 | # Some tests through here use minitest Expectations, which attach to all 21 | # Objects, and begin with 'must' (positive) or 'wont' (negative) 22 | # See https://ruby-doc.org/stdlib-2.1.0/libdoc/minitest/rdoc/MiniTest/Expectations.html 23 | 24 | it "should inherit from the Train Connection base" do 25 | # For Class, '<' means 'is a descendant of' 26 | (connection_class < Train::Plugins::Transport::BaseConnection).must_equal(true) 27 | end 28 | 29 | # Since this is a Local-type connection, we MUST implement these three. 30 | %i{ 31 | file_via_connection 32 | run_command_via_connection 33 | }.each do |method_name| 34 | it "should provide a #{method_name}() method" do 35 | # false passed to instance_methods says 'don't use inheritance' 36 | connection_class.instance_methods(false).must_include(method_name) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/test/unit/transport_test.rb: -------------------------------------------------------------------------------- 1 | # This is a unit test for the example Train plugin, LocalRot13. 2 | # Its job is to verify that the Transport class is setup correctly. 3 | 4 | # Include our test harness 5 | require_relative "../helper" 6 | 7 | # Load the class under test, the Plugin definition. 8 | require "train-local-rot13/transport" 9 | 10 | # Because InSpec is a Spec-style test suite, we're going to use MiniTest::Spec 11 | # here, for familiar look and feel. However, this isn't InSpec (or RSpec) code. 12 | describe TrainPlugins::LocalRot13::Transport do 13 | 14 | # When writing tests, you can use `let` to create variables that you 15 | # can reference easily. 16 | 17 | # This is a long name. Shorten it for clarity. 18 | let(:plugin_class) { TrainPlugins::LocalRot13::Transport } 19 | 20 | # Some tests through here use minitest Expectations, which attach to all 21 | # Objects, and begin with 'must' (positive) or 'wont' (negative) 22 | # See https://ruby-doc.org/stdlib-2.1.0/libdoc/minitest/rdoc/MiniTest/Expectations.html 23 | 24 | it "should be registered with the plugin registry without the train- prefix" do 25 | # Note that Train uses String keys here, not Symbols 26 | Train::Plugins.registry.keys.wont_include("train-local-rot13") 27 | Train::Plugins.registry.keys.must_include("local-rot13") 28 | end 29 | 30 | it "should inherit from the Train plugin base" do 31 | # For Class, '<' means 'is a descendant of' 32 | (plugin_class < Train.plugin(1)).must_equal(true) 33 | end 34 | 35 | it "should provide a connection() method" do 36 | # false passed to instance_methods says 'don't use inheritance' 37 | plugin_class.instance_methods(false).must_include(:connection) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/unit/platforms/platforms_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe "platforms" do 4 | 5 | it "create platform" do 6 | Train::Platforms.list["mock"] = nil 7 | plat = Train::Platforms.name("mock") 8 | Train::Platforms.name("mock").in_family("test") 9 | Train::Platforms.name("mock").detect { true } 10 | _(plat.title).must_equal("Mock") 11 | _(plat.detect.call).must_equal(true) 12 | _(plat.families.keys[0].name).must_equal("test") 13 | end 14 | 15 | it "create family" do 16 | Train::Platforms.families["mock"] = nil 17 | fam = Train::Platforms.family("mock") 18 | Train::Platforms.family("mock").in_family("test") 19 | Train::Platforms.family("mock").detect { true } 20 | _(fam.title).must_equal("Mock Family") 21 | _(fam.detect.call).must_equal(true) 22 | _(fam.families.keys[0].name).must_equal("test") 23 | end 24 | 25 | it "return top platforms empty" do 26 | Train::Platforms.stubs(:list).returns({}) 27 | Train::Platforms.stubs(:families).returns({}) 28 | top = Train::Platforms.top_platforms 29 | _(top.count).must_equal(0) 30 | end 31 | 32 | it "return top platforms with data" do 33 | plat = Train::Platforms.name("linux") 34 | plat.stubs(:families).returns({}) 35 | Train::Platforms.stubs(:list).returns({ "linux" => plat }) 36 | Train::Platforms.stubs(:families).returns({}) 37 | top = Train::Platforms.top_platforms 38 | _(top.count).must_equal(1) 39 | end 40 | 41 | it "return platforms export with data" do 42 | export = Train::Platforms.export 43 | _(export.size).must_be :>, 10 44 | _(export[0][:name]).must_equal "aix" 45 | expected_families = %w{aix unix os} 46 | _(export[0][:families]).must_equal expected_families 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/integration/tests/path_missing_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "a path that doesn't exist" do 5 | let(:file) do 6 | backend.file("/do_not_create_this_path_please_or_my_tests_will_fail") 7 | end 8 | 9 | it "does not exist" do 10 | _(file.exist?).must_equal(false) 11 | end 12 | 13 | it "is not a file" do 14 | _(file.file?).must_equal(false) 15 | end 16 | 17 | it "has type nil" do 18 | _(file.type).must_be_nil 19 | end 20 | 21 | it "has no content" do 22 | _(file.content).must_be_nil 23 | end 24 | 25 | it "has no owner" do 26 | _(file.owner).must_be_nil 27 | end 28 | 29 | it "has no group" do 30 | _(file.group).must_be_nil 31 | end 32 | 33 | it "has mode nil" do 34 | _(file.mode).must_be_nil 35 | end 36 | 37 | it "checks mode? nil" do 38 | _(file.mode?(nil)).must_equal(true) 39 | end 40 | 41 | it "has no link_path" do 42 | _(file.link_path).must_be_nil 43 | end 44 | 45 | it "raises an error if md5sum is attempted" do 46 | _ { file.md5sum }.must_raise RuntimeError 47 | end 48 | 49 | it "raises an error if sha256sum is attempted" do 50 | _ { file.sha256sum }.must_raise RuntimeError 51 | end 52 | 53 | it "has a modified time" do 54 | _(file.mtime).must_be_nil 55 | end 56 | 57 | it "has inode size" do 58 | # Must be around 11 Bytes, +- 4 59 | _(file.size).must_be_nil 60 | end 61 | 62 | it "has no selinux_label" do 63 | _(file.selinux_label).must_be_nil 64 | end 65 | 66 | it "has no product_version" do 67 | _(file.product_version).must_be_nil 68 | end 69 | 70 | it "has no file_version" do 71 | _(file.file_version).must_be_nil 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/unit/file/remote_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/file/remote" 3 | 4 | describe Train::File::Remote do 5 | let(:cls) { Train::File::Remote } 6 | 7 | def mockup(stubs) 8 | Class.new(cls) do 9 | stubs.each do |k, v| 10 | define_method k.to_sym do 11 | v 12 | end 13 | end 14 | end.new(nil, nil, false) 15 | end 16 | 17 | describe "basename helper" do 18 | def fc(path) 19 | mockup(type: :file, path: path) 20 | end 21 | 22 | it "works with an empty path" do 23 | _(fc("").basename).must_equal "" 24 | end 25 | 26 | it "separates a simple path (defaults to unix mode)" do 27 | _(fc("/dir/file").basename).must_equal "file" 28 | end 29 | 30 | it "separates a simple path (Unix mode)" do 31 | _(fc("/dir/file").basename(nil, "/")).must_equal "file" 32 | end 33 | 34 | it "separates a simple path (Windows mode)" do 35 | _(fc('C:\dir\file').basename(nil, "\\")).must_equal "file" 36 | end 37 | 38 | it "identifies a folder name (Unix mode)" do 39 | _(fc("/dir/file/").basename(nil, "/")).must_equal "file" 40 | end 41 | 42 | it "identifies a folder name (Windows mode)" do 43 | _(fc('C:\dir\file\\').basename(nil, "\\")).must_equal "file" 44 | end 45 | 46 | it "ignores tailing separators (Unix mode)" do 47 | _(fc("/dir/file///").basename(nil, "/")).must_equal "file" 48 | end 49 | 50 | it "ignores tailing separators (Windows mode)" do 51 | _(fc('C:\dir\file\\\\\\').basename(nil, "\\")).must_equal "file" 52 | end 53 | 54 | it "doesnt work with backward slashes (Unix mode)" do 55 | _(fc('C:\dir\file').basename(nil, "/")).must_equal 'C:\\dir\file' 56 | end 57 | 58 | it "doesnt work with forward slashes (Windows mode)" do 59 | _(fc("/dir/file").basename(nil, "\\")).must_equal "/dir/file" 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /.expeditor/buildkite/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ueo pipefail 4 | 5 | echo "--- system details" 6 | uname -a 7 | ruby -v 8 | bundle --version 9 | 10 | echo "--- system environment" 11 | env 12 | 13 | echo "--- installing vault" 14 | export VAULT_VERSION=1.13.0 15 | export VAULT_HOME=$HOME/vault 16 | curl --create-dirs -sSLo $VAULT_HOME/vault.zip https://releases.hashicorp.com/vault/$VAULT_VERSION/vault_${VAULT_VERSION}_linux_amd64.zip 17 | unzip -o $VAULT_HOME/vault.zip -d $VAULT_HOME 18 | 19 | if [ -n "${CI_ENABLE_COVERAGE:-}" ]; then 20 | echo "--- fetching Sonar token from vault" 21 | export SONAR_TOKEN=$($VAULT_HOME/vault kv get -field token secret/inspec/train) 22 | fi 23 | 24 | echo "--- bundle install" 25 | bundle config set --local without tools integration 26 | bundle install --jobs=7 --retry=3 27 | 28 | echo "+++ bundle exec rake" 29 | bundle exec rake ${RAKE_TASK:-} 30 | RAKE_EXIT=$? 31 | 32 | if [ -n "${CI_ENABLE_COVERAGE:-}" ]; then 33 | echo "--- installing sonarscanner" 34 | export SONAR_SCANNER_VERSION=6.1.0.4477 35 | export SONAR_SCANNER_HOME=$HOME/.sonar/sonar-scanner-$SONAR_SCANNER_VERSION-linux-x64 36 | curl --create-dirs -sSLo $HOME/.sonar/sonar-scanner.zip https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-$SONAR_SCANNER_VERSION-linux-x64.zip 37 | unzip -o $HOME/.sonar/sonar-scanner.zip -d $HOME/.sonar/ 38 | export PATH=$SONAR_SCANNER_HOME/bin:$PATH 39 | export SONAR_SCANNER_OPTS="-server" 40 | 41 | # Delete the vendor/ directory. I've tried to exclude it using sonar.exclusions, 42 | # but that appears to get ignored, and we end up analyzing the gemfile install 43 | # which blows our analysis. 44 | echo "--- deleting installed gems" 45 | rm -rf vendor/ 46 | 47 | # See sonar-project.properties for additional settings 48 | echo "--- running sonarscanner" 49 | sonar-scanner \ 50 | -Dsonar.sources=. \ 51 | -Dsonar.host.url=https://sonar.progress.com 52 | fi 53 | 54 | exit $RAKE_EXIT 55 | -------------------------------------------------------------------------------- /test/integration/tests/path_block_device_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "block device" do 5 | let(:file) { backend.file("/tmp/block_device") } 6 | 7 | it "exists" do 8 | _(file.exist?).must_equal(true) 9 | end 10 | 11 | it "is a block device" do 12 | _(file.block_device?).must_equal(true) 13 | end 14 | 15 | it "has type :block_device" do 16 | _(file.type).must_equal(:block_device) 17 | end 18 | 19 | it "has no content" do 20 | _(file.content).must_equal("") 21 | end 22 | 23 | it "has owner name root" do 24 | _(file.owner).must_equal("root") 25 | end 26 | 27 | it "has group name" do 28 | _(file.group).must_equal(Test.root_group(backend.os)) 29 | end 30 | 31 | it "has mode 0666" do 32 | _(file.mode).must_equal(00666) 33 | end 34 | 35 | it "checks mode? 0666" do 36 | _(file.mode?(00666)).must_equal(true) 37 | end 38 | 39 | it "has no link_path" do 40 | _(file.link_path).must_be_nil 41 | end 42 | 43 | it "has the correct md5sum" do 44 | _(file.md5sum).must_equal("d41d8cd98f00b204e9800998ecf8427e") 45 | end 46 | 47 | it "has the correct sha256sum" do 48 | _(file.sha256sum).must_equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") 49 | end 50 | 51 | it "has a modified time" do 52 | _(file.mtime).must_be_close_to(Time.now.to_i - Test.mtime / 2, Test.mtime) 53 | end 54 | 55 | it "has inode size of 0" do 56 | _(file.size).must_equal(0) 57 | end 58 | 59 | it "has selinux label handling" do 60 | res = Test.selinux_label(backend, file.path) 61 | _(file.selinux_label).must_equal(res) 62 | end 63 | 64 | it "has no product_version" do 65 | _(file.product_version).must_be_nil 66 | end 67 | 68 | it "has no file_version" do 69 | _(file.file_version).must_be_nil 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/integration/tests/path_character_device_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "character device" do 5 | let(:file) { backend.file("/dev/null") } 6 | 7 | it "exists" do 8 | _(file.exist?).must_equal(true) 9 | end 10 | 11 | it "is a character device" do 12 | _(file.character_device?).must_equal(true) 13 | end 14 | 15 | it "has type :character_device" do 16 | _(file.type).must_equal(:character_device) 17 | end 18 | 19 | it "has empty content" do 20 | _(file.content).must_equal("") 21 | end 22 | 23 | it "has owner name root" do 24 | _(file.owner).must_equal("root") 25 | end 26 | 27 | it "has group name" do 28 | _(file.group).must_equal(Test.root_group(backend.os)) 29 | end 30 | 31 | it "has mode 0666" do 32 | _(file.mode).must_equal(00666) 33 | end 34 | 35 | it "checks mode? 0666" do 36 | _(file.mode?(00666)).must_equal(true) 37 | end 38 | 39 | it "has no link_path" do 40 | _(file.link_path).must_be_nil 41 | end 42 | 43 | it "has an md5sum" do 44 | _(file.md5sum).must_equal("d41d8cd98f00b204e9800998ecf8427e") 45 | end 46 | 47 | it "has an sha256sum" do 48 | _(file.sha256sum).must_equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") 49 | end 50 | 51 | it "has a modified time" do 52 | _(file.mtime).must_be_close_to(Time.now.to_i - Test.mtime / 2, Test.mtime) 53 | end 54 | 55 | it "has inode size of 0" do 56 | _(file.size).must_equal(0) 57 | end 58 | 59 | it "has selinux label handling" do 60 | res = Test.selinux_label(backend, file.path) 61 | _(file.selinux_label).must_equal(res) 62 | end 63 | 64 | it "has no product_version" do 65 | _(file.product_version).must_be_nil 66 | end 67 | 68 | it "has no file_version" do 69 | _(file.file_version).must_be_nil 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/lib/train-local-rot13/platform.rb: -------------------------------------------------------------------------------- 1 | # Platform definition file. This is a good place to separate out any 2 | # logic regarding the identification of the OS or API at the far end 3 | # of the connection. 4 | 5 | # Abbreviate the namespace here, if you like. 6 | module TrainPlugins::LocalRot13 7 | # Since we're mixing in the platform detection facility into Connection, 8 | # this has to come in as a Module. 9 | module Platform 10 | # The method `platform` is called when platform detection is 11 | # about to be performed. Train core defines a sophisticated 12 | # system for platform detection, but for most plugins, you'll 13 | # only ever run on the special platform for which you are targeting. 14 | def platform 15 | # If you are declaring a new platform, you will need to tell 16 | # Train a bit about it. 17 | # If you were defining a cloud API, you should say you are a member 18 | # of the cloud family. 19 | 20 | # This plugin makes up a new platform. Train (or rather InSpec) only 21 | # know how to read files on Windows and Un*x (MacOS is a kind of Un*x), 22 | # so we'll say we're part of those families. 23 | Train::Platforms.name("local-rot13").in_family("unix") 24 | Train::Platforms.name("local-rot13").in_family("windows") 25 | 26 | # When you know you will only ever run on your dedicated platform 27 | # (for example, a plugin named train-aws would only run on the AWS 28 | # API, which we report as the 'aws' platform). 29 | # force_platform! lets you bypass platform detection. 30 | # The options to this are not currently documented completely. 31 | 32 | # Use release to report a version number. You might use the version 33 | # of the plugin, or a version of an important underlying SDK, or a 34 | # version of a remote API. 35 | force_platform!("local-rot13", release: TrainPlugins::LocalRot13::VERSION) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/train/plugins/transport.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Dominik Richter () 3 | # Author:: Christoph Hartmann () 4 | 5 | require "logger" 6 | require_relative "../errors" 7 | require_relative "../extras" 8 | require_relative "../options" 9 | 10 | class Train::Plugins 11 | class Transport 12 | include Train::Extras 13 | Train::Options.attach(self) 14 | 15 | require_relative "base_connection" 16 | 17 | # Initialize a new Transport object 18 | # 19 | # @param [Hash] config = nil the configuration for this transport 20 | # @return [Transport] the transport object 21 | def initialize(options = {}) 22 | @options = merge_options({}, options || {}) 23 | @logger = @options[:logger] || Logger.new($stdout, level: :fatal) 24 | # Validates audit log configuration options if audit log is enabled 25 | # The reason to implement different validate method for audit log options is 26 | # to validate only audit log options and not to break any existing validate_option implementation. 27 | if !@options.empty? && @options[:enable_audit_log] 28 | validate_audit_log_options(options) 29 | end 30 | end 31 | 32 | # Create a connection to the target. Options may be provided 33 | # for additional configuration. 34 | # 35 | # @param [Hash] _options = nil provide optional configuration params 36 | # @return [Connection] the connection for this configuration 37 | def connection(_options = nil) 38 | raise Train::ClientError, "#{self.class} does not implement #connection()" 39 | end 40 | 41 | # Register the inheriting class with as a train plugin using the 42 | # provided name. 43 | # 44 | # @param [String] name of the plugin, by which it will be found 45 | def self.name(name) 46 | Train::Plugins.registry[name] = self 47 | end 48 | 49 | private 50 | 51 | # @return [Logger] logger for reporting information 52 | attr_reader :logger 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/train/file/local/windows.rb: -------------------------------------------------------------------------------- 1 | module Train 2 | class File 3 | class Local 4 | class Windows < Train::File::Local 5 | # Ensures we do not use invalid characters for file names 6 | # @see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx#naming_conventions 7 | def sanitize_filename(path) 8 | return if path.nil? 9 | 10 | # we do not filter :, backslash and forward slash, since they are part of the path 11 | @spath = path.gsub(/[<>"|?*]/, "") 12 | end 13 | 14 | def product_version 15 | @product_version ||= @backend.run_command( 16 | "[System.Diagnostics.FileVersionInfo]::GetVersionInfo(\"#{@spath}\").ProductVersion" 17 | ).stdout.chomp 18 | end 19 | 20 | def file_version 21 | @file_version ||= @backend.run_command( 22 | "[System.Diagnostics.FileVersionInfo]::GetVersionInfo(\"#{@spath}\").FileVersion" 23 | ).stdout.chomp 24 | end 25 | 26 | def owner 27 | owner = @backend.run_command( 28 | "Get-Acl \"#{@spath}\" | select -expand Owner" 29 | ).stdout.strip 30 | return if owner.empty? 31 | 32 | owner 33 | end 34 | 35 | def stat 36 | return @stat if defined?(@stat) 37 | 38 | begin 39 | file_stat = 40 | if @follow_symlink 41 | ::File.stat(@path) 42 | else 43 | ::File.lstat(@path) 44 | end 45 | rescue StandardError => _err 46 | return @stat = {} 47 | end 48 | 49 | @stat = { 50 | type: type, 51 | mode: file_stat.mode, 52 | mtime: file_stat.mtime.to_i, 53 | size: file_stat.size, 54 | owner: owner, 55 | uid: file_stat.uid, 56 | group: nil, 57 | gid: file_stat.gid, 58 | selinux_label: nil, 59 | } 60 | 61 | @stat 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/train/plugin_test_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is intended to be 'require'd by plugin authors who are developing a 2 | # plugin outside of the Train source tree. 3 | 4 | # Load Train. We certainly need the plugin system, and also several other parts 5 | # that are tightly coupled. Train itself is fairly light, and non-invasive. 6 | require_relative "../train" 7 | 8 | # You can select from a number of test harnesses. Since Train is closely related 9 | # to InSpec, and InSpec uses Spec-style controls in profile code, you will 10 | # probably want to use something like minitest/spec, which provides Spec-style 11 | # tests. 12 | require "minitest/spec" 13 | require "minitest/autorun" 14 | 15 | # Data formats commonly used in testing 16 | require "json" unless defined?(JSON) 17 | require "ostruct" unless defined?(OpenStruct) 18 | 19 | # Utilities often needed 20 | require "fileutils" unless defined?(FileUtils) 21 | require "tmpdir" unless defined?(Dir.mktmpdir) 22 | require "pathname" unless defined?(Pathname) 23 | 24 | # You might want to put some debugging tools here. We run tests to find bugs, 25 | # after all. 26 | require "byebug" 27 | 28 | # Configure MiniTest to expose things like `let` 29 | class Module 30 | include Minitest::Spec::DSL 31 | end 32 | 33 | # Finally, let's make some modules that can help us out. 34 | module TrainPluginBaseHelper 35 | # Sneakily detect the location of the plugin 36 | # source code when they include this Module 37 | def self.included(base) 38 | plugin_test_helper_path = Pathname.new(caller_locations(4, 1).first.absolute_path) 39 | plugin_src_root = plugin_test_helper_path.parent.parent 40 | base.let(:plugin_src_path) { plugin_src_root } 41 | base.let(:plugin_fixtures_path) { File.join(plugin_src_root, "test", "fixtures") } 42 | end 43 | 44 | let(:train_src_path) { File.expand_path(File.join(__FILE__, "..", "..")) } 45 | let(:train_fixtures_path) { File.join(train_src_path, "test", "fixtures") } 46 | let(:registry) { Train::Plugins.registry } 47 | end 48 | 49 | module TrainPluginFunctionalHelper 50 | include TrainPluginBaseHelper 51 | end 52 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler" 3 | require "bundler/gem_helper" 4 | require "rake/testtask" 5 | require "chefstyle" 6 | require "rubocop/rake_task" 7 | 8 | Bundler::GemHelper.install_tasks name: "train" 9 | 10 | RuboCop::RakeTask.new(:lint) do |task| 11 | task.options << "--display-cop-names" 12 | end 13 | 14 | # run tests 15 | task default: %i{test} 16 | 17 | Rake::TestTask.new do |t| 18 | t.libs << "test" 19 | t.pattern = "test/unit/**/*_test.rb" 20 | t.warning = false 21 | t.verbose = true 22 | t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) 23 | end 24 | 25 | namespace :test do 26 | task :docker do 27 | path = File.join(__dir__, "test", "integration") 28 | sh("sh", "-c", "cd #{path} && ruby -I ../../lib docker_test.rb tests/*") 29 | end 30 | 31 | task :windows do 32 | Dir.glob("test/windows/*_test.rb").all? do |file| 33 | sh(Gem.ruby, "-w", '-I .\test\windows', file) 34 | end || raise("Failures") 35 | end 36 | 37 | task :vm do 38 | concurrency = ENV["CONCURRENCY"] || 4 39 | path = File.join(__dir__, "test", "integration") 40 | sh("sh", "-c", "cd #{path} && kitchen test -c #{concurrency}") 41 | end 42 | 43 | # Target required: 44 | # rake "test:ssh[user@server]" 45 | # sh -c cd /home/foobarbam/src/gems/train/test/integration \ 46 | # && target=user@server ruby -I ../../lib test_ssh.rb tests/* 47 | # ... 48 | # Turn debug logging back on: 49 | # debug=1 rake "test:ssh[user@server]" 50 | # Use a different ssh key: 51 | # key_files=/home/foobarbam/.ssh/id_rsa2 rake "test:ssh[user@server]" 52 | # Run with a specific test: 53 | # test=path_block_device_test.rb rake "test:ssh[user@server]" 54 | task :ssh, [:target] do |t, args| 55 | path = File.join(__dir__, "test", "integration") 56 | key_files = ENV["key_files"] || File.join(ENV["HOME"], ".ssh", "id_rsa") 57 | 58 | sh_cmd = "cd #{path} && target=#{args[:target]} key_files=#{key_files}" 59 | 60 | sh_cmd += " debug=#{ENV["debug"]}" if ENV["debug"] 61 | sh_cmd += " ruby -I ../../lib test_ssh.rb tests/" 62 | sh_cmd += ENV["test"] || "*" 63 | 64 | sh("sh", "-c", sh_cmd) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/train/file/local.rb: -------------------------------------------------------------------------------- 1 | module Train 2 | class File 3 | class Local < Train::File 4 | %w{ 5 | exist? file? socket? directory? symlink? pipe? size basename 6 | }.each do |m| 7 | define_method m.to_sym do 8 | ::File.method(m.to_sym).call(@path) 9 | end 10 | end 11 | 12 | def content 13 | @content ||= ::File.read(@path, encoding: "UTF-8") 14 | rescue StandardError => _ 15 | nil 16 | end 17 | 18 | def content=(new_content) 19 | ::File.open(@path, "w", encoding: "UTF-8") { |fp| fp.write(new_content) } 20 | 21 | @content = new_content 22 | end 23 | 24 | def link_path 25 | return nil unless symlink? 26 | 27 | begin 28 | @link_path ||= ::File.realpath(@path) 29 | rescue Errno::ELOOP => _ 30 | # Leave it blank on symbolic loop, same as readlink 31 | @link_path = "" 32 | end 33 | end 34 | 35 | def shallow_link_path 36 | return nil unless symlink? 37 | 38 | @link_path ||= ::File.readlink(@path) 39 | end 40 | 41 | def block_device? 42 | ::File.blockdev?(@path) 43 | end 44 | 45 | def character_device? 46 | ::File.chardev?(@path) 47 | end 48 | 49 | def type 50 | case ::File.ftype(@path) 51 | when "blockSpecial" 52 | :block_device 53 | when "characterSpecial" 54 | :character_device 55 | when "link" 56 | :symlink 57 | when "fifo" 58 | :pipe 59 | else 60 | ::File.ftype(@path).to_sym 61 | end 62 | end 63 | 64 | %w{ 65 | mode owner group uid gid mtime selinux_label 66 | }.each do |field| 67 | define_method field.to_sym do 68 | stat[field.to_sym] 69 | end 70 | end 71 | 72 | def mode?(sth) 73 | mode == sth 74 | end 75 | 76 | def linked_to?(dst) 77 | link_path == dst 78 | end 79 | end 80 | end 81 | end 82 | 83 | # subclass requires are loaded after Train::File::Local is defined 84 | # to avoid superclass mismatch errors 85 | require_relative "local/unix" 86 | require_relative "local/windows" 87 | -------------------------------------------------------------------------------- /test/unit/platforms/detect/scanner_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/platforms/detect/scanner" 3 | require "train/transports/mock" 4 | 5 | describe "scanner" do 6 | let(:backend) { Train::Transports::Mock::Connection.new } 7 | let(:scanner) { Train::Platforms::Detect::Scanner.new(backend) } 8 | 9 | describe "scan family children" do 10 | it "return child" do 11 | family = Train::Platforms.family("linux") 12 | _(scanner.scan_family_children(family).name).must_equal("linux") 13 | _(scanner.instance_variable_get(:@family_hierarchy)).must_equal(["linux"]) 14 | end 15 | 16 | it "return nil" do 17 | family = Train::Platforms.family("fake-fam") 18 | _(scanner.scan_family_children(family)).must_be_nil 19 | _(scanner.instance_variable_get(:@family_hierarchy)).must_be_empty 20 | end 21 | end 22 | 23 | describe "check condition" do 24 | it "return true equal" do 25 | scanner.instance_variable_set(:@platform, { arch: "x86_64" }) 26 | _(scanner.check_condition({ arch: "= x86_64" })).must_equal(true) 27 | end 28 | 29 | it "return true greater then" do 30 | scanner.instance_variable_set(:@platform, { release: "8.2" }) 31 | _(scanner.check_condition({ release: ">= 7" })).must_equal(true) 32 | end 33 | 34 | it "return false greater then" do 35 | scanner.instance_variable_set(:@platform, { release: "2.2" }) 36 | _(scanner.check_condition({ release: "> 7" })).must_equal(false) 37 | end 38 | end 39 | 40 | describe "get platform" do 41 | it "return empty platform" do 42 | plat = Train::Platforms.name("linux") 43 | plat = scanner.get_platform(plat) 44 | _(plat.platform).must_equal({}) 45 | _(plat.backend).must_equal(backend) 46 | _(plat.family_hierarchy).must_equal([]) 47 | end 48 | 49 | it "return full platform" do 50 | scanner.instance_variable_set(:@platform, { family: "linux" }) 51 | scanner.instance_variable_set(:@family_hierarchy, %w{linux unix}) 52 | plat = Train::Platforms.name("linux") 53 | plat = scanner.get_platform(plat) 54 | _(plat.platform).must_equal({ family: "linux" }) 55 | _(plat.backend).must_equal(backend) 56 | _(plat.family_hierarchy).must_equal(%w{linux unix}) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/train-local-rot13.gemspec: -------------------------------------------------------------------------------- 1 | # As plugins are usually packaged and distributed as a RubyGem, 2 | # we have to provide a .gemspec file, which controls the gembuild 3 | # and publish process. This is a fairly generic gemspec. 4 | 5 | # It is traditional in a gemspec to dynamically load the current version 6 | # from a file in the source tree. The next three lines make that happen. 7 | lib = File.expand_path("lib", __dir__) 8 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 9 | require "train-local-rot13/version" 10 | 11 | Gem::Specification.new do |spec| 12 | # Importantly, all Train plugins must be prefixed with `train-` 13 | spec.name = "train-local-rot13" 14 | 15 | # It is polite to namespace your plugin under TrainPlugins::YourPluginInCamelCase 16 | spec.version = TrainPlugins::LocalRot13::VERSION 17 | spec.authors = ["Chef InSpec Team"] 18 | spec.email = ["inspec@chef.io"] 19 | spec.summary = "Train Plugin example, rot13's file content and command output" 20 | spec.description = "Example for implementing a Train plugin. This simply performs the ROT13 substitution cipher on file content and command output." 21 | spec.homepage = "https://github.com/inspec/train/tree/master/examples/plugin" 22 | spec.license = "Apache-2.0" 23 | 24 | # Though complicated-looking, this is pretty standard for a gemspec. 25 | # It just filters what will actually be packaged in the gem (leaving 26 | # out tests, etc) 27 | spec.files = %w{ 28 | README.md train-local-rot13.gemspec Gemfile 29 | } + Dir.glob( 30 | "lib/**/*", File::FNM_DOTMATCH 31 | ).reject { |f| File.directory?(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # If you rely on any other gems, list them here with any constraints. 35 | # This is how `inspec plugin install` is able to manage your dependencies. 36 | # For example, perhaps you are writing a thing that talks to AWS, and you 37 | # want to ensure you have `aws-sdk` in a certain version. 38 | 39 | # If you only need certain gems during development or testing, list 40 | # them in Gemfile, not here. 41 | # Do not list inspec as a dependency of the train plugin. 42 | 43 | # All plugins should mention train, > 1.4 44 | spec.add_dependency "train", "~> 1.4" 45 | spec.add_dependency "rot13", "~> 0.1" 46 | end 47 | -------------------------------------------------------------------------------- /test/integration/tests/path_folder_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "a folder" do 5 | let(:file) { backend.file("/tmp/folder") } 6 | 7 | it "exists" do 8 | _(file.exist?).must_equal(true) 9 | end 10 | 11 | it "is a directory" do 12 | _(file.directory?).must_equal(true) 13 | end 14 | 15 | it "has type :directory" do 16 | _(file.type).must_equal(:directory) 17 | end 18 | 19 | case get_backend.call.os[:family] 20 | when "freebsd" 21 | it "has freebsd folder content behavior" do 22 | _(file.content).must_equal("\u0003\u0000") 23 | end 24 | 25 | it "has an md5sum" do 26 | _(file.md5sum).must_equal("598f4fe64aefab8f00bcbea4c9239abf") 27 | end 28 | 29 | it "has an sha256sum" do 30 | _(file.sha256sum).must_equal("9b4fb24edd6d1d8830e272398263cdbf026b97392cc35387b991dc0248a628f9") 31 | end 32 | 33 | else 34 | it "has no content" do 35 | _(file.content).must_be_nil 36 | end 37 | 38 | it "raises an error if md5sum is attempted" do 39 | _ { file.md5sum }.must_raise RuntimeError 40 | end 41 | 42 | it "raises an error if sha256sum is attempted" do 43 | _ { file.sha256sum }.must_raise RuntimeError 44 | end 45 | end 46 | 47 | it "has owner name root" do 48 | _(file.owner).must_equal("root") 49 | end 50 | 51 | it "has group name" do 52 | _(file.group).must_equal(Test.root_group(backend.os)) 53 | end 54 | 55 | it "has mode 0567" do 56 | _(file.mode).must_equal(00567) 57 | end 58 | 59 | it "checks mode? 0567" do 60 | _(file.mode?(00567)).must_equal(true) 61 | end 62 | 63 | it "has no link_path" do 64 | _(file.link_path).must_be_nil 65 | end 66 | 67 | it "has a modified time" do 68 | _(file.mtime).must_be_close_to(Time.now.to_i - Test.mtime / 2, Test.mtime) 69 | end 70 | 71 | it "has inode size" do 72 | _(file.size).must_be_close_to(4096, 4096) 73 | end 74 | 75 | it "has selinux label handling" do 76 | res = Test.selinux_label(backend, file.path) 77 | _(file.selinux_label).must_equal(res) 78 | end 79 | 80 | it "has no product_version" do 81 | _(file.product_version).must_be_nil 82 | end 83 | 84 | it "has no file_version" do 85 | _(file.file_version).must_be_nil 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/integration/tests/path_symlink_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "symlink file" do 5 | let(:file) { backend.file("/tmp/symlink") } 6 | 7 | it "exists" do 8 | _(file.exist?).must_equal(true) 9 | end 10 | 11 | it "is a symlink" do 12 | _(file.symlink?).must_equal(true) 13 | end 14 | 15 | it "is pointing to a file" do 16 | _(file.file?).must_equal(true) 17 | end 18 | 19 | it "is not pointing to a folder" do 20 | _(file.directory?).must_equal(false) 21 | end 22 | 23 | it "has type :file" do 24 | _(file.type).must_equal(:file) 25 | end 26 | 27 | it "has content" do 28 | _(file.content).must_equal("hello world") 29 | end 30 | 31 | it "has owner name root" do 32 | _(file.owner).must_equal("root") 33 | end 34 | 35 | it "has uid 0" do 36 | _(file.uid).must_equal(0) 37 | end 38 | 39 | it "has group name" do 40 | _(file.group).must_equal(Test.root_group(backend.os)) 41 | end 42 | 43 | it "has gid 0" do 44 | _(file.gid).must_equal(0) 45 | end 46 | 47 | it "has mode 0777" do 48 | _(file.source.mode).must_equal(00777) 49 | end 50 | 51 | it "has mode 0765" do 52 | _(file.mode).must_equal(00765) 53 | end 54 | 55 | it "checks mode? 0765" do 56 | _(file.mode?(00765)).must_equal(true) 57 | end 58 | 59 | it "has link_path" do 60 | _(file.link_path).must_equal("/tmp/file") 61 | end 62 | 63 | it "has an md5sum" do 64 | _(file.md5sum).must_equal("5eb63bbbe01eeed093cb22bb8f5acdc3") 65 | end 66 | 67 | it "has an sha256sum" do 68 | _(file.sha256sum).must_equal("b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9") 69 | end 70 | 71 | it "has a modified time" do 72 | _(file.mtime).must_be_close_to(Time.now.to_i - Test.mtime / 2, Test.mtime) 73 | end 74 | 75 | it "has size" do 76 | # Must be around 11 Bytes, +- 4 77 | _(file.size).must_be_close_to(11, 4) 78 | end 79 | 80 | it "has selinux label handling" do 81 | res = Test.selinux_label(backend, file.path) 82 | _(file.selinux_label).must_equal(res) 83 | end 84 | 85 | it "has no product_version" do 86 | _(file.product_version).must_be_nil 87 | end 88 | 89 | it "has no file_version" do 90 | _(file.file_version).must_be_nil 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /train.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "train/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "train" 7 | spec.version = Train::VERSION 8 | spec.authors = ["Chef InSpec Team"] 9 | spec.email = ["inspec@chef.io"] 10 | spec.summary = "Transport interface to talk to different backends." 11 | spec.description = "Transport interface to talk to different backends." 12 | spec.license = "Apache-2.0" 13 | 14 | spec.metadata = { 15 | "homepage_uri" => "https://github.com/inspec/train", 16 | "changelog_uri" => "https://github.com/inspec/train/blob/master/CHANGELOG.md", 17 | "source_code_uri" => "https://github.com/inspec/train", 18 | "bug_tracker_uri" => "https://github.com/inspec/train/issues", 19 | } 20 | 21 | spec.required_ruby_version = ">= 3.1.0" 22 | 23 | spec.files = %w{LICENSE} + Dir.glob("lib/**/*") 24 | .grep(%r{transports/(azure|clients|docker|podman|gcp|helpers|vmware)}) 25 | .reject { |f| File.directory?(f) } 26 | 27 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_dependency "train-core", "= #{Train::VERSION}" 31 | spec.add_dependency "train-winrm", "~> 0.4.0" # Ruby 3.4 upgrade included 32 | 33 | spec.add_dependency "activesupport", "~> 7.2", ">= 7.2.2.1" 34 | 35 | # azure, docker, gcp dependencies 36 | spec.add_dependency "inifile", "~> 3.0" 37 | spec.add_dependency "azure_graph_rbac", "~> 0.16" 38 | spec.add_dependency "azure_mgmt_key_vault", "~> 0.17" 39 | spec.add_dependency "azure_mgmt_resources", "~> 0.15" 40 | spec.add_dependency "azure_mgmt_security", "~> 0.18" 41 | spec.add_dependency "azure_mgmt_storage", "~> 0.18" 42 | spec.add_dependency "docker-api", ">= 1.26", "< 3.0" 43 | spec.add_dependency "googleauth", ">= 0.16.2", "< 1.9.0" 44 | spec.add_dependency "google-apis-admin_directory_v1", "~> 0.46.0" 45 | spec.add_dependency "google-apis-cloudkms_v1", "~> 0.41.0" 46 | spec.add_dependency "google-apis-monitoring_v3", "~> 0.51.0" 47 | spec.add_dependency "google-apis-compute_v1", "~> 0.83.0" 48 | spec.add_dependency "google-apis-cloudresourcemanager_v1", "~> 0.35.0" 49 | spec.add_dependency "google-apis-storage_v1", "~> 0.30.0" 50 | spec.add_dependency "google-apis-iam_v1", "~> 0.50.0" 51 | # Gem dependency needed with Ruby 3.4 upgrade 52 | spec.add_dependency "ostruct", "~> 0.1.0" 53 | end 54 | -------------------------------------------------------------------------------- /test/integration/tests/path_file_test.rb: -------------------------------------------------------------------------------- 1 | describe "file interface" do 2 | let(:backend) { get_backend.call } 3 | 4 | describe "regular file" do 5 | let(:file) { backend.file("/tmp/file") } 6 | 7 | it "exists" do 8 | _(file.exist?).must_equal(true) 9 | end 10 | 11 | it "is a file" do 12 | _(file.file?).must_equal(true) 13 | end 14 | 15 | it "has type :file" do 16 | _(file.type).must_equal(:file) 17 | end 18 | 19 | it "has content" do 20 | _(file.content).must_equal("hello world") 21 | end 22 | 23 | it "has owner name root" do 24 | _(file.owner).must_equal("root") 25 | end 26 | 27 | it "has group name" do 28 | _(file.group).must_equal(Test.root_group(backend.os)) 29 | end 30 | 31 | it "has mode 0765" do 32 | _(file.mode).must_equal(00765) 33 | end 34 | 35 | it "checks mode? 0765" do 36 | _(file.mode?(00765)).must_equal(true) 37 | end 38 | 39 | it "doesn't check mode? 0764" do 40 | _(file.mode?(00764)).must_equal(false) 41 | end 42 | 43 | it "has no link_path" do 44 | _(file.link_path).must_be_nil 45 | end 46 | 47 | it "has an md5sum" do 48 | _(file.md5sum).must_equal("5eb63bbbe01eeed093cb22bb8f5acdc3") 49 | end 50 | 51 | it "has an sha256sum" do 52 | _(file.sha256sum).must_equal("b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9") 53 | end 54 | 55 | it "has a modified time" do 56 | _(file.mtime).must_be_close_to(Time.now.to_i - Test.mtime / 2, Test.mtime) 57 | end 58 | 59 | it "has size" do 60 | # Must be around 11 Bytes, +- 4 61 | _(file.size).must_be_close_to(11, 4) 62 | end 63 | 64 | it "has selinux label handling" do 65 | res = Test.selinux_label(backend, file.path) 66 | _(file.selinux_label).must_equal(res) 67 | end 68 | 69 | it "has no product_version" do 70 | _(file.product_version).must_be_nil 71 | end 72 | 73 | it "has no file_version" do 74 | _(file.file_version).must_be_nil 75 | end 76 | 77 | it "provides a json representation" do 78 | j = file.to_json 79 | _(j).must_be_kind_of Hash 80 | _(j["type"]).must_equal :file 81 | end 82 | end 83 | 84 | describe "regular file" do 85 | let(:file) { backend.file("/tmp/sfile") } 86 | it "has mode 7765" do 87 | _(file.mode).must_equal(07765) 88 | end 89 | end 90 | 91 | describe "regular file" do 92 | let(:file) { backend.file("/tmp/spaced file") } 93 | it "has content" do 94 | _(file.content).must_equal("hello space") 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/train/file/local/unix.rb: -------------------------------------------------------------------------------- 1 | require "shellwords" unless defined?(Shellwords) 2 | require_relative "../../extras/stat" 3 | 4 | module Train 5 | class File 6 | class Local 7 | class Unix < Train::File::Local 8 | def sanitize_filename(path) 9 | @spath = Shellwords.escape(path) || @path 10 | end 11 | 12 | def stat 13 | return @stat if defined?(@stat) 14 | 15 | begin 16 | file_stat = 17 | if @follow_symlink 18 | ::File.stat(@path) 19 | else 20 | ::File.lstat(@path) 21 | end 22 | rescue StandardError => _err 23 | return @stat = {} 24 | end 25 | 26 | @stat = { 27 | type: Train::Extras::Stat.find_type(file_stat.mode), 28 | mode: file_stat.mode & 07777, 29 | mtime: file_stat.mtime.to_i, 30 | size: file_stat.size, 31 | owner: pw_username(file_stat.uid), 32 | uid: file_stat.uid, 33 | group: pw_groupname(file_stat.gid), 34 | gid: file_stat.gid, 35 | } 36 | 37 | lstat = @follow_symlink ? " -L" : "" 38 | res = @backend.run_command("stat#{lstat} #{@spath} 2>/dev/null --printf '%C'") 39 | if res.exit_status == 0 && !res.stdout.empty? && res.stdout != "?" 40 | @stat[:selinux_label] = res.stdout.strip 41 | end 42 | 43 | @stat 44 | end 45 | 46 | def mounted 47 | @mounted ||= 48 | @backend.run_command("mount | grep -- ' on #{@path} '") 49 | end 50 | 51 | def grouped_into?(sth) 52 | group == sth 53 | end 54 | 55 | def unix_mode_mask(owner, type) 56 | o = UNIX_MODE_OWNERS[owner.to_sym] 57 | return nil if o.nil? 58 | 59 | t = UNIX_MODE_TYPES[type.to_sym] 60 | return nil if t.nil? 61 | 62 | t & o 63 | end 64 | 65 | private 66 | 67 | def pw_username(uid) 68 | Etc.getpwuid(uid).name 69 | rescue ArgumentError => _ 70 | nil 71 | end 72 | 73 | def pw_groupname(gid) 74 | Etc.getgrgid(gid).name 75 | rescue ArgumentError => _ 76 | nil 77 | end 78 | 79 | UNIX_MODE_OWNERS = { 80 | all: 00777, 81 | owner: 00700, 82 | group: 00070, 83 | other: 00007, 84 | }.freeze 85 | 86 | UNIX_MODE_TYPES = { 87 | r: 00444, 88 | w: 00222, 89 | x: 00111, 90 | }.freeze 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/train/platforms/detect/scanner.rb: -------------------------------------------------------------------------------- 1 | require_relative "helpers/os_common" 2 | 3 | module Train::Platforms::Detect 4 | class Scanner 5 | include Train::Platforms::Detect::Helpers::OSCommon 6 | 7 | def initialize(backend) 8 | @backend = backend 9 | @platform = {} 10 | @family_hierarchy = [] 11 | 12 | # detect cache variables 13 | @files = {} 14 | @uname = {} 15 | @lsb = {} 16 | @cache = {} 17 | end 18 | 19 | # Main detect method to scan all platforms for a match 20 | # 21 | # @return Train::Platform instance or error if none found 22 | def scan 23 | # start with the platform/families who have no families (the top levels) 24 | top = Train::Platforms.top_platforms 25 | top.each do |_name, plat| 26 | # we are doing a instance_eval here to make sure we have the proper 27 | # context with all the detect helper methods 28 | next unless instance_eval(&plat.detect) 29 | 30 | # if we have a match start looking at the children 31 | plat_result = scan_children(plat) 32 | next if plat_result.nil? 33 | 34 | # return platform to backend 35 | @family_hierarchy << plat.name 36 | return get_platform(plat_result) 37 | end 38 | 39 | raise Train::PlatformDetectionFailed, "Sorry, we are unable to detect your platform" 40 | end 41 | 42 | def scan_children(parent) 43 | parent.children.each do |plat, condition| 44 | next unless instance_eval(&plat.detect) 45 | 46 | if plat.class == Train::Platforms::Platform 47 | return plat if condition.empty? || check_condition(condition) 48 | elsif plat.class == Train::Platforms::Family 49 | plat = scan_family_children(plat) 50 | return plat unless plat.nil? 51 | end 52 | end 53 | 54 | nil 55 | end 56 | 57 | def scan_family_children(plat) 58 | child_result = scan_children(plat) unless plat.children.nil? 59 | return if child_result.nil? 60 | 61 | @family_hierarchy << plat.name 62 | child_result 63 | end 64 | 65 | def check_condition(condition) 66 | condition.each do |k, v| 67 | op, expected = v.strip.split(" ") 68 | op = "==" if op == "=" 69 | return false if @platform[k].nil? || !instance_eval("'#{@platform[k]}' #{op} '#{expected}'") 70 | end 71 | 72 | true 73 | end 74 | 75 | def get_platform(plat) 76 | plat.backend = @backend 77 | plat.platform = @platform 78 | plat.add_platform_methods 79 | plat.family_hierarchy = @family_hierarchy 80 | plat 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /.expeditor/config.yml: -------------------------------------------------------------------------------- 1 | # Documentation available at https://expeditor.chef.io/docs/getting-started/ 2 | --- 3 | # Slack channel in Chef Software slack to send notifications about build failures, etc 4 | slack: 5 | notify_channel: 6 | - chef-found-notify 7 | - inspec-notify 8 | 9 | # This publish is triggered by the `built_in:publish_rubygems` artifact_action. 10 | rubygems: 11 | - train 12 | - train-core 13 | 14 | github: 15 | # This deletes the GitHub PR branch after successfully merged into the release branch 16 | delete_branch_on_merge: true 17 | # The tag format to use (e.g. v1.0.0) 18 | version_tag_format: "v{{version}}" 19 | # allow bumping the minor release via label 20 | minor_bump_labels: 21 | - "Expeditor: Bump Minor Version" 22 | major_bump_labels: 23 | - "Expeditor: Bump Major Version" 24 | 25 | release_branches: 26 | - 2-stable: 27 | version_constraint: 2.* 28 | - main: 29 | version_constraint: 3.* 30 | 31 | changelog: 32 | rollup_header: Changes not yet released to rubygems.org 33 | 34 | subscriptions: 35 | # These actions are taken, in order they are specified, anytime a Pull Request is merged. 36 | - workload: pull_request_merged:{{github_repo}}:{{release_branch}}:* 37 | actions: 38 | - built_in:bump_version: 39 | ignore_labels: 40 | - "Expeditor: Skip Version Bump" 41 | - "Expeditor: Skip All" 42 | - bash:.expeditor/update_version.sh: 43 | only_if: built_in:bump_version 44 | - built_in:update_changelog: 45 | ignore_labels: 46 | - "Expeditor: Skip Changelog" 47 | - "Expeditor: Skip All" 48 | - built_in:build_gem: 49 | only_if: built_in:bump_version 50 | - trigger_pipeline:coverage: 51 | post_commit: true 52 | - workload: pull_request_opened:{{github_repo}}:{{release_branch}}:* 53 | actions: 54 | - post_github_comment:.expeditor/templates/pull_request.mustache: 55 | ignore_team_members: 56 | - inspec/owners 57 | - inspec/inspec-core-team 58 | - built_in:github_auto_assign_author: 59 | only_if_team_member: 60 | - inspec/owners 61 | - inspec/inspec-core-team 62 | - workload: project_promoted:{{agent_id}}:* 63 | actions: 64 | - built_in:rollover_changelog 65 | - built_in:publish_rubygems 66 | # - built_in:create_github_release # This is not yet supported for rubygems - see https://github.com/chef/expeditor/pull/1182 67 | 68 | pipelines: 69 | - verify: 70 | description: Pull Request validation tests 71 | public: true 72 | - coverage: 73 | description: Generate test coverage report 74 | trigger: pull_request -------------------------------------------------------------------------------- /test/unit/transports/ssh_connection_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/ssh" 3 | require "train/transports/ssh_connection" 4 | 5 | # Mocha limitations don't let us mock a function 6 | # such that it can receive a block, so here's a minimal 7 | # class that simulates Net::SSH::Connection::Channel to the 8 | # extent required by Transports::SSH::Connection 9 | class MockChannel 10 | def exec(cmd) 11 | @cmd = cmd 12 | yield("ignored", true) 13 | end 14 | 15 | def data_handler 16 | @handler 17 | end 18 | 19 | def on_data(&block) 20 | @handler = block 21 | end 22 | 23 | def mock_inbound_data(data) 24 | # trigger the 'on-data' event off of the channel. 25 | @handler.call("ignored", data) 26 | end 27 | 28 | def on_extended_data; end 29 | 30 | def on_request(any); end 31 | 32 | def on_close(&block); end 33 | end 34 | 35 | describe "ssh connection" do 36 | let(:cls) do 37 | plat = Train::Platforms.name("mock").in_family("linux") 38 | plat.add_platform_methods 39 | Train::Platforms::Detect.stubs(:scan).returns(plat) 40 | Train::Transports::SSH::Connection 41 | end 42 | let(:conf) do 43 | { 44 | host: rand.to_s, 45 | password: rand.to_s, 46 | transport_options: {}, 47 | } 48 | end 49 | 50 | describe "#run_command_via_connection through BaseConnection::run_command" do 51 | let(:ssh) { cls.new(conf) } 52 | # A bit more mocking than I'd like to see, but there's no sane way around 53 | # it if we want to test output handling behavior. 54 | let(:inbound_data) { "testdata" } 55 | let(:channel_mock) { MockChannel.new } 56 | 57 | let(:session_mock) do 58 | session_mock = mock 59 | session_mock.stubs(:closed?).returns false 60 | session_mock.stubs(:open_channel).yields(channel_mock) 61 | # Simulate the way that Net::SSH::Session processes request(s) on invoking #loop. 62 | session_mock.stubs(:loop).with do 63 | channel_mock.mock_inbound_data(inbound_data) 64 | end 65 | session_mock 66 | end 67 | 68 | it "invokes the provided block when a block is provided and data is received" do 69 | ssh.stubs(:session).returns(session_mock) 70 | called = false 71 | # run_command b/c run_command_via_connection is private. 72 | ssh.run_command("test") do |data| 73 | called = true 74 | _(data).must_equal inbound_data 75 | end 76 | _(called).must_equal true 77 | 78 | end 79 | 80 | it "accepts an options hash" do 81 | ssh.stubs(:session).returns(session_mock) 82 | _(ssh.run_command("keychecks off; safety -o; white rabbit object", my_option: "my value").stdout).must_equal "testdata" 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/train/platforms/detect/helpers/os_linux.rb: -------------------------------------------------------------------------------- 1 | module Train::Platforms::Detect::Helpers 2 | module Linux 3 | def redhatish_platform(conf) 4 | conf =~ /^red hat/i ? "redhat" : /(\w+)/i.match(conf)[1].downcase 5 | end 6 | 7 | def redhatish_version(conf) 8 | case conf 9 | when /rawhide/i 10 | /((\d+) \(Rawhide\))/i.match(conf)[1].downcase 11 | when /Amazon Linux/i 12 | /([\d\.]+)/.match(conf)[1] 13 | when /derived from .*linux|amazon/i 14 | /Linux ((\d+|\.)+)/i.match(conf)[1] 15 | else 16 | /release ([\d\.]+)/.match(conf)[1] 17 | end 18 | end 19 | 20 | def redhatish(path) 21 | if (raw = unix_file_contents(path)) 22 | @platform[:release] = redhatish_version(raw) 23 | true 24 | end 25 | end 26 | 27 | def linux_os_release 28 | data = unix_file_contents("/etc/os-release") 29 | return if data.nil? 30 | 31 | os_info = parse_os_release_info(data) 32 | cisco_info_file = os_info["CISCO_RELEASE_INFO"] 33 | if cisco_info_file 34 | os_info.merge!(parse_os_release_info(unix_file_contents(cisco_info_file))) 35 | end 36 | 37 | os_info 38 | end 39 | 40 | def parse_os_release_info(raw) 41 | return {} if raw.nil? 42 | 43 | raw.lines.each_with_object({}) do |line, memo| 44 | line.strip! 45 | next if line.nil? || line.empty? 46 | next if line.start_with?("#") 47 | 48 | key, value = line.split("=", 2) 49 | memo[key] = value.gsub(/\A"|"\Z/, "") unless value.nil? || value.empty? 50 | end 51 | end 52 | 53 | def lsb_config(content) 54 | id = /^DISTRIB_ID=["']?(.+?)["']?$/.match(content) 55 | release = /^DISTRIB_RELEASE=["']?(.+?)["']?$/.match(content) 56 | codename = /^DISTRIB_CODENAME=["']?(.+?)["']?$/.match(content) 57 | { 58 | id: id.nil? ? nil : id[1], 59 | release: release.nil? ? nil : release[1], 60 | codename: codename.nil? ? nil : codename[1], 61 | } 62 | end 63 | 64 | def lsb_release(content) 65 | id = /^Distributor ID:\s+(.+)$/.match(content) 66 | release = /^Release:\s+(.+)$/.match(content) 67 | codename = /^Codename:\s+(.+)$/.match(content) 68 | { 69 | id: id.nil? ? nil : id[1], 70 | release: release.nil? ? nil : release[1], 71 | codename: codename.nil? ? nil : codename[1], 72 | } 73 | end 74 | 75 | def read_linux_lsb 76 | return @lsb unless @lsb.empty? 77 | 78 | if !(raw = unix_file_contents("/etc/lsb-release")).nil? 79 | @lsb = lsb_config(raw) 80 | elsif !(raw = unix_file_contents("/usr/bin/lsb-release")).nil? 81 | @lsb = lsb_release(raw) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/unit/file/remote/qnx_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/local" 3 | require "train/file/remote/qnx" 4 | require "train/transports/mock" 5 | 6 | describe Train::File::Remote::Qnx do 7 | let(:cls) { Train::File::Remote::Qnx } 8 | let(:backend) do 9 | backend = Train::Transports::Mock.new.connection 10 | backend.mock_os({ family: "qnx" }) 11 | backend 12 | end 13 | 14 | it "returns file contents when the file exists" do 15 | out = rand.to_s 16 | backend.mock_command("cat path", out) 17 | file = cls.new(backend, "path") 18 | file.stubs(:exist?).returns(true) 19 | _(file.content).must_equal out 20 | end 21 | 22 | it "returns nil contents when the file does not exist" do 23 | file = cls.new(backend, "path") 24 | file.stubs(:exist?).returns(false) 25 | _(file.content).must_be_nil 26 | end 27 | 28 | it "returns a file type" do 29 | backend.mock_command("file path", "blah directory blah") 30 | _(cls.new(backend, "path").type).must_equal :directory 31 | end 32 | 33 | it "returns a directory type" do 34 | backend.mock_command("file path", "blah regular file blah") 35 | _(cls.new(backend, "path").type).must_equal :file 36 | end 37 | 38 | it "raises exception for unimplemented methods" do 39 | file = cls.new(backend, "path") 40 | %w{mode owner group uid gid mtime size selinux_label link_path mounted stat}.each do |m| 41 | _ { file.send(m) }.must_raise NotImplementedError 42 | end 43 | end 44 | 45 | describe "#md5sum" do 46 | let(:md5_checksum) { "17404a596cbd0d1e6c7d23fcd845ab82" } 47 | 48 | let(:ruby_md5_mock) do 49 | checksum_mock = mock 50 | checksum_mock.expects(:update).returns("") 51 | checksum_mock.expects(:hexdigest).returns(md5_checksum) 52 | checksum_mock 53 | end 54 | 55 | it "defaults to a Ruby based checksum if other methods fail" do 56 | backend.mock_command("md5sum /tmp/testfile", "", "", 1) 57 | Digest::MD5.expects(:new).returns(ruby_md5_mock) 58 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 59 | end 60 | end 61 | 62 | describe "#sha256sum" do 63 | let(:sha256_checksum) do 64 | "ec864fe99b539704b8872ac591067ef22d836a8d942087f2dba274b301ebe6e5" 65 | end 66 | 67 | let(:ruby_sha256_mock) do 68 | checksum_mock = mock 69 | checksum_mock.expects(:update).returns("") 70 | checksum_mock.expects(:hexdigest).returns(sha256_checksum) 71 | checksum_mock 72 | end 73 | 74 | it "defaults to a Ruby based checksum if other methods fail" do 75 | backend.mock_command("sha256sum /tmp/testfile", "", "", 1) 76 | Digest::SHA256.expects(:new).returns(ruby_sha256_mock) 77 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/unit/file/remote/windows_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/mock" 3 | require "train/file/remote/windows" 4 | 5 | describe Train::File::Remote::Windows do 6 | let(:cls) { Train::File::Remote::Windows } 7 | let(:backend) do 8 | backend = Train::Transports::Mock.new.connection 9 | backend.mock_os({ family: "windows" }) 10 | backend 11 | end 12 | 13 | describe "#md5sum" do 14 | let(:md5_checksum) { "4ce0c733cdcf1d2f78532bbd9ce3441d" } 15 | let(:filepath) { 'C:\Windows\explorer.exe' } 16 | 17 | let(:ruby_md5_mock) do 18 | checksum_mock = mock 19 | checksum_mock.expects(:update).returns("") 20 | checksum_mock.expects(:hexdigest).returns(md5_checksum) 21 | checksum_mock 22 | end 23 | 24 | it "defaults to a Ruby based checksum if other methods fail" do 25 | backend.mock_command("CertUtil -hashfile #{filepath} MD5", "", "", 1) 26 | Digest::MD5.expects(:new).returns(ruby_md5_mock) 27 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 28 | end 29 | 30 | it "calculates the correct md5sum on the `windows` platform family" do 31 | output = <<-EOC 32 | MD5 hash of file C:\\Windows\\explorer.exe:\r 33 | 4c e0 c7 33 cd cf 1d 2f 78 53 2b bd 9c e3 44 1d\r 34 | CertUtil: -hashfile command completed successfully.\r 35 | EOC 36 | 37 | backend.mock_command("CertUtil -hashfile #{filepath} MD5", output) 38 | _(cls.new(backend, filepath).md5sum).must_equal md5_checksum 39 | end 40 | end 41 | 42 | describe "#sha256sum" do 43 | let(:sha256_checksum) do 44 | "85270240a5fd51934f0627c92b2282749d071fdc9ac351b81039ced5b10f798b" 45 | end 46 | let(:filepath) { 'C:\Windows\explorer.exe' } 47 | 48 | let(:ruby_sha256_mock) do 49 | checksum_mock = mock 50 | checksum_mock.expects(:update).returns("") 51 | checksum_mock.expects(:hexdigest).returns(sha256_checksum) 52 | checksum_mock 53 | end 54 | 55 | it "defaults to a Ruby based checksum if other methods fail" do 56 | backend.mock_command("CertUtil -hashfile #{filepath} SHA256", "", "", 1) 57 | Digest::SHA256.expects(:new).returns(ruby_sha256_mock) 58 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 59 | end 60 | 61 | it "calculates the correct sha256sum on the `windows` platform family" do 62 | output = <<-EOC 63 | SHA256 hash of file C:\\Windows\\explorer.exe:\r 64 | 85 27 02 40 a5 fd 51 93 4f 06 27 c9 2b 22 82 74 9d 07 1f dc 9a c3 51 b8 10 39 ce d5 b1 0f 79 8b\r 65 | CertUtil: -hashfile command completed successfully.\r 66 | EOC 67 | 68 | backend.mock_command("CertUtil -hashfile #{filepath} SHA256", output) 69 | _(cls.new(backend, filepath).sha256sum).must_equal sha256_checksum 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/unit/platforms/detect/os_common_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class OsDetectLinuxTester 4 | attr_reader :platform 5 | include Train::Platforms::Detect::Helpers::OSCommon 6 | 7 | def initialize 8 | @platform = {} 9 | end 10 | end 11 | 12 | describe "os_common" do 13 | let(:detector) { OsDetectLinuxTester.new } 14 | 15 | describe "winrm? check" do 16 | it "return winrm? true" do 17 | OsDetectLinuxTester.any_instance.stubs(:backend_name).returns("TrainPlugins::WinRM::Connection") 18 | _(detector.winrm?).must_equal(true) 19 | end 20 | 21 | it "return winrm? false when winrm is not loaded" do 22 | OsDetectLinuxTester.any_instance.stubs(:backend_name).returns("Something::Else") 23 | _(detector.winrm?).must_equal(false) 24 | end 25 | end 26 | 27 | describe "unix file contents" do 28 | it "return new file contents" do 29 | be = mock("Backend") 30 | output = mock("Output", exit_status: 0) 31 | output.expects(:stdout).returns("test") 32 | be.stubs(:run_command).with("test -f /etc/fstab && cat /etc/fstab").returns(output) 33 | detector.instance_variable_set(:@backend, be) 34 | detector.instance_variable_set(:@files, {}) 35 | _(detector.unix_file_contents("/etc/fstab")).must_equal("test") 36 | end 37 | 38 | it "return new file contents cached" do 39 | be = mock("Backend") 40 | detector.instance_variable_set(:@backend, be) 41 | detector.instance_variable_set(:@files, { "/etc/profile" => "test" }) 42 | _(detector.unix_file_contents("/etc/profile")).must_equal("test") 43 | end 44 | end 45 | 46 | describe "unix file exist?" do 47 | it "file does exist" do 48 | be = mock("Backend") 49 | be.stubs(:run_command).with("test -f /etc/test").returns(mock("Output", exit_status: 0)) 50 | detector.instance_variable_set(:@backend, be) 51 | _(detector.unix_file_exist?("/etc/test")).must_equal(true) 52 | end 53 | end 54 | 55 | describe "#detect_linux_arch" do 56 | it "uname m call" do 57 | be = mock("Backend") 58 | be.stubs(:run_command).with("uname -m").returns(mock("Output", stdout: "x86_64\n", stderr: "")) 59 | detector.instance_variable_set(:@backend, be) 60 | detector.instance_variable_set(:@uname, {}) 61 | _(detector.unix_uname_m).must_equal("x86_64") 62 | end 63 | 64 | it "uname s call" do 65 | be = mock("Backend") 66 | be.stubs(:run_command).with("uname -s").returns(mock("Output", stdout: "linux", stderr: "")) 67 | detector.instance_variable_set(:@backend, be) 68 | detector.instance_variable_set(:@uname, {}) 69 | _(detector.unix_uname_s).must_equal("linux") 70 | end 71 | 72 | it "uname r call" do 73 | be = mock("Backend") 74 | be.stubs(:run_command).with("uname -r").returns(mock("Output", stdout: "17.0.0\n", stderr: "")) 75 | detector.instance_variable_set(:@backend, be) 76 | detector.instance_variable_set(:@uname, {}) 77 | _(detector.unix_uname_r).must_equal("17.0.0") 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/train/platforms/platform.rb: -------------------------------------------------------------------------------- 1 | module Train::Platforms 2 | class Platform 3 | include Train::Platforms::Common 4 | attr_accessor :backend, :condition, :families, :family_hierarchy, :platform 5 | 6 | def initialize(name, condition = {}) 7 | @name = name 8 | @condition = condition 9 | @families = {} 10 | @family_hierarchy = [] 11 | @platform = {} 12 | @detect = nil 13 | @title = name.to_s.capitalize 14 | 15 | # add itself to the platform list 16 | Train::Platforms.list[name] = self 17 | end 18 | 19 | def direct_families 20 | @families.collect { |k, _v| k.name } 21 | end 22 | 23 | def find_family_hierarchy(platform = self) 24 | families = platform.families.map { |k, v| [k.name, find_family_hierarchy(k)] } 25 | 26 | @family_hierarchy = families.flatten 27 | end 28 | 29 | def family 30 | @platform[:family] || @family_hierarchy[0] 31 | end 32 | 33 | def name 34 | # Override here incase a updated name was set 35 | # during the detect logic 36 | clean_name 37 | end 38 | 39 | def clean_name(force: false) 40 | @cleaned_name = nil if force 41 | @cleaned_name ||= (@platform[:name] || @name).downcase.tr(" ", "_") 42 | end 43 | 44 | def uuid 45 | @uuid ||= Train::Platforms::Detect::UUID.new(self).find_or_create_uuid.downcase 46 | end 47 | 48 | # This is for backwards compatibility with 49 | # the current inspec os resource. 50 | def[](name) 51 | if respond_to?(name) 52 | send(name) 53 | else 54 | "unknown" 55 | end 56 | end 57 | 58 | def title(title = nil) 59 | return @title if title.nil? 60 | 61 | @title = title 62 | self 63 | end 64 | 65 | def to_hash 66 | @platform 67 | end 68 | 69 | # Add generic family? and platform methods to an existing platform 70 | # 71 | # This is done later to add any custom 72 | # families/properties that were created 73 | def add_platform_methods 74 | # Redo clean name if there is a detect override 75 | clean_name(force: true) unless @platform[:name].nil? 76 | 77 | # Add in family methods 78 | family_list = Train::Platforms.families 79 | family_list.each_value do |k| 80 | name = "#{k.name}?" 81 | 82 | next if respond_to?(name) 83 | 84 | define_singleton_method(name) do 85 | family_hierarchy.include?(k.name) 86 | end 87 | end 88 | 89 | # Helper methods for direct platform info 90 | @platform.each_key do |m| 91 | next if respond_to?(m) 92 | 93 | define_singleton_method(m) do 94 | @platform[m] 95 | end 96 | end 97 | 98 | # Create method for name if its not already true 99 | m = name + "?" 100 | return if respond_to?(m) 101 | 102 | define_singleton_method(m) do 103 | true 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/unit/file/remote/aix_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/local" 3 | require "train/file/remote/aix" 4 | require "train/transports/mock" 5 | 6 | describe Train::File::Remote::Aix do 7 | let(:cls) { Train::File::Remote::Aix } 8 | let(:backend) do 9 | backend = Train::Transports::Mock.new.connection 10 | backend.mock_os({ name: "aix", family: "unix" }) 11 | backend 12 | end 13 | 14 | it "returns a nil link_path if the object is not a symlink" do 15 | file = cls.new(backend, "path") 16 | file.stubs(:symlink?).returns(false) 17 | _(file.link_path).must_be_nil 18 | end 19 | 20 | it "returns a correct link_path" do 21 | file = cls.new(backend, "path") 22 | file.stubs(:symlink?).returns(true) 23 | backend.mock_command("perl -e 'print readlink shift' path", "our_link_path") 24 | _(file.link_path).must_equal "our_link_path" 25 | end 26 | 27 | it "returns a correct shallow_link_path" do 28 | file = cls.new(backend, "path") 29 | file.stubs(:symlink?).returns(true) 30 | backend.mock_command("perl -e 'print readlink shift' path", "our_link_path") 31 | _(file.link_path).must_equal "our_link_path" 32 | end 33 | 34 | describe "#md5sum" do 35 | let(:md5_checksum) { "57d4c6f9d15313fd5651317e588c035d" } 36 | 37 | let(:ruby_md5_mock) do 38 | checksum_mock = mock 39 | checksum_mock.expects(:update).returns("") 40 | checksum_mock.expects(:hexdigest).returns(md5_checksum) 41 | checksum_mock 42 | end 43 | 44 | it "defaults to a Ruby based checksum if other methods fail" do 45 | backend.mock_command("md5sum /tmp/testfile", "", "", 1) 46 | Digest::MD5.expects(:new).returns(ruby_md5_mock) 47 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 48 | end 49 | 50 | it "calculates the correct md5sum on the `aix` platform family" do 51 | output = "#{md5_checksum} /tmp/testfile" 52 | backend.mock_command("md5sum /tmp/testfile", output) 53 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 54 | end 55 | end 56 | 57 | describe "#sha256sum" do 58 | let(:sha256_checksum) do 59 | "491260aaa6638d4a64c714a17828c3d82bad6ca600c9149b3b3350e91bcd283d" 60 | end 61 | 62 | let(:ruby_sha256_mock) do 63 | checksum_mock = mock 64 | checksum_mock.expects(:update).returns("") 65 | checksum_mock.expects(:hexdigest).returns(sha256_checksum) 66 | checksum_mock 67 | end 68 | 69 | it "defaults to a Ruby based checksum if other methods fail" do 70 | backend.mock_command("sha256sum /tmp/testfile", "", "", 1) 71 | Digest::SHA256.expects(:new).returns(ruby_sha256_mock) 72 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 73 | end 74 | 75 | it "calculates the correct sha256sum on the `aix` platform family" do 76 | output = "#{sha256_checksum} /tmp/testfile" 77 | backend.mock_command("sha256sum /tmp/testfile", output) 78 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/integration/cookbooks/test/recipes/default.rb: -------------------------------------------------------------------------------- 1 | # author: Dominik Richter 2 | # 3 | # Helper recipe to create create a few files in the operating 4 | # systems, which the runner will test against. 5 | # It also initializes the runner inside the machines 6 | # and makes sure all dependencies are ready to go. 7 | # 8 | # Finally (for now), it actually executes the all tests with 9 | # the local execution backend 10 | 11 | include_recipe "sudo" 12 | include_recipe "::prep_files" 13 | 14 | # prepare ssh for backend 15 | execute "create ssh key" do 16 | command 'ssh-keygen -t rsa -b 2048 -f /root/.ssh/id_rsa -N ""' 17 | not_if "test -e /root/.ssh/id_rsa" 18 | end 19 | 20 | execute "add ssh key to vagrant user" do 21 | command "cat /root/.ssh/id_rsa.pub >> /home/vagrant/.ssh/authorized_keys" 22 | end 23 | 24 | execute "test ssh connection" do 25 | command 'ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_rsa vagrant@localhost "echo 1"' 26 | end 27 | 28 | # prepare a few users 29 | %w{ nopasswd passwd nosudo reqtty customcommand }.each do |name| 30 | user name do 31 | password "$1$7MCNTXPI$r./jqCEoVlLlByYKSL3sZ." 32 | manage_home true 33 | end 34 | end 35 | 36 | %w{nopasswd vagrant}.each do |name| 37 | sudo name do 38 | user "%" + name 39 | nopasswd true 40 | defaults ["!requiretty"] 41 | end 42 | end 43 | 44 | sudo "passwd" do 45 | user "passwd" 46 | nopasswd false 47 | defaults ["!requiretty"] 48 | end 49 | 50 | sudo "reqtty" do 51 | user "reqtty" 52 | nopasswd true 53 | defaults ["requiretty"] 54 | end 55 | 56 | sudo "customcommand" do 57 | user "customcommand" 58 | nopasswd true 59 | defaults ["!requiretty"] 60 | end 61 | 62 | build_essential 63 | 64 | # execute tests 65 | execute "bundle install" do 66 | command "/opt/chef/embedded/bin/bundle config set --local without integration tools" 67 | command "/opt/chef/embedded/bin/bundle install" 68 | cwd "/tmp/kitchen/data" 69 | end 70 | 71 | execute "run local tests" do 72 | command "/opt/chef/embedded/bin/ruby -I lib test/integration/test_local.rb test/integration/tests/*_test.rb" 73 | cwd "/tmp/kitchen/data" 74 | end 75 | 76 | execute "run ssh tests" do 77 | command "/opt/chef/embedded/bin/ruby -I lib test/integration/test_ssh.rb test/integration/tests/*_test.rb" 78 | cwd "/tmp/kitchen/data" 79 | end 80 | 81 | %w{passwd nopasswd reqtty customcommand}.each do |name| 82 | execute "run local sudo tests as #{name}" do 83 | command "/opt/chef/embedded/bin/ruby -I lib test/integration/sudo/#{name}.rb" 84 | cwd "/tmp/kitchen/data" 85 | user name 86 | end 87 | end 88 | 89 | execute "fix sudoers for reqtty" do 90 | command "chef-apply contrib/fixup_requiretty.rb" 91 | cwd "/tmp/kitchen/data" 92 | environment( 93 | "TRAIN_SUDO_USER" => "reqtty", 94 | "TRAIN_SUDO_VERY_MUCH" => "yes" 95 | ) 96 | end 97 | 98 | # if it's fixed, it should behave like user 'nopasswd' 99 | execute "run local sudo tests as reqtty, no longer requiring a tty" do 100 | command "/opt/chef/embedded/bin/ruby -I lib test/integration/sudo/nopasswd.rb" 101 | cwd "/tmp/kitchen/data" 102 | user "reqtty" 103 | end 104 | -------------------------------------------------------------------------------- /lib/train/platforms.rb: -------------------------------------------------------------------------------- 1 | require_relative "platforms/common" 2 | require_relative "platforms/detect" 3 | require_relative "platforms/detect/scanner" 4 | require_relative "platforms/detect/specifications/os" 5 | require_relative "platforms/detect/specifications/api" 6 | require_relative "platforms/detect/uuid" 7 | require_relative "platforms/family" 8 | require_relative "platforms/platform" 9 | 10 | module Train::Platforms 11 | # Retrieve the current platform list 12 | # 13 | # @return [Hash] map with platform names and their objects 14 | def self.list 15 | @list ||= {} 16 | end 17 | 18 | # Retrieve the current family list 19 | # 20 | # @return [Hash] map with family names and their objects 21 | def self.families 22 | @families ||= {} 23 | end 24 | 25 | # Clear all platform settings. Only used for testing. 26 | def self.__reset 27 | @list = {} 28 | @families = {} 29 | end 30 | 31 | # Create or update a platform 32 | # 33 | # @return Train::Platform 34 | def self.name(name, condition = {}) 35 | # TODO: refactor this against family. They're stupidly similar 36 | # Check the list to see if one is already created 37 | plat = list[name] 38 | unless plat.nil? 39 | # Pass the condition incase we are adding a family relationship 40 | plat.condition = condition unless condition.nil? 41 | return plat 42 | end 43 | 44 | Train::Platforms::Platform.new(name, condition) 45 | end 46 | 47 | # Create or update a family 48 | # 49 | # @return Train::Platforms::Family 50 | def self.family(name, condition = {}) 51 | # Check the families to see if one is already created 52 | family = families[name] 53 | unless family.nil? 54 | # Pass the condition incase we are adding a family relationship 55 | family.condition = condition unless condition.nil? 56 | return family 57 | end 58 | 59 | Train::Platforms::Family.new(name, condition) 60 | end 61 | 62 | # Find the families or top level platforms 63 | # 64 | # @return [Hash] with top level family and platforms 65 | def self.top_platforms 66 | empty_list = list.select { |_key, value| value.families.empty? } 67 | empty_fams = families.select { |_key, value| value.families.empty? } 68 | 69 | empty_list.merge empty_fams 70 | end 71 | 72 | # List all platforms and families in a readable output 73 | def self.list_all 74 | top_platforms = self.top_platforms 75 | top_platforms.each_value do |platform| 76 | puts platform.title 77 | print_children(platform) if defined?(platform.children) 78 | end 79 | end 80 | 81 | def self.print_children(parent, pad = 2) 82 | parent.children.each do |key, value| 83 | obj = key 84 | puts "#{" " * pad}-> #{obj.title}#{value unless value.empty?}" 85 | print_children(obj, pad + 2) if defined?(obj.children) && !obj.children.nil? 86 | end 87 | end 88 | 89 | def self.export 90 | export = [] 91 | list.each do |name, platform| 92 | platform.find_family_hierarchy 93 | export << { 94 | name: name, 95 | families: platform.family_hierarchy, 96 | } 97 | end 98 | export.sort_by { |platform| platform[:name] } 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/README.md: -------------------------------------------------------------------------------- 1 | # Example Train Plugin - train-local-rot13 2 | 3 | This plugin is provided as a teaching example for building a Train plugin. Train plugins allow you to connect to remote systems or APIs, so that other tools such as InSpec or Chef Workstation can talk over the connection. 4 | 5 | train-local-rot13's functionality is simple: it acts as a local transport (targeting the local machine), but it applies the [rot13](https://en.wikipedia.org/wiki/ROT13) trivial cypher transformation on the contents of each file it reads, and on the stdout of every command it executes. 6 | 7 | Please note that ROT13 is an incredibly weak cypher, and can be broken by most elementary school students. Do not use this plugin for security purposes. 8 | 9 | ## Relationship between InSpec and Train 10 | 11 | Train itself has no CLI, nor a sophisticated test harness. InSpec does have such facilities, so installing Train plugins will require an InSpec installation. You do not need to use or understand InSpec. 12 | 13 | Train plugins may be developed without an InSpec installation. 14 | 15 | ## To Install this as a User 16 | 17 | You will need InSpec v2.3 or later. 18 | 19 | If you just want to use this (not learn how to write a plugin), you can so by simply running: 20 | 21 | ``` 22 | $ inspec plugin install train-local-rot13 23 | ``` 24 | 25 | You can then run: 26 | 27 | ``` 28 | $ inspec detect -t local-rot13:// 29 | == Platform Details 30 | 31 | Name: local-rot13 32 | Families: unix, os, windows, os 33 | Release: 0.1.0 34 | Arch: example 35 | 36 | $ inspec shell -t local-rot13:// -c 'command("echo hello")' 37 | uryyb 38 | ``` 39 | 40 | ## Features of This Example Kit 41 | 42 | This example plugin is a full-fledged plugin example, with everything a real-world, industrial grade plugin would have, including: 43 | 44 | * an implementation of a Train plugin, using the Train Plugin V1 API, including 45 | * a Transport 46 | * a Connection 47 | * Platform configuration 48 | * documentation (you are reading it now) 49 | * tests, at the unit and functional level 50 | * a .gemspec, for packaging and publishing it as a gem 51 | * a Gemfile, for managing its dependencies 52 | * a Rakefile, for running development tasks 53 | * Rubocop linting support for using the base Train project rubocop.yml (See Rakefile) 54 | 55 | You are encouraged to use this plugin as a starting point for real plugins. 56 | 57 | ## Development of a Plugin 58 | 59 | [Plugin Development](https://github.com/inspec/train/blob/master/docs/plugins.md) is documented on the `train` project on GitHub. Additionally, this example 60 | plugin has extensive comments explaining what is happening, and why. 61 | 62 | ### A Tour of the Plugin 63 | 64 | One nice circuit of the plugin might be: 65 | * look at the gemspec, to see what the plugin thinks it does 66 | * look at the functional tests, to see the plugin proving it does what it says 67 | * look at the unit tests, to see how the plugin claims it is internally structured 68 | * look at the Rakefile, to see how to interact with the project 69 | * look at lib/train-local-rot13.rb, the entry point which InSpec will always load if the plugin is installed 70 | * look at lib/train-local-rot13/transport.rb, the plugin "backbone" 71 | * look at lib/train-local-rot13/connection.rb, the plugin implementation 72 | * look at lib/train-local-rot13/platform.rb, OS platform support declaration 73 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/lib/train-local-rot13/connection.rb: -------------------------------------------------------------------------------- 1 | # Connection definition file for an example Train plugin. 2 | 3 | # Most of the work of a Train plugin happens in this file. 4 | # Connections derive from Train::Plugins::Transport::BaseConnection, 5 | # and provide a variety of services. Later generations of the plugin 6 | # API will likely separate out these responsibilities, but for now, 7 | # some of the responsibilities include: 8 | # * authentication to the target 9 | # * platform / release /family detection 10 | # * caching 11 | # * filesystem access 12 | # * remote command execution 13 | # * API execution 14 | # * marshalling to / from JSON 15 | # You don't have to worry about most of this. 16 | 17 | # This allow us to inherit from Train::Plugins::Transport::BaseConnection 18 | require "train" 19 | 20 | # Push platform detection out to a mixin, as it tends 21 | # to develop at a different cadence than the rest 22 | require "train-local-rot13/platform" 23 | 24 | # This is a support library for our file content meddling 25 | require "train-local-rot13/file_content_rotator" 26 | 27 | # This is a support library for our command meddling 28 | require "mixlib/shellout" 29 | require "ostruct" 30 | 31 | module TrainPlugins 32 | module LocalRot13 33 | # You must inherit from BaseConnection. 34 | class Connection < Train::Plugins::Transport::BaseConnection 35 | # We've placed platform detection in a separate module; pull it in here. 36 | include TrainPlugins::LocalRot13::Platform 37 | 38 | def initialize(options) 39 | # 'options' here is a hash, Symbol-keyed, 40 | # of what Train.target_config decided to do with the URI that it was 41 | # passed by `inspec -t` (or however the application gathered target information) 42 | # Some plugins might use this moment to capture credentials from the URI, 43 | # and the configure an underlying SDK accordingly. 44 | # You might also take a moment to manipulate the options. 45 | # Have a look at the Local, SSH, and AWS transports for ideas about what 46 | # you can do with the options. 47 | 48 | # Regardless, let the BaseConnection have a chance to configure itself. 49 | super(options) 50 | 51 | # If you need to attempt a connection to a remote system, or verify your 52 | # credentials, now is a good time. 53 | end 54 | 55 | # Filesystem access. 56 | # If your plugin is for an API, don't implement this. 57 | # If your plugin supports reading files, you'll need to implement this. 58 | def file_via_connection(path) 59 | train_file = Train::File::Local::Unix.new(self, path) 60 | # But then we wrap the return in a class that meddles with the content. 61 | FileContentRotator.new(train_file) 62 | end 63 | 64 | # Command execution. 65 | # If your plugin is for an API, don't implement this. 66 | # If your plugin supports executing commands, you'll need to implement this. 67 | def run_command_via_connection(cmd) 68 | # Run the command. 69 | run_result = Mixlib::ShellOut.new(cmd) 70 | run_result.run_command 71 | 72 | # Wrap the results in a structure that Train expects... 73 | OpenStruct.new( 74 | # And meddle with the stdout along the way. 75 | stdout: Rot13.rotate(run_result.stdout), 76 | stderr: run_result.stderr, 77 | exit_status: run_result.exitstatus 78 | ) 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /docs/audit_log.md: -------------------------------------------------------------------------------- 1 | # Train Audit Log 2 | 3 | **The Train audit log feature is in preview to determine what features the user base might want and may change at any time.** 4 | 5 | The Train audit log logs the activities happening through the Train connection on the target system. 6 | 7 | Train connections perform three types of operations: 8 | 9 | - file 10 | - command 11 | - API operations 12 | 13 | Train intercepts these operations and writes details about the method call to the audit log before the call is made. 14 | Specifically, it captures the invocations that are executed on the target system through `run_command` and captures file paths that are accessed through the Train connection. 15 | 16 | ## Limitations 17 | 18 | The Train audit log has the following limitations: 19 | 20 | - Strong support for command invocations, but no ability to control level of output detail. 21 | - Limited ability to see file operations. A file access may be seen, but what specific operation may be unknown. 22 | - Not all file operations are performed using the top-level connection methods; some file operations are implemented in the transport plugins and are not captured consistently. 23 | - No support for API calls at this time. No API-based transports will show traffic in the audit log. 24 | 25 | ## Log format 26 | 27 | The audit log records event data in a JSON object. The data returned varies based on the event type. 28 | 29 | ### Command event 30 | 31 | The audit log returns the following command execution data: 32 | 33 | - timestamp 34 | - type (`cmd`) 35 | - command (the invocation run on the target system) 36 | - user (system user who executes the command) 37 | - hostname (hostname of the system if it is a remote target) 38 | 39 | The following example shows data returned from a command event. 40 | 41 | ```json 42 | {"timestamp":"2023-11-06 11:21:32 -0500","app":"train","type":"cmd","command":"whoami"} 43 | ``` 44 | 45 | ### File event 46 | 47 | The audit log returns the following file event data: 48 | 49 | - file path that has been accessed 50 | - timestamp 51 | - type (`file`) 52 | - user (system user who executed the command) 53 | - hostname (hostname of the system if it is a remote target) 54 | 55 | The following example shows data returned from a file event. 56 | 57 | ```json 58 | {"timestamp":"2023-11-06 11:23:59 -0500","app":"train","type":"file","path":"/tmp"} 59 | ``` 60 | 61 | ### API event 62 | 63 | Reserved for future use. 64 | 65 | ## Log Options 66 | 67 | Audit Logs are disabled by default. To enable and configure the audit log, use the following audit log options while creating the Train object: 68 | 69 | - Log size and frequency come from Ruby's Logger implementation. See Ruby's Logger documentation for details. 70 | - `enable_audit_log`: Type Boolean. Default is `false`. 71 | - `audit_log_location`: Type String. Path to audit log file. For example, `"~/chef/logs/my-app-audit.log"`. Default is nil. 72 | - `audit_log_size`: Type Numeric. Maximum file size in bytes before it gets rotated to another file. Log rotation is disabled by default. Defaults to `1048576` (1MB) 73 | - `audit_log_frequency`: Frequency of rotation (`daily`, `weekly`, or `monthly`). Default value is `0`, which disables log file rotation. 74 | 75 | ## Driving Train from IRB 76 | 77 | Note: this is an example for developers; Train is a support library and has no direct UI. Most people use audit log through an application that embeds Train, such as Chef InSpec. 78 | 79 | ```ruby 80 | require 'train' 81 | t = Train.create('local', enable_audit_log: true, audit_log_location: "my.log") 82 | c = t.connection 83 | c.run_command("whoami") 84 | c.file("").content 85 | ``` 86 | -------------------------------------------------------------------------------- /examples/plugins/train-local-rot13/test/functional/local-rot13_test.rb: -------------------------------------------------------------------------------- 1 | # Functional tests for the Train Local Rot13 Example Plugin. 2 | 3 | # Functional tests are used to verify the behaviors of the plugin are as 4 | # expected, to a user. 5 | 6 | # For train, a "user" is a developer using your plugin to access things from 7 | # their app. Unlike unit tests, we don't assume any knowledge of how anything 8 | # works; we just know what it is supposed to do. 9 | 10 | # Include our test harness 11 | require_relative "../helper" 12 | 13 | # Because InSpec is a Spec-style test suite, and Train has a close relationship 14 | # to InSpec, we're going to use MiniTest::Spec here, for familiar look and 15 | # feel. However, this isn't InSpec (or RSpec) code. 16 | describe "train-local-rot13" do 17 | # Our helper.rb locates this library from the Train install that 18 | # Bundler installed for us. If we want its methods, we still must 19 | # import it. Including it here will make it available in all child 20 | # 'describe' blocks. 21 | include TrainPluginFunctionalHelper 22 | 23 | # When thinking up scenarios to test, start with the simplest. 24 | # Then think of each major feature, and exercise them. 25 | # Running combinations of features makes sense if it is very likely, 26 | # or a difficult / dangerous case. You can always add more tests 27 | # here as users find subtle problems. In fact, having a user submit 28 | # a PR that creates a failing functional test is a great way to 29 | # capture the reproduction case. 30 | 31 | # Some tests through here use minitest Expectations, which attach to all 32 | # Objects, and begin with 'must' (positive) or 'wont' (negative) 33 | # See https://ruby-doc.org/stdlib-2.1.0/libdoc/minitest/rdoc/MiniTest/Expectations.html 34 | 35 | # LocalRot13 should do at least this: 36 | # * Not explode when you run Train with it 37 | # * Apply rot13 when you use Train to read a file 38 | # * Apply rot13 when you use Train to run a command 39 | 40 | describe "creating a train instance with this transport" do 41 | # This is a bit of an awkward test. There is no 'wont_raise', so 42 | # we just execute the risky code; if it breaks, the test will be 43 | # registered as an Error. 44 | 45 | it "should not explode on create" do 46 | # This checks for uncaught exceptions. 47 | Train.create("local-rot13") 48 | 49 | # This checks for warnings (or any other output) to stdout/stderr 50 | proc { Train.create("local-rot13") }.must_be_silent 51 | end 52 | 53 | it "should not explode on connect" do 54 | # This checks for uncaught exceptions. 55 | Train.create("local-rot13").connection 56 | 57 | # This checks for warnings (or any other output) to stdout/stderr 58 | proc { Train.create("local-rot13").connection }.must_be_silent 59 | end 60 | end 61 | 62 | describe "reading a file" do 63 | it "should rotate the text by 13 positions" do 64 | conn = Train.create("local-rot13").connection 65 | # Here, plugin_fixtures_path is provided by the TrainPluginFunctionalHelper, 66 | # and refers to the absolute path to the test fixtures directory. 67 | # The file 'hello' simply has the text 'hello' in it. 68 | file_obj = conn.file(File.join(plugin_fixtures_path, "hello")) 69 | file_obj.content.wont_include("hello") 70 | file_obj.content.must_include("uryyb") 71 | end 72 | end 73 | 74 | describe "running a command" do 75 | it "should rotate the stdout by 13 positions" do 76 | conn = Train.create("local-rot13").connection 77 | file_obj = conn.run_command("echo hello") 78 | file_obj.stdout.wont_include("hello") 79 | file_obj.stdout.must_include("uryyb") 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/train/file/remote/windows.rb: -------------------------------------------------------------------------------- 1 | module Train 2 | class File 3 | class Remote 4 | class Windows < Train::File::Remote 5 | attr_reader :path 6 | # Ensures we do not use invalid characters for file names 7 | # @see https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx#naming_conventions 8 | def sanitize_filename(path) 9 | return if path.nil? 10 | 11 | # we do not filter :, backslash and forward slash, since they are part of the path 12 | @spath = path.gsub(/[<>"|?*]/, "") 13 | end 14 | 15 | def basename(suffix = nil, sep = "\\") 16 | super(suffix, sep) 17 | end 18 | 19 | def content 20 | return @content if defined?(@content) 21 | 22 | @content = @backend.run_command("Get-Content(\"#{@spath}\") | Out-String").stdout 23 | return @content unless @content.empty? 24 | 25 | @content = nil if directory? # or size.nil? or size > 0 26 | @content 27 | end 28 | 29 | def content=(new_content) 30 | win_cmd = format('[IO.File]::WriteAllBytes("%s", [Convert]::FromBase64String("%s"))', 31 | base64: Base64.strict_encode64(new_content), 32 | file: @spath) 33 | 34 | @backend.run_command(win_cmd) 35 | 36 | @content = new_content 37 | end 38 | 39 | def exist? 40 | return @exist if defined?(@exist) 41 | 42 | @exist = @backend.run_command( 43 | "(Test-Path -Path \"#{@spath}\").ToString()" 44 | ).stdout.chomp == "True" 45 | end 46 | 47 | def owner 48 | owner = @backend.run_command( 49 | "Get-Acl \"#{@spath}\" | select -expand Owner" 50 | ).stdout.strip 51 | return if owner.empty? 52 | 53 | owner 54 | end 55 | 56 | def type 57 | if attributes.include?("Archive") && !attributes.include?("Directory") 58 | return :file 59 | elsif attributes.include?("ReparsePoint") 60 | return :symlink 61 | elsif attributes.include?("Directory") 62 | return :directory 63 | end 64 | 65 | :unknown 66 | end 67 | 68 | def size 69 | if file? 70 | @backend.run_command("((Get-Item '#{@spath}').Length)").stdout.strip.to_i 71 | end 72 | end 73 | 74 | def product_version 75 | @product_version ||= @backend.run_command( 76 | "[System.Diagnostics.FileVersionInfo]::GetVersionInfo(\"#{@spath}\").ProductVersion" 77 | ).stdout.chomp 78 | end 79 | 80 | def file_version 81 | @file_version ||= @backend.run_command( 82 | "[System.Diagnostics.FileVersionInfo]::GetVersionInfo(\"#{@spath}\").FileVersion" 83 | ).stdout.chomp 84 | end 85 | 86 | %w{ 87 | mode group uid gid mtime selinux_label 88 | }.each do |field| 89 | define_method field.to_sym do 90 | nil 91 | end 92 | end 93 | 94 | def link_path 95 | nil 96 | end 97 | 98 | def shallow_link_path 99 | nil 100 | end 101 | 102 | def mounted 103 | nil 104 | end 105 | 106 | private 107 | 108 | def attributes 109 | return @attributes if defined?(@attributes) 110 | 111 | @attributes = @backend.run_command( 112 | "(Get-ItemProperty -Path \"#{@spath}\").attributes.ToString()" 113 | ).stdout.chomp.split(/\s*,\s*/) 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/unit/plugins/transport_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe "v1 Transport Plugin" do 4 | describe "empty v1 transport plugin" do 5 | let(:plugin) { Class.new(Train.plugin(1)) } 6 | 7 | let(:default_audit_log_options) { 8 | { 9 | enable_audit_log: false, 10 | audit_log_location: nil, 11 | audit_log_app_name: "train", 12 | audit_log_size: nil, 13 | audit_log_frequency: 0, 14 | } 15 | } 16 | 17 | it "initializes an empty configuration" do 18 | _(plugin.new.options).must_equal(default_audit_log_options) 19 | end 20 | 21 | it "saves the provided configuration" do 22 | conf = default_audit_log_options.merge({ a: rand }) 23 | _(plugin.new(conf).options).must_equal(conf) 24 | end 25 | 26 | it "saves the provided configuration" do 27 | conf = default_audit_log_options.merge({ a: rand }) 28 | _(plugin.new(conf).options).must_equal(conf) 29 | end 30 | 31 | it "provides a default logger" do 32 | conf = { a: rand } 33 | _(plugin.new(conf) 34 | .method(:logger).call) 35 | .must_be_instance_of(Logger) 36 | end 37 | 38 | it "can configure custom loggers" do 39 | l = rand 40 | _(plugin.new({ logger: l }) 41 | .method(:logger).call) 42 | .must_equal(l) 43 | end 44 | 45 | it "provides a connection method" do 46 | _ { plugin.new.connection }.must_raise Train::ClientError 47 | end 48 | end 49 | 50 | describe "registered with a name" do 51 | before do 52 | Train::Plugins.registry.clear 53 | end 54 | 55 | it "doesnt have any plugins in the registry if none were configured" do 56 | _(Train::Plugins.registry.empty?).must_equal true 57 | end 58 | 59 | it "is is added to the plugins registry" do 60 | plugin_name = rand 61 | _(Train::Plugins.registry).wont_include(plugin_name) 62 | 63 | plugin = Class.new(Train.plugin(1)) do 64 | name plugin_name 65 | end 66 | 67 | _(Train::Plugins.registry[plugin_name]).must_equal(plugin) 68 | end 69 | end 70 | 71 | describe "with options" do 72 | def train_class(opts = {}) 73 | name = rand.to_s 74 | plugin = Class.new(Train.plugin(1)) do 75 | option name, opts 76 | end 77 | [name, plugin] 78 | end 79 | 80 | it "exposes the parameters via api" do 81 | name, plugin = train_class 82 | _(plugin.default_options.keys).must_equal [name] 83 | end 84 | 85 | it "exposes the parameters via api" do 86 | default = rand.to_s 87 | name, plugin = train_class({ default: default }) 88 | _(plugin.default_options[name][:default]).must_equal default 89 | end 90 | 91 | it "option must be required" do 92 | name, plugin = train_class(required: true) 93 | _(plugin.default_options[name][:required]).must_equal true 94 | end 95 | 96 | it "default option must not be required" do 97 | name, plugin = train_class 98 | _(plugin.default_options[name][:required]).must_be_nil 99 | end 100 | 101 | it "can include options from another module" do 102 | name_a, plugin_a = train_class 103 | b = Class.new(Train.plugin(1)) do 104 | include_options(plugin_a) 105 | end 106 | _(b.default_options[name_a]).wont_be_nil 107 | end 108 | 109 | it "overwrites existing options when including" do 110 | old = rand.to_s 111 | nu = rand.to_s 112 | name_a, plugin_a = train_class({ default: nu }) 113 | b = Class.new(Train.plugin(1)) do 114 | option name_a, default: old 115 | include_options(plugin_a) 116 | end 117 | _(b.default_options[name_a][:default]).must_equal nu 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/unit/transports/cisco_ios_connection_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/ssh" 3 | 4 | describe "CiscoIOSConnection" do 5 | before do 6 | # This is to skip the test on windows as bundle exec rake is giving eror ArgumentError: non-absolute home 7 | skip "not on windows" if windows? 8 | end 9 | 10 | let(:cls) do 11 | plat = Train::Platforms.name("mock").in_family("cisco_ios") 12 | plat.add_platform_methods 13 | def plat.cisco_ios? 14 | true 15 | end 16 | 17 | Train::Platforms::Detect.stubs(:scan).returns(plat) 18 | Train::Transports::SSH 19 | end 20 | 21 | let(:opts) do 22 | { 23 | host: "fakehost", 24 | user: "fakeuser", 25 | password: "fakepassword", 26 | } 27 | end 28 | 29 | let(:connection) do 30 | cls.new(opts).connection 31 | end 32 | 33 | describe "#initialize" do 34 | it "provides a uri" do 35 | _(connection.uri).must_equal "ssh://fakeuser@fakehost:22" 36 | end 37 | end 38 | 39 | describe "#unique_identifier" do 40 | it "returns the correct identifier" do 41 | output = "\r\nProcessor board ID 1111111111\r\n" 42 | Train::Transports::SSH::CiscoIOSConnection.any_instance 43 | .expects(:run_command_via_connection) 44 | .with("show version | include Processor") 45 | .returns(OpenStruct.new(stdout: output)) 46 | _(connection.unique_identifier).must_equal("1111111111") 47 | end 48 | end 49 | 50 | describe "#format_result" do 51 | it "returns correctly when result is 'good'" do 52 | exp = Train::Extras::CommandResult.new("good", "", 0) 53 | assert_equal exp, connection.send(:format_result, "good") 54 | end 55 | 56 | it "returns correctly when result matches /Bad IP address/" do 57 | output = "Translating \"nope\"\r\n\r\nTranslating \"nope\"\r\n\r\n% Bad IP address or host name\r\n% Unknown command or computer name, or unable to find computer address\r\n" 58 | Train::Extras::CommandResult.expects(:new).with("", output, 1) 59 | connection.send(:format_result, output) 60 | end 61 | 62 | it "returns correctly when result matches /Incomplete command/" do 63 | output = "% Incomplete command.\r\n\r\n" 64 | Train::Extras::CommandResult.expects(:new).with("", output, 1) 65 | connection.send(:format_result, output) 66 | end 67 | 68 | it "returns correctly when result matches /Invalid input detected/" do 69 | output = " ^\r\n% Invalid input detected at '^' marker.\r\n\r\n" 70 | Train::Extras::CommandResult.expects(:new).with("", output, 1) 71 | connection.send(:format_result, output) 72 | end 73 | 74 | it "returns correctly when result matches /Unrecognized host/" do 75 | output = "Translating \"nope\"\r\n% Unrecognized host or address, or protocol not running.\r\n\r\n" 76 | Train::Extras::CommandResult.expects(:new).with("", output, 1) 77 | connection.send(:format_result, output) 78 | end 79 | end 80 | 81 | describe "#format_output" do 82 | it "returns the correct output" do 83 | cmd = "show calendar" 84 | output = "show calendar\r\n10:35:50 UTC Fri Mar 23 2018\r\n7200_ios_12#\r\n7200_ios_12#" 85 | result = connection.send(:format_output, output, cmd) 86 | _(result).must_equal "10:35:50 UTC Fri Mar 23 2018" 87 | end 88 | 89 | it "returns the correct output when a pipe is used" do 90 | cmd = "show running-config | section line con 0" 91 | output = "show running-config | section line con 0\r\nline con 0\r\n exec-timeout 0 0\r\n privilege level 15\r\n logging synchronous\r\n stopbits 1\r\n7200_ios_12#\r\n7200_ios_12#" 92 | result = connection.send(:format_output, output, cmd) 93 | _(result).must_equal "line con 0\r\n exec-timeout 0 0\r\n privilege level 15\r\n logging synchronous\r\n stopbits 1" 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/unit/file_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Train::File do 4 | let(:cls) { Train::File } 5 | let(:new_cls) { cls.new(nil, "/temp/file", false) } 6 | 7 | def mockup(stubs) 8 | Class.new(cls) do 9 | stubs.each do |k, v| 10 | define_method k.to_sym do 11 | v 12 | end 13 | end 14 | end.new(nil, nil, false) 15 | end 16 | 17 | it "has the default type of unknown" do 18 | _(new_cls.type).must_equal :unknown 19 | end 20 | 21 | it "throws Not implemented error for exist?" do 22 | # proc { Train.validate_backend({ host: rand }) }.must_raise Train::UserError 23 | _ { new_cls.exist? }.must_raise NotImplementedError 24 | end 25 | 26 | it "throws Not implemented error for mode" do 27 | _ { new_cls.mode }.must_raise NotImplementedError 28 | end 29 | 30 | it "throws Not implemented error for owner" do 31 | _ { new_cls.owner }.must_raise NotImplementedError 32 | end 33 | 34 | it "throws Not implemented error for group" do 35 | _ { new_cls.group }.must_raise NotImplementedError 36 | end 37 | 38 | it "throws Not implemented error for uid" do 39 | _ { new_cls.uid }.must_raise NotImplementedError 40 | end 41 | 42 | it "throws Not implemented error for gid" do 43 | _ { new_cls.gid }.must_raise NotImplementedError 44 | end 45 | 46 | it "throws Not implemented error for content" do 47 | _ { new_cls.content }.must_raise NotImplementedError 48 | end 49 | 50 | it "throws Not implemented error for mtime" do 51 | _ { new_cls.mtime }.must_raise NotImplementedError 52 | end 53 | 54 | it "throws Not implemented error for size" do 55 | _ { new_cls.size }.must_raise NotImplementedError 56 | end 57 | 58 | it "throws Not implemented error for selinux_label" do 59 | _ { new_cls.selinux_label }.must_raise NotImplementedError 60 | end 61 | 62 | it "return path of file" do 63 | _(new_cls.path).must_equal("/temp/file") 64 | end 65 | 66 | it "set product_version to nil" do 67 | _(new_cls.product_version).must_be_nil 68 | end 69 | 70 | it "set product_version to nil" do 71 | _(new_cls.file_version).must_be_nil 72 | end 73 | 74 | describe "type" do 75 | it "recognized type == file" do 76 | fc = mockup(type: :file) 77 | _(fc.file?).must_equal true 78 | end 79 | 80 | it "recognized type == block_device" do 81 | fc = mockup(type: :block_device) 82 | _(fc.block_device?).must_equal true 83 | end 84 | 85 | it "recognized type == character_device" do 86 | fc = mockup(type: :character_device) 87 | _(fc.character_device?).must_equal true 88 | end 89 | 90 | it "recognized type == socket" do 91 | fc = mockup(type: :socket) 92 | _(fc.socket?).must_equal true 93 | end 94 | 95 | it "recognized type == directory" do 96 | fc = mockup(type: :directory) 97 | _(fc.directory?).must_equal true 98 | end 99 | 100 | it "recognized type == pipe" do 101 | fc = mockup(type: :pipe) 102 | _(fc.pipe?).must_equal true 103 | end 104 | 105 | it "recognized type == symlink" do 106 | fc = mockup(type: :symlink) 107 | _(fc.symlink?).must_equal true 108 | end 109 | end 110 | 111 | describe "version" do 112 | it "recognized wrong version" do 113 | fc = mockup(product_version: rand, file_version: rand) 114 | _(fc.version?(rand)).must_equal false 115 | end 116 | 117 | it "recognized product_version" do 118 | x = rand 119 | fc = mockup(product_version: x, file_version: rand) 120 | _(fc.version?(x)).must_equal true 121 | end 122 | 123 | it "recognized file_version" do 124 | x = rand 125 | fc = mockup(product_version: rand, file_version: x) 126 | _(fc.version?(x)).must_equal true 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/unit/file/local/windows_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/mock" 3 | require "train/file/local/windows" 4 | 5 | describe "file common" do 6 | let(:cls) { Train::File::Local::Windows } 7 | let(:backend) do 8 | backend = Train::Transports::Mock.new.connection 9 | backend.mock_os({ family: "windows" }) 10 | backend 11 | end 12 | 13 | it "check escaping of invalid chars in path" do 14 | wf = cls.new(nil, nil) 15 | _(wf.sanitize_filename("c:/test") ).must_equal "c:/test" 16 | _(wf.sanitize_filename("c:/test directory") ).must_equal "c:/test directory" 17 | %w{ < > " * ?}.each do |char| 18 | _(wf.sanitize_filename("c:/test#{char}directory") ).must_equal "c:/testdirectory" 19 | end 20 | end 21 | 22 | it "returns file version" do 23 | out = rand.to_s 24 | backend.mock_command('[System.Diagnostics.FileVersionInfo]::GetVersionInfo("path").FileVersion', out) 25 | _(cls.new(backend, "path").file_version).must_equal out 26 | end 27 | 28 | it "returns product version" do 29 | out = rand.to_s 30 | backend.mock_command('[System.Diagnostics.FileVersionInfo]::GetVersionInfo("path").FileVersion', out) 31 | _(cls.new(backend, "path").file_version).must_equal out 32 | end 33 | 34 | it "returns owner of file" do 35 | out = rand.to_s 36 | backend.mock_command('Get-Acl "path" | select -expand Owner', out) 37 | _(cls.new(backend, "path").owner).must_equal out 38 | end 39 | 40 | describe "#md5sum" do 41 | let(:md5_checksum) { "4ce0c733cdcf1d2f78532bbd9ce3441d" } 42 | let(:filepath) { 'C:\Windows\explorer.exe' } 43 | 44 | let(:ruby_md5_mock) do 45 | checksum_mock = mock 46 | checksum_mock.expects(:update).returns("") 47 | checksum_mock.expects(:hexdigest).returns(md5_checksum) 48 | checksum_mock 49 | end 50 | 51 | it "defaults to a Ruby based checksum if other methods fail" do 52 | backend.mock_command("CertUtil -hashfile #{filepath} MD5", "", "", 1) 53 | Digest::MD5.expects(:new).returns(ruby_md5_mock) 54 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 55 | end 56 | 57 | it "calculates the correct md5sum on the `windows` platform family" do 58 | output = <<-EOC 59 | MD5 hash of file C:\\Windows\\explorer.exe:\r 60 | 4c e0 c7 33 cd cf 1d 2f 78 53 2b bd 9c e3 44 1d\r 61 | CertUtil: -hashfile command completed successfully.\r 62 | EOC 63 | 64 | backend.mock_command("CertUtil -hashfile #{filepath} MD5", output) 65 | _(cls.new(backend, filepath).md5sum).must_equal md5_checksum 66 | end 67 | end 68 | 69 | describe "#sha256sum" do 70 | let(:sha256_checksum) do 71 | "85270240a5fd51934f0627c92b2282749d071fdc9ac351b81039ced5b10f798b" 72 | end 73 | let(:filepath) { 'C:\Windows\explorer.exe' } 74 | 75 | let(:ruby_sha256_mock) do 76 | checksum_mock = mock 77 | checksum_mock.expects(:update).returns("") 78 | checksum_mock.expects(:hexdigest).returns(sha256_checksum) 79 | checksum_mock 80 | end 81 | 82 | it "defaults to a Ruby based checksum if other methods fail" do 83 | backend.mock_command('CertUtil -hashfile #{filepath} SHA256', "", "", 1) 84 | Digest::SHA256.expects(:new).returns(ruby_sha256_mock) 85 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 86 | end 87 | 88 | it "calculates the correct sha256sum on the `windows` platform family" do 89 | output = <<-EOC 90 | SHA256 hash of file C:\\Windows\\explorer.exe:\r 91 | 85 27 02 40 a5 fd 51 93 4f 06 27 c9 2b 22 82 74 9d 07 1f dc 9a c3 51 b8 10 39 ce d5 b1 0f 79 8b\r 92 | CertUtil: -hashfile command completed successfully.\r 93 | EOC 94 | 95 | backend.mock_command("CertUtil -hashfile #{filepath} SHA256", output) 96 | _(cls.new(backend, filepath).sha256sum).must_equal sha256_checksum 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/unit/file/local_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/local" 3 | 4 | describe Train::File::Local do 5 | # there is zero need to instantiate this OVER and over, so just do it once. 6 | transport = Train::Transports::Local.new 7 | connection = transport.connection 8 | 9 | it "gets file contents" do 10 | res = rand.to_s 11 | File.stub :read, res do 12 | _(connection.file(rand.to_s).content).must_equal(res) 13 | end 14 | end 15 | 16 | { 17 | exist?: :exist?, 18 | file?: :file?, 19 | socket?: :socket?, 20 | directory?: :directory?, 21 | symlink?: :symlink?, 22 | pipe?: :pipe?, 23 | character_device?: :chardev?, 24 | block_device?: :blockdev?, 25 | }.each do |method, file_method| 26 | it "checks if file is a #{method}" do 27 | File.stub file_method.to_sym, true do 28 | _(connection.file(rand.to_s).method(method.to_sym).call).must_equal(true) 29 | end 30 | end 31 | end 32 | 33 | it "has a friendly inspect" do 34 | _(connection.inspect).must_equal "Train::Transports::Local::Connection[unknown]" 35 | end 36 | 37 | describe "#type" do 38 | it "returns the type block_device if it is block device" do 39 | File.stub :ftype, "blockSpecial" do 40 | _(connection.file(rand.to_s).type).must_equal :block_device 41 | end 42 | end 43 | 44 | it "returns the type character_device if it is character device" do 45 | File.stub :ftype, "characterSpecial" do 46 | _(connection.file(rand.to_s).type).must_equal :character_device 47 | end 48 | end 49 | 50 | it "returns the type symlink if it is symlink" do 51 | File.stub :ftype, "link" do 52 | _(connection.file(rand.to_s).type).must_equal :symlink 53 | end 54 | end 55 | 56 | it "returns the type file if it is file" do 57 | File.stub :ftype, "file" do 58 | _(connection.file(rand.to_s).type).must_equal :file 59 | end 60 | end 61 | 62 | it "returns the type directory if it is block directory" do 63 | File.stub :ftype, "directory" do 64 | _(connection.file(rand.to_s).type).must_equal :directory 65 | end 66 | end 67 | 68 | it "returns the type pipe if it is pipe" do 69 | File.stub :ftype, "fifo" do 70 | _(connection.file(rand.to_s).type).must_equal :pipe 71 | end 72 | end 73 | 74 | it "returns the type socket if it is socket" do 75 | File.stub :ftype, "socket" do 76 | _(connection.file(rand.to_s).type).must_equal :socket 77 | end 78 | end 79 | 80 | it "returns the unknown if not known" do 81 | File.stub :ftype, "unknown" do 82 | _(connection.file(rand.to_s).type).must_equal :unknown 83 | end 84 | end 85 | end 86 | 87 | describe "#path" do 88 | it "returns the path if it is not a symlink" do 89 | File.stub :symlink?, false do 90 | filename = rand.to_s 91 | _(connection.file(filename).path).must_equal filename 92 | end 93 | end 94 | 95 | it "returns the link_path if it is a symlink" do 96 | File.stub :symlink?, true do 97 | file_obj = connection.file(rand.to_s) 98 | file_obj.stub :link_path, "/path/to/resolved_link" do 99 | _(file_obj.path).must_equal "/path/to/resolved_link" 100 | end 101 | end 102 | end 103 | end 104 | 105 | describe "#link_path" do 106 | it "returns file's link path" do 107 | out = rand.to_s 108 | File.stub :realpath, out do 109 | File.stub :symlink?, true do 110 | _(connection.file(rand.to_s).link_path).must_equal out 111 | end 112 | end 113 | end 114 | end 115 | 116 | describe "#shallow_shlink_path" do 117 | it "returns file's direct link path" do 118 | out = rand.to_s 119 | File.stub :readlink, out do 120 | File.stub :symlink?, true do 121 | _(connection.file(rand.to_s).shallow_link_path).must_equal out 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/train/options.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Author:: Dominik Richter () 3 | # Author:: Christoph Hartmann () 4 | 5 | module Train 6 | module Options 7 | def self.attach(target) 8 | target.class.method(:include).call(ClassOptions) 9 | target.method(:include).call(InstanceOptions) 10 | end 11 | 12 | module ClassOptions 13 | def option(name, conf = nil, &block) 14 | d = conf || {} 15 | unless d.is_a? Hash 16 | raise Train::ClientError, 17 | "The transport plugin #{self} declared an option #{name} "\ 18 | "and didn't provide a valid configuration hash." 19 | end 20 | 21 | if !conf.nil? && !conf[:default].nil? && block_given? 22 | raise Train::ClientError, 23 | "The transport plugin #{self} declared an option #{name} "\ 24 | "with both a default value and block. Only use one of these." 25 | end 26 | 27 | d[:default] = block if block_given? 28 | 29 | default_options[name] = d 30 | end 31 | 32 | def default_options 33 | @default_options = {} unless defined? @default_options 34 | @default_options 35 | end 36 | 37 | # Created separate method to set the default audit log options so that it will be handled separately 38 | # and will not break any existing functionality 39 | def default_audit_log_options 40 | { 41 | enable_audit_log: { default: false }, 42 | audit_log_location: { required: true, default: nil }, 43 | audit_log_app_name: { default: "train" }, 44 | audit_log_size: { default: nil }, 45 | audit_log_frequency: { default: 0 }, 46 | } 47 | end 48 | 49 | def include_options(other) 50 | unless other.respond_to?(:default_options) 51 | raise "Trying to include options from module #{other.inspect}, "\ 52 | "which doesn't seem to support options." 53 | end 54 | default_options.merge!(other.default_options) 55 | end 56 | end 57 | 58 | module InstanceOptions 59 | # @return [Hash] options, which created this Transport 60 | attr_reader :options 61 | 62 | def default_audit_log_options 63 | self.class.default_audit_log_options 64 | end 65 | 66 | def default_options 67 | self.class.default_options 68 | end 69 | 70 | def merge_options(base, opts) 71 | res = base.merge(opts || {}) 72 | # Also merge the default audit log options into the options so that those are available at the time of validation. 73 | default_options.merge(default_audit_log_options).each do |field, hm| 74 | next unless res[field].nil? && hm.key?(:default) 75 | 76 | default = hm[:default] 77 | if default.is_a? Proc 78 | res[field] = default.call(res) 79 | elsif hm.key?(:coerce) 80 | field_value = hm[:coerce].call(res) 81 | res[field] = field_value.nil? ? default : field_value 82 | else 83 | res[field] = default 84 | end 85 | end 86 | res 87 | end 88 | 89 | def validate_options(opts) 90 | default_options.each do |field, hm| 91 | if opts[field].nil? && hm[:required] 92 | raise Train::ClientError, 93 | "You must provide a value for #{field.to_s.inspect}." 94 | end 95 | end 96 | opts 97 | end 98 | 99 | # Introduced this method to validate only audit log options and avoiding call to validate_options so 100 | # that it will no break existing implementation. 101 | def validate_audit_log_options(opts) 102 | default_audit_log_options.each do |field, hm| 103 | if opts[field].nil? && hm[:required] 104 | raise Train::ClientError, 105 | "You must provide a value for #{field.to_s.inspect}." 106 | end 107 | end 108 | opts 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/train/file/remote/unix.rb: -------------------------------------------------------------------------------- 1 | require "base64" unless defined?(Base64) 2 | require "shellwords" unless defined?(Shellwords) 3 | 4 | module Train 5 | class File 6 | class Remote 7 | class Unix < Train::File::Remote 8 | def sanitize_filename(path) 9 | @spath = Shellwords.escape(path) || @path 10 | end 11 | 12 | def content 13 | @content ||= 14 | if !exist? || directory? 15 | nil 16 | elsif size.nil? || size == 0 17 | "" 18 | else 19 | @backend.run_command("cat #{@spath}").stdout || "" 20 | end 21 | end 22 | 23 | def content=(new_content) 24 | execute_result = @backend.run_command("base64 --help") 25 | if execute_result.exit_status != 0 26 | raise TransportError, "#{self.class} found no base64 binary for file writes" 27 | end 28 | 29 | unix_cmd = format("echo '%s' | base64 --decode > %s", 30 | base64: Base64.strict_encode64(new_content), 31 | file: @spath) 32 | 33 | @backend.run_command(unix_cmd) 34 | 35 | @content = new_content 36 | end 37 | 38 | def exist? 39 | @exist ||= begin 40 | f = @follow_symlink ? "" : " || test -L #{@spath}" 41 | if @backend.platform.solaris? 42 | # Solaris does not support `-e` flag in default `test`, 43 | # so we specify by running /usr/bin/test: 44 | # https://github.com/inspec/train/issues/587 45 | @backend.run_command("/usr/bin/test -e #{@spath}" + f) 46 | else 47 | @backend.run_command("test -e #{@spath}" + f) 48 | end.exit_status == 0 49 | end 50 | end 51 | 52 | def mounted 53 | @mounted ||= 54 | @backend.run_command("mount | grep -- ' on #{@path} '") 55 | end 56 | 57 | %w{ 58 | type mode owner group uid gid mtime size selinux_label 59 | }.each do |field| 60 | define_method field.to_sym do 61 | stat[field.to_sym] 62 | end 63 | end 64 | 65 | def mode?(sth) 66 | mode == sth 67 | end 68 | 69 | def grouped_into?(sth) 70 | group == sth 71 | end 72 | 73 | def linked_to?(dst) 74 | link_path == dst 75 | end 76 | 77 | def link_path 78 | symlink? ? path : nil 79 | end 80 | 81 | def shallow_link_path 82 | return nil unless symlink? 83 | 84 | @shallow_link_path ||= 85 | @backend.run_command("readlink #{@spath}").stdout.chomp 86 | end 87 | 88 | def unix_mode_mask(owner, type) 89 | o = UNIX_MODE_OWNERS[owner.to_sym] 90 | return nil if o.nil? 91 | 92 | t = UNIX_MODE_TYPES[type.to_sym] 93 | return nil if t.nil? 94 | 95 | t & o 96 | end 97 | 98 | def path 99 | return @path unless @follow_symlink && symlink? 100 | 101 | @link_path ||= read_target_path 102 | end 103 | 104 | private 105 | 106 | # Returns full path of a symlink target(real dest) or '' on symlink loop 107 | def read_target_path 108 | full_path = @backend.run_command("readlink -n #{@spath} -f").stdout 109 | # Needed for some OSes like OSX that returns relative path 110 | # when the link and target are in the same directory 111 | if !full_path.start_with?("/") && full_path != "" 112 | full_path = ::File.expand_path("../#{full_path}", @spath) 113 | end 114 | full_path 115 | end 116 | 117 | UNIX_MODE_OWNERS = { 118 | all: 00777, 119 | owner: 00700, 120 | group: 00070, 121 | other: 00007, 122 | }.freeze 123 | 124 | UNIX_MODE_TYPES = { 125 | r: 00444, 126 | w: 00222, 127 | x: 00111, 128 | }.freeze 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/unit/file/remote/unix_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/mock" 3 | require "train/file/remote/unix" 4 | 5 | describe Train::File::Remote::Unix do 6 | let(:cls) { Train::File::Remote::Unix } 7 | 8 | let(:backend) do 9 | backend = Train::Transports::Mock.new.connection 10 | backend.mock_os({ family: "linux" }) 11 | backend 12 | end 13 | 14 | def mockup(stubs) 15 | Class.new(cls) do 16 | stubs.each do |k, v| 17 | define_method k.to_sym do 18 | v 19 | end 20 | end 21 | end.new(nil, nil, false) 22 | end 23 | 24 | describe "unix_mode_mask" do 25 | let(:fc) { mockup(type: :file) } 26 | 27 | it "check owner mode calculation" do 28 | _(fc.unix_mode_mask("owner", "x")).must_equal 0100 29 | _(fc.unix_mode_mask("owner", "w")).must_equal 0200 30 | _(fc.unix_mode_mask("owner", "r")).must_equal 0400 31 | end 32 | 33 | it "check group mode calculation" do 34 | _(fc.unix_mode_mask("group", "x")).must_equal 0010 35 | _(fc.unix_mode_mask("group", "w")).must_equal 0020 36 | _(fc.unix_mode_mask("group", "r")).must_equal 0040 37 | end 38 | 39 | it "check other mode calculation" do 40 | _(fc.unix_mode_mask("other", "x")).must_equal 0001 41 | _(fc.unix_mode_mask("other", "w")).must_equal 0002 42 | _(fc.unix_mode_mask("other", "r")).must_equal 0004 43 | end 44 | 45 | it "check all mode calculation" do 46 | _(fc.unix_mode_mask("all", "x")).must_equal 0111 47 | _(fc.unix_mode_mask("all", "w")).must_equal 0222 48 | _(fc.unix_mode_mask("all", "r")).must_equal 0444 49 | end 50 | end 51 | 52 | describe "#md5sum" do 53 | let(:md5_checksum) { "57d4c6f9d15313fd5651317e588c035d" } 54 | 55 | let(:ruby_md5_mock) do 56 | checksum_mock = mock 57 | checksum_mock.expects(:update).returns("") 58 | checksum_mock.expects(:hexdigest).returns(md5_checksum) 59 | checksum_mock 60 | end 61 | 62 | it "defaults to a Ruby based checksum if other methods fail" do 63 | backend.mock_command("md5 -r /tmp/testfile", "", "", 1) 64 | Digest::MD5.expects(:new).returns(ruby_md5_mock) 65 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 66 | end 67 | 68 | it "calculates the correct md5sum on the `darwin` platform family" do 69 | output = "#{md5_checksum} /tmp/testfile" 70 | backend.mock_os(family: "darwin") 71 | backend.mock_command("md5 -r /tmp/testfile", output) 72 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 73 | end 74 | 75 | it "calculates the correct md5sum on the `solaris` platform family" do 76 | # The `digest` command doesn't output the filename by default 77 | output = "#{md5_checksum}" 78 | backend.mock_os(family: "solaris") 79 | backend.mock_command("digest -a md5 /tmp/testfile", output) 80 | _(cls.new(backend, "/tmp/testfile").md5sum).must_equal md5_checksum 81 | end 82 | end 83 | 84 | describe "#sha256sum" do 85 | let(:sha256_checksum) do 86 | "491260aaa6638d4a64c714a17828c3d82bad6ca600c9149b3b3350e91bcd283d" 87 | end 88 | 89 | let(:ruby_sha256_mock) do 90 | checksum_mock = mock 91 | checksum_mock.expects(:update).returns("") 92 | checksum_mock.expects(:hexdigest).returns(sha256_checksum) 93 | checksum_mock 94 | end 95 | 96 | it "defaults to a Ruby based checksum if other methods fail" do 97 | backend.mock_command("shasum -a 256 /tmp/testfile", "", "", 1) 98 | Digest::SHA256.expects(:new).returns(ruby_sha256_mock) 99 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 100 | end 101 | 102 | it "calculates the correct sha256sum on the `darwin` platform family" do 103 | output = "#{sha256_checksum} /tmp/testfile" 104 | backend.mock_os(family: "darwin") 105 | backend.mock_command("shasum -a 256 /tmp/testfile", output) 106 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 107 | end 108 | 109 | it "calculates the correct sha256sum on the `solaris` platform family" do 110 | # The `digest` command doesn't output the filename by default 111 | output = "#{sha256_checksum}" 112 | backend.mock_os(family: "solaris") 113 | backend.mock_command("digest -a sha256 /tmp/testfile", output) 114 | _(cls.new(backend, "/tmp/testfile").sha256sum).must_equal sha256_checksum 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/integration/docker_run.rb: -------------------------------------------------------------------------------- 1 | require "docker" 2 | require "yaml" unless defined?(YAML) 3 | require "concurrent" 4 | 5 | class DockerRunner 6 | def initialize(conf_path = nil) 7 | @conf_path = conf_path || ENV["config"] 8 | unless File.file?(@conf_path) 9 | raise "Can't find configuration in #{@conf_path}" 10 | end 11 | 12 | @conf = YAML.load_file(@conf_path) 13 | if @conf.nil? || @conf.empty? 14 | raise "Can't read configuration in #{@conf_path}" 15 | end 16 | if @conf["images"].nil? 17 | raise "You must configure test images in your #{@conf_path}" 18 | end 19 | 20 | @images = docker_images_by_tag 21 | @image_pull_tickets = Concurrent::Semaphore.new(2) 22 | @docker_run_tickets = Concurrent::Semaphore.new(5) 23 | end 24 | 25 | def run_all(&block) 26 | raise "You must provide a block for run_all" unless block_given? 27 | 28 | promises = @conf["images"].map do |id| 29 | run_on_target(id, &block) 30 | end 31 | 32 | # wait for all tests to be finished 33 | sleep(0.1) until promises.all?(&:fulfilled?) 34 | 35 | # return resulting values 36 | promises.map(&:value) 37 | end 38 | 39 | def run_on_target(name, &block) 40 | pr = Concurrent::Promise.new do 41 | begin 42 | container = start_container(name) 43 | res = yield(name, container) 44 | # special rescue block to handle not implemented error 45 | rescue NotImplementedError => err 46 | raise err.message 47 | end 48 | # always stop the container 49 | stop_container(container) 50 | res 51 | end.execute 52 | 53 | # failure handling 54 | pr.rescue do |err| 55 | msg = "\033[31;1m#{err.message}\033[0m" 56 | puts msg 57 | msg + "\n" + err.backtrace.join("\n") 58 | end 59 | end 60 | 61 | def provision_image(image, prov, files) 62 | tries ||= 3 63 | return image if prov["script"].nil? 64 | 65 | path = File.join(File.dirname(@conf_path), prov["script"]) 66 | unless File.file?(path) 67 | puts "Can't find script file #{path}" 68 | return image 69 | end 70 | puts " script #{path}" 71 | dst = "/bootstrap#{files.length}.sh" 72 | files.push(dst) 73 | image.insert_local("localPath" => path, "outputPath" => dst) 74 | rescue StandardError => _ 75 | retry unless (tries -= 1) == 0 76 | end 77 | 78 | def bootstrap_image(name, image) 79 | files = [] 80 | provisions = Array(@conf["provision"]) 81 | puts "--> provision docker #{name}" unless provisions.empty? 82 | provisions.each do |prov| 83 | image = provision_image(image, prov, files) 84 | end 85 | [image, files] 86 | end 87 | 88 | def start_container(name, version = nil) 89 | unless name.include?(":") 90 | version ||= "latest" 91 | name = "#{name}:#{version}" 92 | end 93 | puts "--> schedule docker #{name}" 94 | 95 | image = @images[name] 96 | if image.nil? 97 | puts "\033[35;1m--> pull docker images #{name} "\ 98 | "(this may take a while)\033[0m" 99 | 100 | @image_pull_tickets.acquire(1) 101 | puts "... start pull image #{name}" 102 | image = Docker::Image.create("fromImage" => name) 103 | @image_pull_tickets.release(1) 104 | 105 | unless image.nil? 106 | puts "\033[35;1m--> pull docker images finished for #{name}\033[0m" 107 | end 108 | end 109 | 110 | raise "Can't find nor pull docker image #{name}" if image.nil? 111 | 112 | @docker_run_tickets.acquire(1) 113 | 114 | image, scripts = bootstrap_image(name, image) 115 | 116 | puts "--> start docker #{name}" 117 | container = Docker::Container.create( 118 | "Cmd" => %w{sleep 3600}, 119 | "Image" => image.id, 120 | "OpenStdin" => true 121 | ) 122 | container.start 123 | 124 | scripts.each do |script| 125 | container.exec(%w{chmod +x}.push(script)) 126 | container.exec(%w{sh -c}.push(script)) 127 | end 128 | 129 | container 130 | end 131 | 132 | def stop_container(container) 133 | @docker_run_tickets.release(1) 134 | puts "--> killrm docker #{container.id}" 135 | container.kill 136 | container.delete(force: true) 137 | end 138 | 139 | private 140 | 141 | # get all docker image tags 142 | def docker_images_by_tag 143 | images = {} 144 | Docker::Image.all.map do |img| 145 | Array(img.info["RepoTags"]).each do |tag| 146 | images[tag] = img 147 | end 148 | end 149 | images 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/unit/transports/helpers/azure/file_credentials_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "tempfile" unless defined?(Tempfile) 3 | require "train/transports/helpers/azure/file_credentials" 4 | 5 | describe "parse_credentials_file" do 6 | let(:cred_file_single_entry) do 7 | file = Tempfile.new("cred_file") 8 | info = <<-INFO 9 | [my_subscription_id] 10 | client_id = "my_client_id" 11 | client_secret = "my_client_secret" 12 | tenant_id = "my_tenant_id" 13 | INFO 14 | file.write(info) 15 | file.close 16 | file 17 | end 18 | 19 | let(:cred_file_multiple_entries) do 20 | file = Tempfile.new("cred_file") 21 | info = <<-INFO 22 | [my_subscription_id] 23 | client_id = "my_client_id" 24 | client_secret = "my_client_secret" 25 | tenant_id = "my_tenant_id" 26 | 27 | [my_subscription_id2] 28 | client_id = "my_client_id2" 29 | client_secret = "my_client_secret2" 30 | tenant_id = "my_tenant_id2" 31 | INFO 32 | file.write(info) 33 | file.close 34 | file 35 | end 36 | 37 | let(:options) { { credentials_file: cred_file_multiple_entries.path } } 38 | 39 | it "handles a nil file" do 40 | options[:credentials_file] = nil 41 | 42 | result = Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 43 | 44 | assert_empty(result) 45 | end 46 | 47 | it "returns empty hash when no credentials file detected" do 48 | result = Train::Transports::Helpers::Azure::FileCredentials.parse(**{}) 49 | 50 | assert_empty(result) 51 | end 52 | 53 | it "loads only entry from file when no subscription id given" do 54 | options[:credentials_file] = cred_file_single_entry.path 55 | 56 | result = Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 57 | 58 | assert_equal("my_tenant_id", result[:tenant_id]) 59 | assert_equal("my_client_id", result[:client_id]) 60 | assert_equal("my_client_secret", result[:client_secret]) 61 | assert_equal("my_subscription_id", result[:subscription_id]) 62 | end 63 | 64 | it "raises an error when no subscription id given and multiple entries" do 65 | error = assert_raises RuntimeError do 66 | Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 67 | end 68 | 69 | assert_equal("Credentials file must have one entry. Check your credentials file. If you have more than one entry set AZURE_SUBSCRIPTION_ID environment variable.", error.message) 70 | end 71 | 72 | it "loads entry when subscription id is given" do 73 | options[:subscription_id] = "my_subscription_id" 74 | 75 | result = Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 76 | 77 | assert_equal("my_tenant_id", result[:tenant_id]) 78 | assert_equal("my_client_id", result[:client_id]) 79 | assert_equal("my_client_secret", result[:client_secret]) 80 | assert_equal("my_subscription_id", result[:subscription_id]) 81 | end 82 | 83 | it "raises an error when subscription id not found" do 84 | options[:subscription_id] = "missing_subscription_id" 85 | 86 | error = assert_raises RuntimeError do 87 | Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 88 | end 89 | 90 | assert_equal("No credentials found for subscription number missing_subscription_id", error.message) 91 | end 92 | 93 | it "loads entry based on index" do 94 | ENV["AZURE_SUBSCRIPTION_NUMBER"] = "2" 95 | 96 | result = Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 97 | 98 | ENV.delete("AZURE_SUBSCRIPTION_NUMBER") 99 | 100 | assert_equal("my_tenant_id2", result[:tenant_id]) 101 | assert_equal("my_client_id2", result[:client_id]) 102 | assert_equal("my_client_secret2", result[:client_secret]) 103 | assert_equal("my_subscription_id2", result[:subscription_id]) 104 | end 105 | 106 | it "raises an error when index is out of bounds" do 107 | ENV["AZURE_SUBSCRIPTION_NUMBER"] = "3" 108 | 109 | error = assert_raises RuntimeError do 110 | Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 111 | end 112 | ENV.delete("AZURE_SUBSCRIPTION_NUMBER") 113 | 114 | assert_equal("Your credentials file only contains 2 subscriptions. You specified number 3.", error.message) 115 | end 116 | 117 | it "raises an error when index 0 is given" do 118 | ENV["AZURE_SUBSCRIPTION_NUMBER"] = "0" 119 | 120 | error = assert_raises RuntimeError do 121 | Train::Transports::Helpers::Azure::FileCredentials.parse(**options) 122 | end 123 | ENV.delete("AZURE_SUBSCRIPTION_NUMBER") 124 | 125 | assert_equal("Index must be greater than 0.", error.message) 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /test/unit/platforms/detect/os_linux_test.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "train/transports/mock" 3 | 4 | class OsDetectLinuxTester 5 | include Train::Platforms::Detect::Helpers::OSCommon 6 | end 7 | 8 | describe "os_linux" do 9 | let(:detector) { OsDetectLinuxTester.new } 10 | 11 | describe "redhatish_platform cleaner" do 12 | it "normal redhat" do 13 | _(detector.redhatish_platform("Red Hattter")).must_equal("redhat") 14 | end 15 | 16 | it "custom redhat" do 17 | _(detector.redhatish_platform("Centos Pro 11")).must_equal("centos") 18 | end 19 | end 20 | 21 | describe "redhatish_version cleaner" do 22 | it "normal rawhide" do 23 | _(detector.redhatish_version("18 (Rawhide) Pro")).must_equal("18 (rawhide)") 24 | end 25 | 26 | it "normal linux" do 27 | _(detector.redhatish_version("derived from Ubuntu Linux 11")).must_equal("11") 28 | end 29 | 30 | it "amazon linux 2 new release naming schema" do 31 | _(detector.redhatish_version("Amazon Linux release 2 (Karoo)")).must_equal("2") 32 | end 33 | 34 | it "amazon linux 2 old release naming schema" do 35 | _(detector.redhatish_version("Amazon Linux 2")).must_equal("2") 36 | end 37 | end 38 | 39 | describe "lsb parse" do 40 | it "lsb config" do 41 | lsb = "DISTRIB_ID=Ubuntu\nDISTRIB_RELEASE=14.06\nDISTRIB_CODENAME=xenial" 42 | expect = { id: "Ubuntu", release: "14.06", codename: "xenial" } 43 | _(detector.lsb_config(lsb)).must_equal(expect) 44 | end 45 | 46 | it "lsb releasel" do 47 | lsb = "Distributor ID: Ubuntu\nRelease: 14.06\nCodename: xenial" 48 | expect = { id: "Ubuntu", release: "14.06", codename: "xenial" } 49 | _(detector.lsb_release(lsb)).must_equal(expect) 50 | end 51 | end 52 | 53 | describe "#linux_os_release" do 54 | describe "when no os-release data is available" do 55 | it "returns nil" do 56 | detector.expects(:unix_file_contents).with("/etc/os-release").returns(nil) 57 | _(detector.linux_os_release).must_be_nil 58 | end 59 | end 60 | end 61 | 62 | describe "when os-release data exists with no CISCO_RELEASE_INFO" do 63 | let(:os_release) { { "KEY1" => "VALUE1" } } 64 | 65 | it "returns a correct hash" do 66 | detector.expects(:unix_file_contents).with("/etc/os-release").returns("os-release data") 67 | detector.expects(:parse_os_release_info).with("os-release data").returns(os_release) 68 | _(detector.linux_os_release["KEY1"]).must_equal("VALUE1") 69 | end 70 | end 71 | 72 | describe "when os-release data exists with CISCO_RELEASE_INFO" do 73 | let(:os_release) { { "KEY1" => "VALUE1", "CISCO_RELEASE_INFO" => "cisco_file" } } 74 | let(:cisco_release) { { "KEY1" => "NEWVALUE1", "KEY2" => "VALUE2" } } 75 | 76 | it "returns a correct hash" do 77 | detector.expects(:unix_file_contents).with("/etc/os-release").returns("os-release data") 78 | detector.expects(:unix_file_contents).with("cisco_file").returns("cisco data") 79 | detector.expects(:parse_os_release_info).with("os-release data").returns(os_release) 80 | detector.expects(:parse_os_release_info).with("cisco data").returns(cisco_release) 81 | 82 | os_info = detector.linux_os_release 83 | _(os_info["KEY1"]).must_equal("NEWVALUE1") 84 | _(os_info["KEY2"]).must_equal("VALUE2") 85 | end 86 | end 87 | 88 | describe "#parse_os_release_info" do 89 | describe "when nil is supplied" do 90 | it "returns an empty hash" do 91 | _(detector.parse_os_release_info(nil)).must_equal({}) 92 | end 93 | end 94 | 95 | describe "when unexpectedly-formatted data is supplied" do 96 | let(:data) do 97 | <<~EOL 98 | blah blah 99 | no good data here 100 | EOL 101 | end 102 | 103 | it "returns an empty hash" do 104 | _(detector.parse_os_release_info(nil)).must_equal({}) 105 | end 106 | end 107 | 108 | describe "when properly-formatted data is supplied" do 109 | let(:data) do 110 | <<~EOL 111 | KEY1=value1 112 | KEY2= 113 | KEY3=value3 114 | KEY4="value4 with spaces" 115 | KEY5="value5 with a = sign" 116 | EOL 117 | end 118 | 119 | it "parses the data correctly" do 120 | parsed_data = detector.parse_os_release_info(data) 121 | 122 | _(parsed_data["KEY1"]).must_equal("value1") 123 | _(parsed_data.key?("KEY2")).must_equal(false) 124 | _(parsed_data["KEY3"]).must_equal("value3") 125 | _(parsed_data["KEY4"]).must_equal("value4 with spaces") 126 | _(parsed_data["KEY5"]).must_equal("value5 with a = sign") 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/train/transports/cisco_ios_connection.rb: -------------------------------------------------------------------------------- 1 | class Train::Transports::SSH 2 | class CiscoIOSConnection < BaseConnection 3 | class BadEnablePassword < Train::TransportError; end 4 | 5 | def initialize(options) 6 | super(options) 7 | 8 | # Extract options to avoid passing them in to `Net::SSH.start` later 9 | @host = options.delete(:host) 10 | @user = options.delete(:user) 11 | @port = options.delete(:port) 12 | @enable_password = options.delete(:enable_password) 13 | 14 | # Use all options left that are not `nil` for `Net::SSH.start` later 15 | @ssh_options = options.reject { |_key, value| value.nil? } 16 | 17 | # Allow older algorithms 18 | @ssh_options[:append_all_supported_algorithms] = true 19 | 20 | @prompt = /^\S+[>#]\r\n.*$/ 21 | end 22 | 23 | def uri 24 | "ssh://#{@user}@#{@host}:#{@port}" 25 | end 26 | 27 | def unique_identifier 28 | result = run_command_via_connection("show version | include Processor") 29 | result.stdout.split(" ")[-1] 30 | end 31 | 32 | def upload(locals, remote) 33 | raise NotImplementedError, "#{self.class} does not implement #upload()" 34 | end 35 | 36 | def download(remotes, local) 37 | raise NotImplementedError, "#{self.class} does not implement #download()" 38 | end 39 | 40 | private 41 | 42 | def establish_connection 43 | logger.debug("[SSH] opening connection to #{self}") 44 | 45 | Net::SSH.start(@host, @user, @ssh_options) 46 | end 47 | 48 | def session 49 | return @session unless @session.nil? 50 | 51 | @session = open_channel(establish_connection) 52 | 53 | # Escalate privilege to enable mode if password is given 54 | if @enable_password 55 | # This verifies we are not in privileged exec mode before running the 56 | # enable command. Otherwise, the password will be in history. 57 | if run_command_via_connection("show privilege").stdout.split[-1] != "15" 58 | # Extra newlines to get back to prompt if incorrect password is used 59 | run_command_via_connection("enable\n#{@enable_password}\n\n\n") 60 | end 61 | end 62 | 63 | # Prevent `--MORE--` by removing terminal length limit 64 | run_command_via_connection("terminal length 0") 65 | 66 | @session 67 | end 68 | 69 | def run_command_via_connection(cmd, &_data_handler) 70 | # Ensure buffer is empty before sending data 71 | @buf = "" 72 | 73 | logger.debug("[SSH] Running `#{cmd}` on #{self}") 74 | session.send_data(cmd + "\r\n") 75 | 76 | logger.debug("[SSH] waiting for prompt") 77 | until @buf =~ @prompt 78 | if @buf =~ /Bad (secrets|password)|Access denied/ 79 | raise BadEnablePassword 80 | end 81 | 82 | session.connection.process(0) 83 | end 84 | 85 | # Save the buffer and clear it for the next command 86 | output = @buf.dup 87 | @buf = "" 88 | 89 | format_result(format_output(output, cmd)) 90 | end 91 | 92 | ERROR_MATCHERS = [ 93 | "Bad IP address", 94 | "Incomplete command", 95 | "Invalid input detected", 96 | "Unrecognized host", 97 | ].freeze 98 | 99 | # IOS commands do not have an exit code so we must compare the command 100 | # output with partial segments of known errors. Then, we return a 101 | # `CommandResult` with arguments in the correct position based on the 102 | # result. 103 | def format_result(result) 104 | if ERROR_MATCHERS.none? { |e| result.include?(e) } 105 | CommandResult.new(result, "", 0) 106 | else 107 | CommandResult.new("", result, 1) 108 | end 109 | end 110 | 111 | # The buffer (@buf) contains all data sent/received on the SSH channel so 112 | # we need to format the data to match what we would expect from Train 113 | def format_output(output, cmd) 114 | leading_prompt = /(\r\n|^)\S+[>#]/ 115 | command_string = /#{Regexp.quote(cmd)}\r\n/ 116 | trailing_prompt = /\S+[>#](\r\n|$)/ 117 | trailing_line_endings = /(\r\n)+$/ 118 | 119 | output 120 | .sub(leading_prompt, "") 121 | .sub(command_string, "") 122 | .gsub(trailing_prompt, "") 123 | .gsub(trailing_line_endings, "") 124 | end 125 | 126 | # Create an SSH channel that writes to @buf when data is received 127 | def open_channel(ssh) 128 | logger.debug("[SSH] opening SSH channel to #{self}") 129 | ssh.open_channel do |ch| 130 | ch.on_data do |_, data| 131 | @buf += data 132 | end 133 | 134 | ch.send_channel_request("shell") do |_, success| 135 | raise "Failed to open SSH shell" unless success 136 | 137 | logger.debug("[SSH] shell opened") 138 | end 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/train/transports/gcp.rb: -------------------------------------------------------------------------------- 1 | require "train/plugins" 2 | require "google/apis" 3 | require "google-apis-cloudresourcemanager_v1" 4 | require "google-apis-compute_v1" 5 | require "google-apis-storage_v1" 6 | require "google-apis-iam_v1" 7 | require "google-apis-admin_directory_v1" 8 | require "googleauth" 9 | 10 | module Train::Transports 11 | class Gcp < Train.plugin(1) 12 | name "gcp" 13 | 14 | # GCP will look automatically for the below env var for service accounts etc. : 15 | option :google_application_credentials, required: false do 16 | ENV["GOOGLE_APPLICATION_CREDENTIALS"] 17 | end 18 | # see https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application 19 | # In the absence of this, the client is expected to have already set up local credentials via: 20 | # $ gcloud auth application-default login 21 | # $ gcloud config set project 22 | # GCP projects can have default regions / zones set, see: 23 | # https://cloud.google.com/compute/docs/regions-zones/changing-default-zone-region 24 | # can also specify project via env var: 25 | option :google_cloud_project, required: false do 26 | ENV["GOOGLE_CLOUD_PROJECT"] 27 | end 28 | option :google_super_admin_email, required: false do 29 | ENV["GOOGLE_SUPER_ADMIN_EMAIL"] 30 | end 31 | 32 | def connection(_ = nil) 33 | @connection ||= Connection.new(@options) 34 | end 35 | 36 | class Connection < BaseConnection 37 | def initialize(options) 38 | super(options) 39 | 40 | # additional GCP platform metadata 41 | # The google-apis-core dependency is the common for all gcp service related gems. 42 | release = Gem.loaded_specs["google-apis-core"].version 43 | @platform_details = { release: "google-apis-core-v#{release}" } 44 | 45 | # Initialize the client object cache 46 | @cache_enabled[:api_call] = true 47 | @cache[:api_call] = {} 48 | 49 | connect 50 | end 51 | 52 | def platform 53 | force_platform!("gcp", @platform_details) 54 | end 55 | 56 | # Instantiate some named classes for ease of use 57 | def gcp_compute_client 58 | gcp_client(Google::Apis::ComputeV1::ComputeService) 59 | end 60 | 61 | def gcp_iam_client 62 | gcp_client(Google::Apis::IamV1::IamService) 63 | end 64 | 65 | def gcp_project_client 66 | gcp_client(Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService) 67 | end 68 | 69 | def gcp_storage_client 70 | gcp_client(Google::Apis::StorageV1::StorageService) 71 | end 72 | 73 | def gcp_admin_client 74 | scopes = ["https://www.googleapis.com/auth/admin.directory.user.readonly"] 75 | authorization = Google::Auth.get_application_default(scopes).dup 76 | # Use of the Admin API requires delegation (impersonation). An email address of a Super Admin in 77 | # the G Suite account may be required. 78 | authorization.sub = @options[:google_super_admin_email] if @options[:google_super_admin_email] 79 | Google::Apis::RequestOptions.default.authorization = authorization 80 | gcp_client(Google::Apis::AdminDirectoryV1::DirectoryService) 81 | end 82 | 83 | # Let's allow for other clients too 84 | def gcp_client(klass) 85 | return klass.new unless cache_enabled?(:api_call) 86 | 87 | @cache[:api_call][klass.to_s.to_sym] ||= klass.new 88 | end 89 | 90 | def connect 91 | ENV["GOOGLE_APPLICATION_CREDENTIALS"] = @options[:google_application_credentials] if @options[:google_application_credentials] 92 | ENV["GOOGLE_CLOUD_PROJECT"] = @options[:google_cloud_project] if @options[:google_cloud_project] 93 | # GCP initialization 94 | scopes = ["https://www.googleapis.com/auth/cloud-platform", 95 | "https://www.googleapis.com/auth/compute"] 96 | authorization = Google::Auth.get_application_default(scopes) 97 | Google::Apis::ClientOptions.default.application_name = "chef-inspec-train" 98 | Google::Apis::ClientOptions.default.application_version = Train::VERSION 99 | Google::Apis::RequestOptions.default.authorization = authorization 100 | end 101 | 102 | def uri 103 | "gcp://#{unique_identifier}" 104 | end 105 | 106 | def unique_identifier 107 | unique_id = "default" 108 | # use auth client_id for users (issuer is nil) 109 | authorization = gcp_iam_client.request_options.authorization 110 | unique_id = authorization.client_id if authorization.respond_to?(:client_id) && !authorization.client_id.nil? 111 | # for service account credentials (client_id is nil) 112 | unique_id = authorization.issuer if authorization.respond_to?(:issuer) && !authorization.issuer.nil? 113 | unique_id 114 | end 115 | end 116 | end 117 | end 118 | --------------------------------------------------------------------------------