CFINSPEC-246/CFINSPEC-247 Attestation changes for N/R outcomes (#6222)

* Added attestation file option to read attestation in various formats

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Added method to add attestation data on control level

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Enhanced outcomes flag to be true when attestation file is passed

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Added logic for attestation for reporters

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Attestation integration with streaming reporters and lots of refactoring

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Support for mitre - with frequency, updated and explanation fields'

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* To only revise enhanced outcomes when attestation data is present for the control - fix in streaming reporter

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Added test cases for attestation and also added validation warnings

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Attestation test for different formats of attestation file

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Validating presence of status column to be mandtory for attestation files

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Build fix

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Attestation build fix for windows

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* No justification and no status graceful handling

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* New class attestations added for logic and added missing test attestations file

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Code comments and cli doc changes for attestation option

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Moved logic of attestations and enhanced outcomes to the base of streaming reporter

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Attestation documentation added

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Added information on what happens if justification is missing in attestation file

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Attestation doc changes as per the review

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* File fields doc changes in attestation doc

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Content Review

Signed-off-by: Deepa Kumaraswamy <dkumaras@progress.com>

* Edits

Signed-off-by: Deepa Kumaraswamy <dkumaras@progress.com>

* Attestation test changes matching regex and separated logic for expiration using frequency and updated date

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Proof-read

Signed-off-by: Deepa Kumaraswamy <dkumaras@progress.com>

* Name changes for expiry calculation method

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

* Generic tests in attestations for cross platform

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>

Signed-off-by: Nikita Mathur <nikita.mathur@chef.io>
Signed-off-by: Deepa Kumaraswamy <dkumaras@progress.com>
Co-authored-by: Deepa Kumaraswamy <dkumaras@progress.com>
This commit is contained in:
Nikita Mathur 2022-09-30 19:23:32 +05:30 committed by GitHub
parent b745e55499
commit efc6f2c63a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1017 additions and 45 deletions

View file

@ -0,0 +1,179 @@
+++
title = "Attestations"
draft = false
gh_repo = "inspec"
[menu]
[menu.inspec]
title = "Attestations"
identifier = "inspec/reference/attestations.md Attestations"
parent = "inspec/reference"
weight = 140
+++
Attestations is a mechanism to mark the `Not Reviewed (N/R)` tests as `passed` or `failed` manually using an attestations file.
## Example
A fire alarm needs to be audited, but it cannot be reviewed (N/R) through automation. Hence, to audit the fire alarm using an InSpec profile, the outcome of its working must be marked as `passed` or `failed` in a test through manual intervention. By using attestations and passing the status using an attestations file, we can audit the fire alarm.
### Attestations File to an audit fire alarm
```yaml
fire-alarm-1:
expiration_date: 2090-10-1
status: passed
justification: "Fire alarm 1 was tested manually and it works."
fire-alarm-2:
expiration_date: 2090-10-1
status: failed
justification: "Fire alarm 2 was tested manually and it does not work."
```
### InSpec Test
```ruby
control "fire-alarm-1" do
only_if("Fire alarm 1 needs to be tested manually") {
false
}
end
control "fire-alarm-2" do
only_if("Fire alarm 2 needs to be tested manually") {
false
}
end
```
### Running attestations to an audit fire alarm
```bash
inspec exec path/to/fire-alarm-audit-profile --attestation-file attestation.yaml
Profile: InSpec Profile (attestation)
Version: 0.1.0
Target: local://
Target ID: fa3923b9-f806-4cc2-960d-1ddefb4c7654
✔ fire-alarm-1: No-op (1 skipped)
↺ Skipped control due to only_if condition: Fire alarm 1 needs to be tested manually
✔ Control Attested : Fire alarm 1 was tested manually and it works.
× fire-alarm-2: No-op (1 failed) (1 skipped)
↺ Skipped control due to only_if condition: Fire alarm 2 needs to be tested manually
× Control Attested : Fire alarm 2 was tested manually and it does not work.
Profile Summary: 1 successful control, 1 control failure, 0 controls not reviewed, 0 controls not applicable, 0 controls have error
Test Summary: 1 successful, 1 failure, 2 skipped
```
## Attestations Fields
An attestations file identifies:
1. the controls need to be attested.
1. an explanation of why it is manually attested.
1. control status `passed` or `failed` to attest controls.
1. (optional) an URL pointing to a website containing information on control attestation.
1. (optional) an expiration date of attestation.
## Usage
To use attestations, you must have a correctly formatted attestations file and
invoke `inspec exec` with `--attestation-file [path]`.
```bash
inspec exec path/to/profile --attestation-file attestation.yaml
```
## File Format
Attestations files support YAML, JSON, CSV, XLSX, and XLS formats.
```yaml
control_id:
expiration_date: YYYY-MM-DD
status: passed
justification: "reason for attesting this control"
evidence_url: "URL pointing to a website containing information on control attestation"
```
OR
```json
{
"control_id": {
"expiration_date": "YYYY-MM-DD",
"status": "passed",
"justification": "reason for attesting this control",
"evidence_url": "URL pointing to a website containing information on control attestation"
}
}
```
- `status` is mandatory. If absent, the control will not be attested. It can only be `passed` or `failed`.
- `expiration_date` sets the day the attestations file expires in **YYYY-MM-DD** format. Attestations files expire at 00:00 at the local time of the system on the specified date. Attestations files without expiration date are permanent. `expiration_date` is optional.
- `justification` is a text containing the reason why attestations is required. It might as well as include information on who initiated the attestation. If it is absent, it shows a warning message to include justification in the attestations file.
- `evidence_url` is an URL of a website containing information on control attestation. It is optional.
### File Format Examples
#### Example in YAML
```yaml
example-3.0.1:
justification: "Passed by the auditor manually"
evidence_url: "https://www.attestation-info-chef-example/"
expiration_date: 2050-06-01
status: passed
example-3.0.2:
justification: "Failed by the auditor manually"
evidence_url: "https://www.attestation-info-chef-example/"
expiration_date: 2050-07-01
status: failed
```
#### Example in JSON
```json
{
"example-3.0.1": {
"justification": "Passed by the auditor manually",
"evidence_url": "https://www.attestation-info-chef-example/",
"expiration_date": "2050-06-01",
"status": "passed"
},
"example-3.0.2": {
"justification": "Failed by the auditor manually",
"evidence_url": "https://www.attestation-info-chef-example/",
"expiration_date": "2050-07-01",
"status": "failed"
}
}
```
#### Example in CSV/XLSX/XLS
These file formats support the following fields in a file:
- `control_id`
_Required_.
- `justification`
_Required_.
- `status`
_Required_.
- `evidence_url`
_Optional_.
- `expiration_date`
_Optional_.
![Attestations File Excel Example](/images/inspec/attestations_file_excel.png)
{{< note >}}
How is the Attestations mechanism different than Waivers?
The waivers mechanism skips the controls for various reasons which are required for waiving. Whereas attestations mark the skipped controls which are not reviewed as `passed` or `failed` using the status passed through the attestations file by the auditor.
{{< /note >}}

View file

@ -405,6 +405,8 @@ This subcommand has the following additional options:
Specify which transport to use, defaults to negotiate (WinRM).
* `--enhanced-outcomes`
Includes enhanced outcome of controls in report data.
* `--attestation-file=one two three`
Loads one or more attestation files. Using this option, makes the `enhanced-outcomes` option true.
## habitat

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,50 @@
require "inspec/secrets/yaml"
require "inspec/utils/waivers/csv_file_reader"
require "inspec/utils/waivers/json_file_reader"
require "inspec/utils/waivers/excel_file_reader"
module Inspec
class AttestationFileReader < WaiverFileReader
# Invoked from rule.rb and streaming reporter base class to fetch attestation data
def self.fetch_attestation_by_profile(profile_id, files)
read_attestation_from_file(profile_id, files) if @attestation_data.nil? || @attestation_data[profile_id].nil?
@attestation_data[profile_id]
end
def self.read_attestation_from_file(profile_id, files)
@attestation_data ||= {}
output = {}
files.each do |file_path|
data = read_from_file(file_path)
output.merge!(data) if !data.nil? && data.is_a?(Hash)
if data.nil?
raise Inspec::Exceptions::AttestationFileNotReadable,
"Cannot find parser for attestation file '#{file_path}'. " \
"Check to make sure file has the appropriate extension."
end
end
@attestation_data[profile_id] = output
end
# Attestation file has different headers than waiver file
# Overriding header validation logic of WaiverFileReader
def self.validate_headers(headers, json_yaml = false)
required_fields = json_yaml ? %w{status} : %w{control_id status}
missing_cols = (required_fields - headers)
missing_cols << "justification" if (!headers.include? "justification") && (!headers.include? "explanation")
Inspec::Log.warn "Missing column headers: #{missing_cols}" unless missing_cols.empty?
Inspec::Log.warn "Invalid column header: Column can't be nil" if headers.include? nil
Inspec::Log.warn "Extra column headers: #{(headers - all_fields)}" unless (headers - all_fields).empty?
end
# defining all fields used in attestation files of different formats
def self.all_fields
%w{control_id justification expiration_date evidence_url status explanation frequency updated}
end
end
end

155
lib/inspec/attestations.rb Normal file
View file

@ -0,0 +1,155 @@
module Inspec
module Attestations
# Invoked from reporters base classes & run_data.rb to modify run data
def self.attest(run_data)
run_data[:profiles].each do |profile|
profile[:controls].each do |control|
# logic for attestation applied for N/R controls here.
if control[:status] == "not_reviewed" && !control[:attestation_data].empty?
expiry = determine_expiry(control[:attestation_data], control[:id])
# if expiration date parsing was successful
if expiry
control[:attestation_data]["message"] = validate_attestation_expiry(expiry, control[:id])
attestation_result = attestation_check(control[:attestation_data]["message"], control[:attestation_data], control[:id])
if attestation_result
status, attestation_msg = attestation_result
control[:status] = status # N/R status -> to passed/failed based on attestation logic
# replicated test result hash to invoke pass/fail test
control[:results].push({
status: control[:status],
code_desc: attestation_msg,
expectation_message: attestation_msg,
})
end
end
end
end
end
end
# Invoked from streaming reporter base class
def self.attest_streaming_data(attestation_data, status, control_id)
# logic check for N/R controls here for streaming reporters
if status == "not_reviewed" && !attestation_data.blank?
expiry = determine_expiry(attestation_data, control_id)
# if expiration date parsing was successful
if expiry
expiry_message = validate_attestation_expiry(expiry, control_id)
attestation_check(expiry_message, attestation_data, control_id)
end
end
end
def self.validate_attestation_expiry(expiry, control_id)
# logic to check for expiry
if [Date, Time].include?(expiry.class) || (expiry.is_a?(String) && Time.parse(expiry).year != 0)
expiry = expiry.to_time if expiry.is_a? Date
expiry = Time.parse(expiry) if expiry.is_a? String
if expiry < Time.now # If the attestation expired, return - no attestation done
expiry_message = "Attestation expired on #{expiry}"
expiry_message
end
else
ui = Inspec::UI.new
ui.error("Unable to parse attestation expiration date '#{expiry}' for control #{control_id}")
ui.exit(:usage_error)
end
rescue => e
ui = Inspec::UI.new
ui.error("Unable to parse attestation expiration date '#{expiry}' for control #{control_id}. Error: #{e.message}")
ui.exit(:usage_error)
end
def self.attestation_check(expiry_message, attestation_data, control_id)
# logic to update enhanced outcome status
status, msg = nil
if %w{passed failed}.include? attestation_data["status"]
if expiry_message
status = "failed"
msg = "Control not attested : #{expiry_message}"
else
# use justification and evidence url to show information in msg
attestation_message = attestation_data["justification"] || attestation_data["explanation"] || ""
unless attestation_data["evidence_url"].blank?
if attestation_message.blank?
attestation_message = "Evidence URL: #{attestation_data["evidence_url"]}"
else
attestation_message += " | Evidence URL: #{attestation_data["evidence_url"]}"
end
end
status = attestation_data["status"]
if attestation_message.blank?
msg = "Control Attested : No justification provided."
else
msg = "Control Attested : #{attestation_message}"
end
end
else
if attestation_data["status"].blank?
Inspec::Log.warn "No attestation status for control #{control_id}. Use 'passed' or 'failed'."
else
Inspec::Log.warn "Invalid attestation status '#{attestation_data["status"]}' for control #{control_id}. Use 'passed' or 'failed'."
end
return nil
end
[status, msg]
end
def self.determine_expiry(attestation_data, control_id)
if attestation_data["expiration_date"]
attestation_data["expiration_date"]
elsif !attestation_data["updated"].blank? && !attestation_data["frequency"].blank?
begin
calculate_expiry(attestation_data["updated"], attestation_data["frequency"], control_id)
rescue => e
ui = Inspec::UI.new
ui.error("Unable to parse attestation updated date '#{attestation_data["updated"]}' for control #{control_id}. Error: #{e.message}")
ui.exit(:usage_error)
end
end
end
def self.calculate_expiry(updated_date, frequency, control_id)
# logic to find expiration date using frequency and updated date.
if updated_date.is_a?(Date) || (updated_date.is_a?(String) && Date.parse(updated_date).year != 0)
updated_date = Date.parse(updated_date) if updated_date.is_a? String
if updated_date < Time.now.to_date
case frequency
when "annually"
updated_date.to_date.next_year(1)
when "semiannually"
updated_date.next_month(6)
when "quarterly"
updated_date.next_month(3)
when "monthly"
updated_date.next_month(1)
when "every2weeks"
updated_date.next_day(14)
when "weekly"
updated_date.next_day(7)
when "every3days"
updated_date.next_day(3)
when "daily"
updated_date.next_day(1)
else
Inspec::Log.warn "Invalid frequency value '#{frequency}' for control #{control_id}."
updated_date
end
else
updated_date
end
else
ui = Inspec::UI.new
ui.error("Unable to parse attestation updated date '#{updated_date}' for control #{control_id}")
ui.exit(:usage_error)
end
end
end
end

View file

@ -177,6 +177,8 @@ module Inspec
desc: "Load one or more input files, a YAML file with values for the profile to use"
option :waiver_file, type: :array,
desc: "Load one or more waiver files."
option :attestation_file, type: :array,
desc: "Load one or more attestation files."
option :attrs, type: :array,
desc: "Legacy name for --input-file - deprecated."
option :create_lockfile, type: :boolean,

View file

@ -14,5 +14,6 @@ module Inspec
"passed"
end
end
end
end

View file

@ -12,5 +12,7 @@ module Inspec
class ProfileSigningKeyNotFound < ArgumentError; end
class WaiversFileNotReadable < ArgumentError; end
class WaiversFileDoesNotExist < ArgumentError; end
class AttestationFileNotReadable < ArgumentError; end
class AttestationFileDoesNotExist < ArgumentError; end
end
end

View file

@ -160,7 +160,7 @@ module Inspec::Formatters
end
# added this additionally because stats summary is also used for determining exit code in runner rspec
skipped += 1 if control[:results].any? { |r| r[:status] == "skipped" }
skipped += 1 if control[:results] && (control[:results].any? { |r| r[:status] == "skipped" })
end
total = error + not_applicable + not_reviewed + failed + passed
@ -236,6 +236,7 @@ module Inspec::Formatters
resource_title: example.metadata[:described_class] || example.metadata[:example_group][:description],
expectation_message: format_expectation_message(example),
waiver_data: example.metadata[:waiver_data],
attestation_data: example.metadata[:attestation_data],
# This enforces the resource name as expected based off of the class
# name. However, if we wanted the `name` attribute against the class
# to be canonical for this case (consider edge cases!) we would use
@ -358,6 +359,7 @@ module Inspec::Formatters
# (that is, per-describe-block) basis, because that is the only granularity
# available to us in the RSpec report data structure which we use as a vehicle.
control[:waiver_data] ||= example[:waiver_data] || {}
control[:attestation_data] ||= example[:attestation_data] || {}
end
end
end

View file

@ -1,3 +1,4 @@
require "inspec/attestations"
module Inspec::Plugin::V2::PluginType
class StreamingReporter < Inspec::Plugin::V2::PluginBase
register_plugin_type(:streaming_reporter)
@ -12,6 +13,8 @@ module Inspec::Plugin::V2::PluginType
@control_checks_count_map = {}
@controls_count = nil
@notifications = {}
@enhanced_outcome_control_wise = {}
@attestation_message_control_wise = {}
end
private
@ -29,16 +32,57 @@ module Inspec::Plugin::V2::PluginType
# method to identify when the control ended running
# this will be useful in executing operations on control's level end
def control_ended?(control_id)
def control_ended?(notification, control_id)
set_control_checks_count_map_value
unless @control_checks_count_map[control_id].nil?
@control_checks_count_map[control_id] -= 1
@control_checks_count_map[control_id] == 0
control_ended = @control_checks_count_map[control_id] == 0
# after a control has ended it checks for certain operations, like enhanced outcomes & attestations
run_control_operations(notification, control_id) if control_ended
control_ended
else
false
end
end
def run_control_operations(notification, control_id)
check_for_enhanced_outcomes(notification, control_id)
check_for_attestation(notification, control_id)
end
def check_for_enhanced_outcomes(notification, control_id)
if enhanced_outcomes
control_outcome = add_enhanced_outcomes(control_id)
@enhanced_outcome_control_wise[control_id] = control_outcome
end
end
def check_for_attestation(notification, control_id)
control_outcome = control_outcome(control_id)
if control_outcome
attestation_result = attest_control(notification, control_id, control_outcome)
unless attestation_result.blank?
@enhanced_outcome_control_wise[control_id] = attestation_result[0]
@attestation_message_control_wise[control_id] = attestation_result[1]
end
end
end
def format_message(indicator, control_id, title, full_description)
message_to_format = ""
message_to_format += "#{indicator} "
message_to_format += "#{control_id.to_s.strip.dup.force_encoding(Encoding::UTF_8)} "
message_to_format += "#{title.gsub(/\n*\s+/, " ").to_s.force_encoding(Encoding::UTF_8)} " if title
message_to_format += "#{full_description.gsub(/\n*\s+/, " ").to_s.force_encoding(Encoding::UTF_8)} " unless title
# append attestation message if control is attested
message_to_format += "#{@attestation_message_control_wise[control_id].gsub(/\n*\s+/, " ").to_s.force_encoding(Encoding::UTF_8)} " if @attestation_message_control_wise[control_id]
message_to_format
end
def control_outcome(control_id)
@enhanced_outcome_control_wise[control_id]
end
# method to identify total no. of controls
def controls_count
@controls_count ||= RSpec.configuration.formatters.grep(Inspec::Formatters::Base).first.get_controls_count
@ -103,5 +147,23 @@ module Inspec::Plugin::V2::PluginType
@notifications[control_id].push([notification, status])
end
end
def attest_control(notification, control_id, control_outcome)
status = control_outcome
attestation_data = read_attestation_file(notification, control_id)
Inspec::Attestations.attest_streaming_data(attestation_data, status, control_id) unless attestation_data.blank?
end
def read_attestation_file(notification, control_id)
# need to re-read the file from config since not using run data for streaming reporters.
profile_id = notification.example.metadata[:profile_id]
attestation_files = Inspec::Config.cached.final_options["attestation_file"] if Inspec::Config.cached.respond_to?(:final_options)
attestation_data_by_profile = Inspec::AttestationFileReader.fetch_attestation_by_profile(profile_id, attestation_files) unless attestation_files.nil?
return unless attestation_data_by_profile && attestation_data_by_profile[control_id] && attestation_data_by_profile[control_id].is_a?(Hash)
attestation_data_by_profile[control_id]
end
end
end

View file

@ -1,4 +1,5 @@
require_relative "../utils/run_data_filters"
require "inspec/attestations"
module Inspec::Reporters
class Base
@ -12,6 +13,8 @@ module Inspec::Reporters
@run_data = config[:run_data] || {}
apply_run_data_filters_to_hash
# only try for attestation when attestation file is passed
Inspec::Attestations.attest(@run_data) if Inspec::Config.cached[:attestation_file]
@output = ""
end

View file

@ -317,7 +317,7 @@ module Inspec::Reporters
not_applicable = 0
all_unique_controls.each do |control|
next if control[:status].empty?
next if control[:status].blank?
if control[:status] == "failed"
failed += 1

View file

@ -9,6 +9,7 @@ require "inspec/resource"
require "inspec/resources/os"
require "inspec/input_registry"
require "inspec/waiver_file_reader"
require "inspec/attestation_file_reader"
require "inspec/utils/convert"
module Inspec
@ -16,6 +17,7 @@ module Inspec
include ::RSpec::Matchers
attr_reader :__waiver_data
attr_reader :__attestation_data
attr_accessor :resource_dsl
attr_reader :__profile_id
@ -50,6 +52,7 @@ module Inspec
# By applying waivers *after* the instance eval, we assure that
# waivers have higher precedence than only_if.
__apply_waivers
__add_attestation_data
rescue SystemStackError, StandardError => e
# We've encountered an exception while trying to eval the code inside the
@ -392,6 +395,19 @@ module Inspec
__waiver_data["skipped_due_to_waiver"] = true
end
# fetches attestation data for the rule which is used in runner_rspec.rb to assign it inside metadata
def __add_attestation_data
# this adds attestation data to a rule, accesible on run data layer.
control_id = @__rule_id
attestation_files = Inspec::Config.cached.final_options["attestation_file"] if Inspec::Config.cached.respond_to?(:final_options)
attestation_data_by_profile = Inspec::AttestationFileReader.fetch_attestation_by_profile(__profile_id, attestation_files) unless attestation_files.nil?
return unless attestation_data_by_profile && attestation_data_by_profile[control_id] && attestation_data_by_profile[control_id].is_a?(Hash)
@__attestation_data = attestation_data_by_profile[control_id]
end
#
# Takes a block and returns a block that will run the given block
# with access to the resource_dsl of the current class. This is to

View file

@ -27,11 +27,16 @@ module Inspec
) do
include HashLikeStruct
def initialize(raw_run_data)
self.controls = raw_run_data[:controls].map { |c| Inspec::RunData::Control.new(c) }
self.profiles = raw_run_data[:profiles].map { |p| Inspec::RunData::Profile.new(p) }
self.statistics = Inspec::RunData::Statistics.new(raw_run_data[:statistics])
self.platform = Inspec::RunData::Platform.new(raw_run_data[:platform])
self.version = raw_run_data[:version]
@raw_run_data = raw_run_data
# only try for attestation when attestation file is passed
Inspec::Attestations.attest(@raw_run_data) if Inspec::Config.cached[:attestation_file]
self.controls = @raw_run_data[:controls].map { |c| Inspec::RunData::Control.new(c) }
self.profiles = @raw_run_data[:profiles].map { |p| Inspec::RunData::Profile.new(p) }
self.statistics = Inspec::RunData::Statistics.new(@raw_run_data[:statistics])
self.platform = Inspec::RunData::Platform.new(@raw_run_data[:platform])
self.version = @raw_run_data[:version]
end
end

