From f2d64938b7200535036d7c80e0692010f04d8686 Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Wed, 25 Jul 2018 13:00:06 -0700 Subject: [PATCH] windows_feature resource: Add DISM support (#3224) * windows_feature resource: Add DISM support This modifies the `windows_feature` resource to fallback to DISM when the `Get-WindowsFeature` command is not available. * Allow specifying `:dism` or `:powershell` * Replace stacktrace with smaller error message * Add notes/todo about raise behavior * Remove duplicated platform check Signed-off-by: Jerry Aldrich --- docs/resources/windows_feature.md.erb | 16 +- lib/resources/windows_feature.rb | 148 +++++++++++------- test/helper.rb | 4 +- test/unit/mock/cmd/dism-iis-webserver | 20 +++ .../cmd/get-windows-feature-iis-webserver | 7 + test/unit/resources/windows_feature_test.rb | 66 +++++++- 6 files changed, 197 insertions(+), 64 deletions(-) create mode 100644 test/unit/mock/cmd/dism-iis-webserver create mode 100644 test/unit/mock/cmd/get-windows-feature-iis-webserver diff --git a/docs/resources/windows_feature.md.erb b/docs/resources/windows_feature.md.erb index e1e364df4..e2472fede 100644 --- a/docs/resources/windows_feature.md.erb +++ b/docs/resources/windows_feature.md.erb @@ -28,9 +28,21 @@ where The following examples show how to use this InSpec audit resource. -### Test the DHCP Server feature +### Test the DHCP feature (Attempts PowerShell then DISM) - describe windows_feature('DHCP Server') do + describe windows_feature('DHCP') do + it{ should be_installed } + end + +### Test the IIS-WebServer feature using DISM + + describe windows_feature('IIS-WebServer', DISM) do + it{ should be_installed } + end + +### Test the NetFx3 feature using DISM + + describe windows_feature('NetFx3', :dism) do it{ should be_installed } end diff --git a/lib/resources/windows_feature.rb b/lib/resources/windows_feature.rb index ab07e38f9..70a538237 100644 --- a/lib/resources/windows_feature.rb +++ b/lib/resources/windows_feature.rb @@ -1,84 +1,126 @@ # encoding: utf-8 -# check for a Windows feature -# Usage: -# describe windows_feature('DHCP Server') do -# it{ should be_installed } -# end -# -# deprecated serverspec syntax: -# describe windows_feature('IIS-Webserver') do -# it{ should be_installed.by("dism") } -# end -# -# describe windows_feature('Web-Webserver') do -# it{ should be_installed.by("powershell") } -# end -# -# This implementation uses the Get-WindowsFeature commandlet: -# Get-WindowsFeature | Where-Object {$_.Name -eq 'XPS Viewer' -or $_.DisplayName -eq 'XPS Viewe -# r'} | Select-Object -Property Name,DisplayName,Description,Installed,InstallState | ConvertTo-Json -# { -# "Name": "XPS-Viewer", -# "DisplayName": "XPS Viewer", -# "Description": "The XPS Viewer is used to read, set permissions for, and digitally sign XPS documents.", -# "Installed": false, -# "InstallState": 0 -# } module Inspec::Resources class WindowsFeature < Inspec.resource(1) name 'windows_feature' supports platform: 'windows' desc 'Use the windows_feature InSpec audit resource to test features on Microsoft Windows.' - example " - describe windows_feature('dhcp') do + example <<-EOX + # By default this resource will use Get-WindowsFeature. + # Failing that, it will use DISM. + + # Get-WindowsFeature Example + describe windows_feature('Web-WebServer', :powershell) do it { should be_installed } end - " - def initialize(feature) + # DISM Example + describe windows_feature('IIS-WebServer', :dism) do + it { should be_installed } + end + + # Try PowerShell then DISM Example + describe windows_feature('IIS-WebServer') do + it { should be_installed } + end + EOX + + def initialize(feature, method = nil) @feature = feature + @method = method @cache = nil - - # verify that this resource is only supported on Windows - return skip_resource 'The `windows_feature` resource is not supported on your OS.' if !inspec.os.windows? end # returns true if the package is installed - def installed?(_provider = nil, _version = nil) + def installed? info[:installed] == true end # returns the package description def info return @cache if !@cache.nil? - features_cmd = "Get-WindowsFeature | Where-Object {$_.Name -eq '#{@feature}' -or $_.DisplayName -eq '#{@feature}'} | Select-Object -Property Name,DisplayName,Description,Installed,InstallState | ConvertTo-Json" - cmd = inspec.command(features_cmd) - @cache = { - name: @feature, - type: 'windows-feature', - } - - # cannot rely on exit code for now, successful command returns exit code 1 - # return nil if cmd.exit_status != 0 - # try to parse json - begin - params = JSON.parse(cmd.stdout) - rescue JSON::ParserError => _e - return @cache + case @method + when :powershell + @cache = info_via_powershell(@feature) + if @cache[:error] + # TODO: Allow handling `Inspec::Exception` outside of initialize + # See: https://github.com/inspec/inspec/issues/3237 + # The below will fail the resource regardless of what is raised + raise Inspec::Exceptions::ResourceFailed, @cache[:error] + end + when :dism + @cache = info_via_dism(@feature) + else + @cache = info_via_powershell(@feature) + @cache = info_via_dism(@feature) if @cache[:error] end - @cache = { - name: params['Name'], - description: params['Description'], - installed: params['Installed'], - type: 'windows-feature', - } + @cache end def to_s "Windows Feature '#{@feature}'" end + + private + + def info_via_dism(feature) + dism_command = "dism /online /get-featureinfo /featurename:#{feature}" + cmd = inspec.command(dism_command) + + if cmd.exit_status != 0 + feature_info = { + name: feature, + description: 'N/A', + installed: false, + } + else + result = cmd.stdout + feature_name_regex = /Feature Name : (.*)(\r\n|\n)/ + description_regex = /Description : (.*)(\r\n|\n)/ + feature_info = { + name: result.match(feature_name_regex).captures[0].chomp, + description: result.match(description_regex).captures[0].chomp, + installed: true, + } + end + + feature_info[:method] = :dism + feature_info + end + + def info_via_powershell(feature) + features_cmd = "Get-WindowsFeature | Where-Object {$_.Name -eq '#{feature}' -or $_.DisplayName -eq '#{feature}'} | Select-Object -Property Name,DisplayName,Description,Installed,InstallState | ConvertTo-Json" + cmd = inspec.command(features_cmd) + + feature_info = {} + + # The `Get-WindowsFeature` command is not available on the Windows + # non-server OS. This attempts to use the `dism` command to get the info. + if cmd.stderr =~ /The term 'Get-WindowsFeature' is not recognized/ + feature_info[:name] = feature + feature_info[:error] = 'Could not find `Get-WindowsFeature`' + else + # We cannot rely on `cmd.exit_status != 0` because by default the + # command will exit 1 even on success. So, if we cannot parse the JSON + # we know that the feature is not installed. + begin + result = JSON.parse(cmd.stdout) + + feature_info = { + name: result['Name'], + description: result['Description'], + installed: result['Installed'], + } + rescue JSON::ParserError => _e + feature_info[:name] = feature + feature_info[:installed] = false + end + end + + feature_info[:method] = :powershell + feature_info + end end end diff --git a/test/helper.rb b/test/helper.rb index 6bcf7dd07..840c21f5c 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -272,7 +272,9 @@ class MockLoader '(choco list --local-only --exact --include-programs --limit-output \'git\') -Replace "\|", "=" | ConvertFrom-StringData | ConvertTo-JSON' => empty.call, "New-Object -Type PSObject | Add-Member -MemberType NoteProperty -Name Service -Value (Get-Service -Name 'dhcp'| Select-Object -Property Name, DisplayName, Status) -PassThru | Add-Member -MemberType NoteProperty -Name WMI -Value (Get-WmiObject -Class Win32_Service | Where-Object {$_.Name -eq 'dhcp' -or $_.DisplayName -eq 'dhcp'} | Select-Object -Property StartMode) -PassThru | ConvertTo-Json" => cmd.call('get-service-dhcp'), "New-Object -Type PSObject | Add-Member -MemberType NoteProperty -Name Pip -Value (Invoke-Command -ScriptBlock {where.exe pip}) -PassThru | Add-Member -MemberType NoteProperty -Name Python -Value (Invoke-Command -ScriptBlock {where.exe python}) -PassThru | ConvertTo-Json" => cmd.call('get-windows-pip-package'), - "Get-WindowsFeature | Where-Object {$_.Name -eq 'dhcp' -or $_.DisplayName -eq 'dhcp'} | Select-Object -Property Name,DisplayName,Description,Installed,InstallState | ConvertTo-Json" => cmd.call('get-windows-feature'), + "Get-WindowsFeature | Where-Object {$_.Name -eq 'DHCP' -or $_.DisplayName -eq 'DHCP'} | Select-Object -Property Name,DisplayName,Description,Installed,InstallState | ConvertTo-Json" => cmd.call('get-windows-feature'), + "Get-WindowsFeature | Where-Object {$_.Name -eq 'IIS-WebServer' -or $_.DisplayName -eq 'IIS-WebServer'} | Select-Object -Property Name,DisplayName,Description,Installed,InstallState | ConvertTo-Json" => cmd_exit_1.call('get-windows-feature-iis-webserver'), + "dism /online /get-featureinfo /featurename:IIS-WebServer" => cmd.call('dism-iis-webserver'), 'lsmod' => cmd.call('lsmod'), '/sbin/sysctl -q -n net.ipv4.conf.all.forwarding' => cmd.call('sbin_sysctl'), # ports on windows diff --git a/test/unit/mock/cmd/dism-iis-webserver b/test/unit/mock/cmd/dism-iis-webserver new file mode 100644 index 000000000..840b8d357 --- /dev/null +++ b/test/unit/mock/cmd/dism-iis-webserver @@ -0,0 +1,20 @@ + +Deployment Image Servicing and Management tool +Version: 10.0.16299.15 + +Image Version: 10.0.16299.15 + +Feature Information: + +Feature Name : IIS-WebServer +Display Name : World Wide Web Services +Description : Installs the IIS 10.0 World Wide Web Services. Provides support for HTML web sites and optional support for ASP.NET, Classic ASP, and web server extensions. +Restart Required : Possible +State : Disabled + +Custom Properties: + +(No custom properties found) + +The operation completed successfully. + diff --git a/test/unit/mock/cmd/get-windows-feature-iis-webserver b/test/unit/mock/cmd/get-windows-feature-iis-webserver new file mode 100644 index 000000000..e5faaa1a0 --- /dev/null +++ b/test/unit/mock/cmd/get-windows-feature-iis-webserver @@ -0,0 +1,7 @@ +The term 'Get-WindowsFeature' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again. +At line:1 char:1 ++ Get-WindowsFeature | Where-Object {$_.Name -eq 'IIS-Webserver' -or $_ ... ++ ~~~~~~~~~~~~~~~~~~ ++ CategoryInfo : ObjectNotFound: (Get-WindowsFeature:String) [], CommandNotFoundException ++ FullyQualifiedErrorId : CommandNotFoundException + diff --git a/test/unit/resources/windows_feature_test.rb b/test/unit/resources/windows_feature_test.rb index eeca0dfc8..4ec2513f4 100644 --- a/test/unit/resources/windows_feature_test.rb +++ b/test/unit/resources/windows_feature_test.rb @@ -5,13 +5,63 @@ require 'helper' require 'inspec/resource' -describe 'Inspec::Resources::Feature' do - describe 'feature' do - it 'verify windows feature parsing' do - resource = MockLoader.new(:windows).load_resource('windows_feature', 'dhcp') - pkg = { name: 'DHCP', description: 'Dynamic Host Configuration Protocol (DHCP) Server enables you to centrally configure, manage, and provide temporary IP addresses and related information for client computers.', installed: false, type: 'windows-feature' } - _(resource.info).must_equal pkg - _(resource.installed?).must_equal false - end +describe 'Inspec::Resources::WindowsFeature' do + it 'can retrieve feature info using PowerShell' do + resource = MockLoader.new(:windows).load_resource( + 'windows_feature', + 'DHCP', + :powershell, + ) + params = { + name: 'DHCP', + description: 'Dynamic Host Configuration Protocol (DHCP) Server enables you to centrally configure, manage, and provide temporary IP addresses and related information for client computers.', + installed: false, + method: :powershell, + } + _(resource.info).must_equal params + _(resource.installed?).must_equal false + end + + it 'can retrieve feature info using DISM' do + resource = MockLoader.new(:windows).load_resource( + 'windows_feature', + 'IIS-WebServer', + :dism, + ) + params = { + name: 'IIS-WebServer', + description: 'Installs the IIS 10.0 World Wide Web Services. Provides support for HTML web sites and optional support for ASP.NET, Classic ASP, and web server extensions.', + installed: true, + method: :dism, + } + _(resource.info).must_equal params + _(resource.installed?).must_equal true + end + + it 'uses DISM when Get-WindowsFeature does not exist' do + resource = MockLoader.new(:windows) + .load_resource('windows_feature', 'IIS-WebServer') + params = { + name: 'IIS-WebServer', + description: 'Installs the IIS 10.0 World Wide Web Services. Provides support for HTML web sites and optional support for ASP.NET, Classic ASP, and web server extensions.', + installed: true, + method: :dism, + } + _(resource.info).must_equal params + _(resource.installed?).must_equal true + end + + it 'fails the resource if PowerShell method is used but command not found' do + resource = MockLoader.new(:windows).load_resource( + 'windows_feature', + 'IIS-WebServer', + :powershell, + ) + + e = proc { + resource.info + }.must_raise(Inspec::Exceptions::ResourceFailed) + + e.message.must_match(/Could not find `Get-WindowsFeature`/) end end