View file

@ -13,7 +13,8 @@ module Inspec
:source_location, # Complex local
:tags, # Hash with custom keys
:title, # String
:waiver_data # Complex local
:waiver_data, # Complex local
:attestation_data # Complex local
) do
include HashLikeStruct
def initialize(raw_ctl_data)
@ -21,6 +22,7 @@ module Inspec
self.results = (raw_ctl_data[:results] || []).map { |r| Inspec::RunData::Result.new(r) }
self.source_location = Inspec::RunData::Control::SourceLocation.new(raw_ctl_data[:source_location] || {})
self.waiver_data = Inspec::RunData::Control::WaiverData.new(raw_ctl_data[:waiver_data] || {})
self.attestation_data = Inspec::RunData::Control::AttestationData.new(raw_ctl_data[:attestation_data] || {})
[
:code, # String
@ -84,6 +86,26 @@ module Inspec
}.each { |f| self[f] = raw_wv_data[f.to_s] }
end
end
AttestationData = Struct.new(
:expiration_date,
:justification,
:evidence_url,
:status,
:message
) do
include HashLikeStruct
def initialize(raw_attestation_data)
# These have string keys in the raw data!
%i{
expiration_date
justification
evidence_url
status
message
}.each { |f| self[f] = raw_attestation_data[f.to_s] }
end
end
end
end
end

View file

@ -67,6 +67,14 @@ module Inspec
end
end
if @conf[:attestation_file]
@conf[:attestation_file].each do |file|
unless File.file?(file)
raise Inspec::Exceptions::AttestationFileDoesNotExist, "Attestation file #{file} does not exist."
end
end
end
# About reading inputs:
# @conf gets passed around a lot, eventually to
# Inspec::InputRegistry.register_external_inputs.
@ -168,7 +176,9 @@ module Inspec
return if @conf["reporter"].nil?
@conf["reporter"].each do |reporter|
result = Inspec::Reporters.render(reporter, run_data, @conf["enhanced_outcomes"])
# if attestation file is used then we need enhanced outcomes
enhanced_outcome_flag = @conf["attestation_file"] ? true : @conf["enhanced_outcomes"]
result = Inspec::Reporters.render(reporter, run_data, enhanced_outcome_flag)
raise Inspec::ReporterError, "Error generating reporter '#{reporter[0]}'" if result == false
end
end

View file

@ -196,7 +196,9 @@ module Inspec
def configure_output
RSpec.configuration.output_stream = $stdout
@formatter = RSpec.configuration.add_formatter(Inspec::Formatters::Base)
@formatter.enhanced_outcomes = @conf.final_options["enhanced_outcomes"]
# if attestation file is used then we need enhanced outcomes
@formatter.enhanced_outcomes = @conf.final_options["attestation_file"] ? true : @conf.final_options["enhanced_outcomes"]
RSpec.configuration.add_formatter(Inspec::Formatters::ShowProgress, $stderr) if @conf[:show_progress]
set_optional_formatters
RSpec.configuration.color = @conf["color"]
@ -228,6 +230,7 @@ module Inspec
metadata[:code] = rule.instance_variable_get(:@__code)
metadata[:source_location] = rule.instance_variable_get(:@__source_location)
metadata[:waiver_data] = rule.__waiver_data
metadata[:attestation_data] = rule.__attestation_data # data fetched from rule object
end
end
end

View file

@ -16,24 +16,8 @@ module Inspec
output = {}
files.each do |file_path|
file_extension = File.extname(file_path)
data = nil
if [".yaml", ".yml"].include? file_extension
data = Secrets::YAML.resolve(file_path)
data = data.inputs unless data.nil?
validate_json_yaml(data)
elsif file_extension == ".csv"
data = Waivers::CSVFileReader.resolve(file_path)
headers = Waivers::CSVFileReader.headers
validate_headers(headers)
elsif file_extension == ".json"
data = Waivers::JSONFileReader.resolve(file_path)
validate_json_yaml(data)
elsif [".xls", ".xlsx"].include? file_extension
data = Waivers::ExcelFileReader.resolve(file_path)
headers = Waivers::ExcelFileReader.headers
validate_headers(headers)
end
data = read_from_file(file_path)
output.merge!(data) if !data.nil? && data.is_a?(Hash)
if data.nil?
@ -46,10 +30,34 @@ module Inspec
@waivers_data[profile_id] = output
end
def self.read_from_file(file_path)
data = nil
file_extension = File.extname(file_path)
if [".yaml", ".yml"].include? file_extension
data = Secrets::YAML.resolve(file_path)
data = data.inputs unless data.nil?
validate_json_yaml(data)
elsif file_extension == ".csv"
data = Waivers::CSVFileReader.resolve(file_path)
headers = Waivers::CSVFileReader.headers
validate_headers(headers)
elsif file_extension == ".json"
data = Waivers::JSONFileReader.resolve(file_path)
validate_json_yaml(data)
elsif [".xls", ".xlsx"].include? file_extension
data = Waivers::ExcelFileReader.resolve(file_path)
headers = Waivers::ExcelFileReader.headers
validate_headers(headers)
end
data
end
def self.all_fields
%w{control_id justification expiration_date run}
end
def self.validate_headers(headers, json_yaml = false)
required_fields = json_yaml ? %w{justification} : %w{control_id justification}
all_fields = %w{control_id justification expiration_date run}
Inspec::Log.warn "Missing column headers: #{(required_fields - headers)}" unless (required_fields - headers).empty?
Inspec::Log.warn "Invalid column header: Column can't be nil" if headers.include? nil
Inspec::Log.warn "Extra column headers: #{(headers - all_fields)}" unless (headers - all_fields).empty?

View file

@ -85,23 +85,20 @@ module InspecPlugins::StreamingReporterProgressBar
full_description = notification.example.metadata[:full_description]
set_status_mapping(control_id, status)
collect_notifications(notification, control_id, status)
control_ended = control_ended?(control_id)
if control_ended
control_outcome = add_enhanced_outcomes(control_id) if enhanced_outcomes
show_progress(control_id, title, full_description, control_outcome)
end
show_progress(control_id, title, full_description) if control_ended?(notification, control_id)
end
def show_progress(control_id, title, full_description, control_outcome)
def show_progress(control_id, title, full_description)
@bar ||= ProgressBar.new(controls_count, :bar, :counter, :percentage)
sleep 0.1
@bar.increment!
@bar.puts format_it(control_id, title, full_description, control_outcome)
@bar.puts format_it(control_id, title, full_description)
rescue StandardError => e
raise "Exception in Progress Bar streaming reporter: #{e}"
end
def format_it(control_id, title, full_description, control_outcome)
def format_it(control_id, title, full_description)
control_outcome = control_outcome(control_id)
if control_outcome
control_status = control_outcome
else
@ -115,11 +112,7 @@ module InspecPlugins::StreamingReporterProgressBar
end
end
indicator = INDICATORS[control_status]
message_to_format = ""
message_to_format += "#{indicator} "
message_to_format += "#{control_id.to_s.strip.dup.force_encoding(Encoding::UTF_8)} "
message_to_format += "#{title.gsub(/\n*\s+/, " ").to_s.force_encoding(Encoding::UTF_8)} " if title
message_to_format += "#{full_description.gsub(/\n*\s+/, " ").to_s.force_encoding(Encoding::UTF_8)} " unless title
message_to_format = format_message(indicator, control_id, title, full_description)
format_with_color(control_status, message_to_format)
rescue Exception => e
raise "Exception in show_progress: #{e}"

View file

@ -0,0 +1,93 @@
# Error
control "tmp-1.0.1" do
impact 0.7
describe "a.1" do
it { should_bot "a.1" }
end
end
control "tmp-1.0.2" do
impact 0.0
describe "a.2" do
it { should_bot "a.2" }
end
end
# Not Applicable
control "tmp-2.0.1" do
impact 0.0
describe "b.1" do
it { should cmp "b.1" }
end
end
control "tmp-2.0.2" do
impact 0.0
only_if { false }
describe "b.2" do
it { should cmp "b.2" }
end
end
# Not Reviewed
control "tmp-3.0.1" do
only_if { false }
describe "c.1" do
it { should cmp "c.1" }
end
end
control "tmp-3.0.2" do
only_if { false }
describe "c.2" do
it { should_bot "c.2" }
end
end
control "tmp-3.0.3" do
only_if { false }
describe "c.2" do
it { should_bot "c.2" }
end
end
control "tmp-3.0.4" do
only_if { false }
describe "c.2" do
it { should_bot "c.2" }
end
end
# Failed
control "tmp-4.0" do
impact 0.7
describe "d.1" do
it { should_not cmp "d.1" }
it { should cmp "d.1" }
end
end
# Passed
control "tmp-5.0" do
impact 0.7
describe "e.1" do
it { should cmp "e.1" }
end
end
# Example of setting impact using code and marking it N/A
control "tmp-6.0.1" do
impact 0.5
only_if("Some reason for N/A", impact: 0.0) { false }
describe "f.1" do
it { should cmp "f.1" }
end
end
# Example of setting impact using code and not marked as N/A
control "tmp-6.0.2" do
only_if(impact: 0.5) { false }
describe "f.2" do
it { should cmp "f.2" }
end
end

View file

@ -0,0 +1,5 @@
control_id,justification,explanation,evidence_url,status,expiration_date,updated,frequency
tmp-3.0.1,Sound reasoning,,Dummy url,failed,2001-06-01T00:00:00.000Z,,,
tmp-3.0.2,,Unassailable thinking,Dummy url,failed,,2021-06-01T00:00:00.000Z,semiannually
tmp-4.0,Sheer cleverness,,Dummy url,passed,2050-06-01T00:00:00.000Z,,,
tmp-6.0.2,Sheer cleverness,,Dummy url,passed,2050-06-01T00:00:00.000Z,,,
1 control_id,justification,explanation,evidence_url,status,expiration_date,updated,frequency
2 tmp-3.0.1,Sound reasoning,,Dummy url,failed,2001-06-01T00:00:00.000Z,,,
3 tmp-3.0.2,,Unassailable thinking,Dummy url,failed,,2021-06-01T00:00:00.000Z,semiannually
4 tmp-4.0,Sheer cleverness,,Dummy url,passed,2050-06-01T00:00:00.000Z,,,
5 tmp-6.0.2,Sheer cleverness,,Dummy url,passed,2050-06-01T00:00:00.000Z,,,

View file

@ -0,0 +1,27 @@
{
"tmp-3.0.1": {
"explanation": "Sound reasoning",
"evidence_url": "Dummy url",
"status": "failed",
"updated": "2021-06-01T00:00:00.000Z",
"frequency": "semiannually"
},
"tmp-3.0.2": {
"justification": "Unassailable thinking",
"evidence_url": "Dummy url",
"expiration_date": "2001-06-01T00:00:00.000Z",
"status": "passed"
},
"tmp-4.0": {
"justification": "Sheer cleverness",
"evidence_url": "Dummy url",
"expiration_date": "2050-06-01T00:00:00.000Z",
"status": "passed"
},
"tmp-6.0.2": {
"justification": "Sheer cleverness",
"evidence_url": "Dummy url",
"expiration_date": "2050-06-01T00:00:00.000Z",
"status": "passed"
}
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,24 @@
tmp-3.0.1:
explanation: Sound reasoning
evidence_url: Dummy url
status: failed
updated: 2021-06-01
frequency: semiannually
tmp-3.0.2:
justification: Unassailable thinking
evidence_url: Dummy url
expiration_date: 2001-06-01
status: passed
tmp-4.0:
justification: Sheer cleverness
evidence_url: Dummy url
expiration_date: 2050-06-01
status: passed
tmp-6.0.2:
justification: Sheer cleverness
evidence_url: Dummy url
expiration_date: 2050-06-01
status: passed

View file

@ -0,0 +1,5 @@
tmp-3.0.2:
justification: Unassailable thinking
evidence_url: Dummy url
expiration_date: bad date
status: passed

View file

@ -0,0 +1,6 @@
tmp-3.0.1:
justification: Sound reasoning
evidence_url: Dummy url
status: failed
updated: bad date
frequency: semiannually

View file

@ -0,0 +1,6 @@
tmp-3.0.4:
justification: Sound reasoning
evidence_url: Dummy url
status: failed
updated: 2021-06-01
frequency: biweekly

View file

@ -0,0 +1,5 @@
tmp-3.0.3:
justification: Unassailable thinking
evidence_url: Dummy url
expiration_date: 2050-06-01
status: pass

View file

@ -0,0 +1,7 @@
tmp-3.0.1:
expiration_date: 2050-06-01
status: passed
tmp-3.0.2:
evidence_url: Dummy url
expiration_date: 2050-06-01
status: passed

View file

@ -0,0 +1,4 @@
tmp-3.0.3:
justification: Unassailable thinking
evidence_url: Dummy url
expiration_date: 2050-06-01

View file

@ -0,0 +1,8 @@
control_id_random,justification_random,run_random,expiration_date_random,,,
03_waivered_no_expiry_ran_passes,Sound reasoning,TRUE,,,,
04_waivered_no_expiry_ran_fails,Unassailable thinking,TRUE,2077-11-10T00:00:00Z,,,
,,,,,,
05_waivered_no_expiry_not_ran,Sheer cleverness,FALSE,,,,
06_waivered_expiry_in_past_ran_passes,Necessity,TRUE,,,,
14_waivered_expiry_in_future_z_not_ran,Lack of imagination,FALSE,2077-11-10T00:00:00Z,,,
random contorl id with no data,,,,,,random data in csv!
1 control_id_random justification_random run_random expiration_date_random
2 03_waivered_no_expiry_ran_passes Sound reasoning TRUE
3 04_waivered_no_expiry_ran_fails Unassailable thinking TRUE 2077-11-10T00:00:00Z
4
5 05_waivered_no_expiry_not_ran Sheer cleverness FALSE
6 06_waivered_expiry_in_past_ran_passes Necessity TRUE
7 14_waivered_expiry_in_future_z_not_ran Lack of imagination FALSE 2077-11-10T00:00:00Z
8 random contorl id with no data random data in csv!

View file

@ -0,0 +1,21 @@
{
"tmp-3.0.1": {
"justification": "Sound reasoning",
"evidence_url": "Dummy url",
"expiration_date": "2050-06-01T00:00:00.000Z",
"status": "passed",
"random": "haha"
},
"tmp-3.0.2": {
"justification": "Unassailable thinking",
"evidence_url": "Dummy url",
"expiration_date": "2050-06-01T00:00:00.000Z",
"status": "passed"
},
"tmp-4.0": {
"justification": "Sheer cleverness",
"evidence_url": "Dummy url",
"expiration_date": "2050-06-01T00:00:00.000Z",
"status": "passed"
}
}

Binary file not shown.

View file

@ -0,0 +1,18 @@
tmp-3.0.1:
justification: Sound reasoning
evidence_url: Dummy url
expiration_date: 2050-06-01
status: passed
random: haha
tmp-3.0.2:
justification: Unassailable thinking
evidence_url: Dummy url
expiration_date: 2050-06-01
status: passed
tmp-4.0:
justification: Sheer cleverness
evidence_url: Dummy url
expiration_date: 2050-06-01
status: passed

View file

@ -0,0 +1,10 @@
name: attestation
title: InSpec Profile
maintainer: The Authors
copyright: The Authors
copyright_email: you@example.com
license: Apache-2.0
summary: An InSpec Compliance Profile
version: 0.1.0
supports:
platform: os

View file

@ -0,0 +1,218 @@
require "functional/helper"
describe "attestations" do
include FunctionalHelper
parallelize_me!
let(:attestation_profile) { "#{profile_path}/attestation" }
let(:run_result) { run_inspec_process(cmd) }
let(:cmd) { "exec #{attestation_profile} --attestation-file #{attestation_profile}/files/#{attestation_file}" }
attr_accessor :out
def inspec(commandline, prefix = nil)
@stdout = @stderr = nil
self.out = super
end
def stdout
@stdout ||= out.stdout
.force_encoding(Encoding::UTF_8)
end
def stderr
@stderr ||= out.stderr
.force_encoding(Encoding::UTF_8)
end
describe "with a attestation file that does not exist" do
let(:attestation_file) { "no_file.yaml" }
it "raise file does not exist standard error" do
result = run_result
assert_includes result.stderr, "no_file.yaml does not exist"
assert_equal 1, result.exit_status
end
end
describe "with a attestation file that has wrong headers - yaml format" do
let(:attestation_file) { "wrong-headers.yaml" }
it "raise file does not exist standard error" do
result = run_result
assert_includes result.stdout, "Extra column headers: [\"random\"]"
end
end
describe "with a attestation file that has wrong headers - csv format" do
let(:attestation_file) { "wrong-headers.csv" }
it "raise file does not exist standard error" do
result = run_result
assert_includes result.stdout, "Missing column headers: [\"control_id\", \"status\", \"justification\"]"
assert_includes result.stdout, "Extra column headers: [\"control_id_random\", \"justification_random\", \"run_random\", \"expiration_date_random\", nil]\n"
end
end
describe "with a attestation file that has wrong headers - json format" do
let(:attestation_file) { "wrong-headers.json" }
it "raise file does not exist standard error" do
result = run_result
assert_includes result.stdout, "Extra column headers: [\"random\"]"
end
end
describe "with a attestation file that has wrong headers - xlsx format" do
let(:attestation_file) { "wrong-headers.xlsx" }
it "raise file does not exist standard error" do
result = run_result
assert_includes result.stdout, "Missing column headers: [\"control_id\", \"status\", \"justification\"]"
assert_includes result.stdout, "Extra column headers: [\"control_id_random\", \"justification_random\", \"run_random\", \"expiration_date_random\"]\n"
end
end
describe "running attestation on a profile - yaml" do
let(:attestation_file) { "attestations.yaml" }
it "attests N/R controls correctly" do
result = run_result
assert_includes result.stdout, "tmp-3.0.1: No-op (1 failed)"
refute_includes result.stdout, "N/R tmp-3.0.2: No-op"
refute_includes result.stdout, "N/R tmp-6.0.2: No-op"
end
it "does not attests non N/R controls" do
result = run_result
assert_includes result.stdout, "tmp-4.0: d.1 (1 failed)"
end
end
describe "running attestation on a profile - json" do
let(:attestation_file) { "attestations.json" }
it "attests N/R controls correctly" do
result = run_result
assert_includes result.stdout, "tmp-3.0.1: No-op (1 failed)"
refute_includes result.stdout, "N/R tmp-3.0.2: No-op"
refute_includes result.stdout, "N/R tmp-6.0.2: No-op"
end
it "does not attests non N/R controls" do
result = run_result
assert_includes result.stdout, "tmp-4.0: d.1 (1 failed)"
end
end
describe "running attestation on a profile - csv" do
let(:attestation_file) { "attestations.csv" }
it "attests N/R controls correctly" do
result = run_result
assert_includes result.stdout, "tmp-3.0.1: No-op (1 failed)"
refute_includes result.stdout, "N/R tmp-3.0.2: No-op"
refute_includes result.stdout, "N/R tmp-6.0.2: No-op"
end
it "does not attests non N/R controls" do
result = run_result
assert_includes result.stdout, "tmp-4.0: d.1 (1 failed)"
end
end
describe "running attestation on a profile - xlsx" do
let(:attestation_file) { "attestations.xlsx" }
it "attests N/R controls correctly" do
result = run_result
assert_includes result.stdout, "tmp-3.0.1: No-op (1 failed)"
refute_includes result.stdout, "N/R tmp-3.0.2: No-op"
refute_includes result.stdout, "N/R tmp-6.0.2: No-op"
end
it "does not attests non N/R controls" do
result = run_result
assert_includes result.stdout, "tmp-4.0: d.1 (1 failed)"
end
end
describe "running attestation on a profile - xls" do
let(:attestation_file) { "attestations.xls" }
it "attests N/R controls correctly" do
result = run_result
assert_includes result.stdout, "tmp-3.0.1: No-op (1 failed)"
refute_includes result.stdout, "N/R tmp-3.0.2: No-op"
refute_includes result.stdout, "N/R tmp-6.0.2: No-op"
end
it "does not attests non N/R controls" do
result = run_result
assert_includes result.stdout, "tmp-4.0: d.1 (1 failed)"
end
end
describe "running attestation on profile with streaming reporter" do
let(:attestation_file) { "#{attestation_profile}/files/attestations.yaml" }
it "attests controls correctly" do
inspec("exec " + "#{attestation_profile}" + " --attestation-file #{attestation_file}" + " --no-create-lockfile" + " --no-color" + " --reporter progress-bar")
if windows?
_(stderr).must_match(/\[FAIL\]\s*tmp-3.0.1\s*No-op Skipped control due to only_if condition. Control not attested : Attestation expired on 2021-12-01/)
_(stderr).must_match(/\[FAIL\]\s*tmp-3.0.2\s*No-op Skipped control due to only_if condition. Control not attested : Attestation expired on 2001-06-01/)
_(stderr).must_match(/\[PASS\]\s*tmp-6.0.2\s*No-op Skipped control due to only_if condition. Control Attested : Sheer cleverness | Evidence URL: Dummy url/)
_(stderr).must_match(/\[FAIL\]\s*tmp-4.0\s*d.1 is expected to cmp == \"d.1\"/)
else
_(stderr).must_match(/\[FAILED\]\s*tmp-3.0.1\s*No-op Skipped control due to only_if condition. Control not attested : Attestation expired on 2021-12-01/)
_(stderr).must_match(/\[FAILED\]\s*tmp-3.0.2\s*No-op Skipped control due to only_if condition. Control not attested : Attestation expired on 2001-06-01/)
_(stderr).must_match(/\[PASSED\]\s*tmp-6.0.2\s*No-op Skipped control due to only_if condition. Control Attested : Sheer cleverness | Evidence URL: Dummy url/)
_(stderr).must_match(/\[FAILED\]\s*tmp-4.0\s*d.1 is expected to cmp == \"d.1\"/)
end
end
end
describe "an attestation file with invalid dates" do
let(:attestation_file) { "bad-date.yaml" }
it "gracefully errors" do
result = run_result
assert_includes result.stdout, "ERROR"
end
end
describe "an attestation file with invalid update dates" do
let(:attestation_file) { "bad-update-date.yaml" }
it "gracefully errors" do
result = run_result
assert_includes result.stdout, "ERROR"
end
end
describe "an attestation file with invalid status" do
let(:attestation_file) { "invalid-status.yaml" }
it "throws warning" do
result = run_result
assert_includes result.stdout, "Invalid attestation status 'pass' for control tmp-3.0.3. Use 'passed' or 'failed'."
end
end
describe "an attestation file with no status" do
let(:attestation_file) { "no-status.yaml" }
it "throws warning" do
result = run_result
assert_includes result.stdout, "No attestation status for control tmp-3.0.3. Use 'passed' or 'failed'."
end
end
describe "an attestation file with invalid frequency value" do
let(:attestation_file) { "invalid-frequency.yaml" }
it "throws warning" do
result = run_result
assert_includes result.stdout, "Invalid frequency value 'biweekly' for control tmp-3.0.4."
end
end
describe "an attestation file with no justification" do
let(:attestation_file) { "no-justification.yaml" }
it "throws warning and shows proper message for justification absence" do
result = run_result
assert_includes result.stdout, "Missing column headers: [\"justification\"]"
assert_includes result.stdout, "Control Attested : No justification provided."
end
end
end