diff --git a/.github/workflows/scan-codeql.yaml b/.github/workflows/scan-codeql.yaml index ab0923c9..25f3fc77 100644 --- a/.github/workflows/scan-codeql.yaml +++ b/.github/workflows/scan-codeql.yaml @@ -42,7 +42,7 @@ jobs: uses: ./.github/actions/golang - name: Initialize CodeQL - uses: github/codeql-action/init@2d790406f505036ef40ecba973cc774a50395aac # v3.25.13 + uses: github/codeql-action/init@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14 with: languages: ${{ matrix.language }} # config-file: ./.github/codeql.yaml #Uncomment once config file is needed. @@ -52,7 +52,7 @@ jobs: - name: Perform CodeQL Analysis id: scan - uses: github/codeql-action/analyze@2d790406f505036ef40ecba973cc774a50395aac # v3.25.13 + uses: github/codeql-action/analyze@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml index 7fa0172f..2c5ec973 100644 --- a/.github/workflows/scorecard.yaml +++ b/.github/workflows/scorecard.yaml @@ -46,6 +46,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@2d790406f505036ef40ecba973cc774a50395aac # v3.25.13 + uses: github/codeql-action/upload-sarif@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14 with: sarif_file: results.sarif \ No newline at end of file diff --git a/docs/cli-commands/assessments/evaluate.md b/docs/cli-commands/assessments/evaluate.md index a8021531..859fa2e4 100644 --- a/docs/cli-commands/assessments/evaluate.md +++ b/docs/cli-commands/assessments/evaluate.md @@ -2,6 +2,28 @@ Evaluate serves as a method for verifying the compliance of a component/system against an established threshold to determine if it is more or less compliant than a previous assessment. +## Usage + +To evaluate two results (threshold and latest) in a single OSCAL file: +```bash +lula evaluate -f assessment-results.yaml +``` + +To evaluate the latest results in two assessment results files: +```bash +lula evaluate -f assessment-results-threshold.yaml -f assessment-results-new.yaml +``` + +To print a summary of the observation results: +```bash +lula evaluate -f assessment-results.yaml --summary +``` + +## Options + +- `-f, --file`: The path to the file(s) to be evaluated. +- `-s, --summary`: [Optional] Prints a summary of the evaluation. + ## Expected Process ### No Existing Data diff --git a/go.mod b/go.mod index e7b48577..d26ed959 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/defenseunicorns/lula -go 1.22.3 +go 1.22.5 require ( github.com/defenseunicorns/go-oscal v0.5.0 github.com/hashicorp/go-version v1.7.0 github.com/kyverno/kyverno-json v0.0.3 - github.com/open-policy-agent/opa v0.66.0 + github.com/open-policy-agent/opa v0.67.0 github.com/pterm/pterm v0.12.79 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sergi/go-diff v1.3.1 @@ -33,7 +33,7 @@ require ( github.com/aquilax/truncate v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -111,22 +111,22 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect github.com/zach-klippenstein/goregen v0.0.0-20160303162051-795b5e3961ea // indirect - go.opentelemetry.io/otel v1.23.1 // indirect - go.opentelemetry.io/otel/metric v1.23.1 // indirect - go.opentelemetry.io/otel/sdk v1.23.1 // indirect - go.opentelemetry.io/otel/trace v1.23.1 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/sdk v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect go.starlark.net v0.0.0-20240123142251-f86470692795 // indirect - golang.org/x/crypto v0.24.0 // indirect + golang.org/x/crypto v0.25.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/net v0.26.0 // indirect + golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.17.0 // indirect golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.34.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/evanphx/json-patch.v5 v5.9.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 1c97e522..8f03bbac 100644 --- a/go.sum +++ b/go.sum @@ -38,12 +38,12 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= @@ -112,8 +112,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= -github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4= +github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -161,8 +161,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= @@ -235,8 +235,8 @@ github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8 github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= -github.com/open-policy-agent/opa v0.66.0 h1:DbrvfJQja0FBRcPOB3Z/BOckocN+M4ApNWyNhSRJt0w= -github.com/open-policy-agent/opa v0.66.0/go.mod h1:EIgNnJcol7AvQR/IcWLwL13k64gHVbNAVG46b2G+/EY= +github.com/open-policy-agent/opa v0.67.0 h1:FOdsO9yNhfmrh+72oVK7ImWmzruG+VSpfbr5IBqEWVs= +github.com/open-policy-agent/opa v0.67.0/go.mod h1:aqKlHc8E2VAAylYE9x09zJYr/fYzGX+JKne89UGqFzk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= @@ -268,8 +268,8 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqn github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= @@ -323,22 +323,22 @@ github.com/zach-klippenstein/goregen v0.0.0-20160303162051-795b5e3961ea h1:Cyhwe github.com/zach-klippenstein/goregen v0.0.0-20160303162051-795b5e3961ea/go.mod h1:eNr558nEUjP8acGw8FFjTeWvSgU1stO7FAO6eknhHe4= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA= -go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= -go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1 h1:o8iWeVFa1BcLtVEV0LzrCxV2/55tB3xLxADr6Kyoey4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.23.1/go.mod h1:SEVfdK4IoBnbT2FXNM/k8yC08MrfbhWk3U4ljM8B3HE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1 h1:p3A5+f5l9e/kuEBwLOrnpkIDHQFlHmbiVxMURWRK6gQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.23.1/go.mod h1:OClrnXUjBqQbInvjJFjYSnMxBSCXBF8r3b34WqjiIrQ= -go.opentelemetry.io/otel/metric v1.23.1 h1:PQJmqJ9u2QaJLBOELl1cxIdPcpbwzbkjfEyelTl2rlo= -go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= -go.opentelemetry.io/otel/sdk v1.23.1 h1:O7JmZw0h76if63LQdsBMKQDWNb5oEcOThG9IrxscV+E= -go.opentelemetry.io/otel/sdk v1.23.1/go.mod h1:LzdEVR5am1uKOOwfBWFef2DCi1nu3SA8XQxx2IerWFk= -go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8= -go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= -go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= -go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.starlark.net v0.0.0-20240123142251-f86470692795 h1:LmbG8Pq7KDGkglKVn8VpZOZj6vb9b8nKEGcg9l03epM= go.starlark.net v0.0.0-20240123142251-f86470692795/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -351,8 +351,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -368,8 +368,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -393,15 +393,15 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -427,16 +427,16 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe h1:USL2DhxfgRchafRvt/wYyyQNzwgL7ZiURcozOE/Pkvo= -google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= -google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/src/cmd/evaluate/evaluate.go b/src/cmd/evaluate/evaluate.go index b3614523..fb520e92 100644 --- a/src/cmd/evaluate/evaluate.go +++ b/src/cmd/evaluate/evaluate.go @@ -2,11 +2,13 @@ package evaluate import ( "fmt" + "strings" "github.com/defenseunicorns/go-oscal/src/pkg/files" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/pkg/common" "github.com/defenseunicorns/lula/src/pkg/common/oscal" + "github.com/defenseunicorns/lula/src/pkg/common/result" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/spf13/cobra" ) @@ -20,7 +22,8 @@ To evaluate two results (threshold and latest) in a single OSCAL file: ` type flags struct { - files []string + files []string + summary bool } var opts = &flags{} @@ -39,18 +42,19 @@ var evaluateCmd = &cobra.Command{ message.Fatal(err, err.Error()) } - EvaluateAssessments(assessmentMap) + EvaluateAssessments(assessmentMap, opts.summary) }, } func EvaluateCommand() *cobra.Command { evaluateCmd.Flags().StringArrayVarP(&opts.files, "file", "f", []string{}, "Path to the file to be evaluated") + evaluateCmd.Flags().BoolVarP(&opts.summary, "summary", "s", false, "Print a summary of the evaluation") // insert flag options here return evaluateCmd } -func EvaluateAssessments(assessmentMap map[string]*oscalTypes_1_1_2.AssessmentResults) { +func EvaluateAssessments(assessmentMap map[string]*oscalTypes_1_1_2.AssessmentResults, summary bool) { // Identify the threshold & latest for comparison resultMap, err := oscal.IdentifyResults(assessmentMap) if err != nil { @@ -69,22 +73,41 @@ func EvaluateAssessments(assessmentMap map[string]*oscalTypes_1_1_2.AssessmentRe } if resultMap["threshold"] != nil && resultMap["latest"] != nil { + var findingsWithoutObservations []string // Compare the assessment results spinner := message.NewProgressSpinner("Evaluating Assessment Results %s against %s", resultMap["threshold"].UUID, resultMap["latest"].UUID) defer spinner.Stop() message.Debugf("threshold UUID: %s / latest UUID: %s", resultMap["threshold"].UUID, resultMap["latest"].UUID) - status, findings, err := oscal.EvaluateResults(resultMap["threshold"], resultMap["latest"]) + status, resultComparison, err := oscal.EvaluateResults(resultMap["threshold"], resultMap["latest"]) if err != nil { message.Fatal(err, err.Error()) } + // Print summary + if summary { + message.Info("Summary of All Observations:") + findingsWithoutObservations = result.Collapse(resultComparison).PrintObservationComparisonTable(false, true, false) + if len(findingsWithoutObservations) > 0 { + message.Warnf("%d Finding(s) Without Observations", len(findingsWithoutObservations)) + message.Info(strings.Join(findingsWithoutObservations, ", ")) + } + } + + // Check 'status' - Result if evaluation is passing or failing + // Fails if anything went from satisfied -> not-satisfied OR if any old findings are removed (doesn't matter whether they were satisfied or not) if status { - if len(findings["new-passing-findings"]) > 0 { + // Print new-passing-findings + newSatisfied := resultComparison["new-satisfied"] + nowSatisfied := resultComparison["now-satisfied"] + if len(newSatisfied) > 0 || len(nowSatisfied) > 0 { message.Info("New passing finding Target-Ids:") - for _, finding := range findings["new-passing-findings"] { - message.Infof("%s", finding.Target.TargetId) + for id := range newSatisfied { + message.Infof("%s", id) + } + for id := range nowSatisfied { + message.Infof("%s", id) } message.Infof("New threshold identified - threshold will be updated to result %s", resultMap["latest"].UUID) @@ -97,19 +120,33 @@ func EvaluateAssessments(assessmentMap map[string]*oscalTypes_1_1_2.AssessmentRe oscal.UpdateProps("threshold", "https://docs.lula.dev/ns", "true", resultMap["threshold"].Props) } - if len(findings["new-failing-findings"]) > 0 { + // Print new-not-satisfied + newFailing := resultComparison["new-not-satisfied"] + if len(newFailing) > 0 { message.Info("New failing finding Target-Ids:") - for _, finding := range findings["new-failing-findings"] { - message.Infof("%s", finding.Target.TargetId) + for id := range newFailing { + message.Infof("%s", id) } } - message.Info("Evaluation Passed Successfully") + message.Info("Evaluation Passed Successfully") } else { - message.Warn("Evaluation Failed against the following findings:") - for _, finding := range findings["no-longer-satisfied"] { - message.Warnf("%s", finding.Target.TargetId) + // Print no-longer-satisfied + message.Warn("Evaluation Failed against the following:") + + // Alternative printing in a single table + failedFindings := map[string]result.ResultComparisonMap{ + "no-longer-satisfied": resultComparison["no-longer-satisfied"], + "removed-satisfied": resultComparison["removed-satisfied"], + "removed-not-satisfied": resultComparison["removed-not-satisfied"], } + findingsWithoutObservations = result.Collapse(failedFindings).PrintObservationComparisonTable(true, false, true) + // handle controls that failed but didn't have observations + if len(findingsWithoutObservations) > 0 { + message.Warnf("%d Failed Finding(s) Without Observations", len(findingsWithoutObservations)) + message.Info(strings.Join(findingsWithoutObservations, ", ")) + } + message.Fatalf(fmt.Errorf("failed to meet established threshold"), "failed to meet established threshold") // retain result as threshold diff --git a/src/pkg/common/oscal/assessment-results.go b/src/pkg/common/oscal/assessment-results.go index 294b7ea5..2ad5fa18 100644 --- a/src/pkg/common/oscal/assessment-results.go +++ b/src/pkg/common/oscal/assessment-results.go @@ -9,6 +9,7 @@ import ( "github.com/defenseunicorns/go-oscal/src/pkg/uuid" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/config" + "github.com/defenseunicorns/lula/src/pkg/common/result" "gopkg.in/yaml.v3" ) @@ -116,14 +117,6 @@ func MergeAssessmentResults(original *oscalTypes_1_1_2.AssessmentResults, latest return original, nil } -func GenerateFindingsMap(findings []oscalTypes_1_1_2.Finding) map[string]oscalTypes_1_1_2.Finding { - findingsMap := make(map[string]oscalTypes_1_1_2.Finding) - for _, finding := range findings { - findingsMap[finding.Target.TargetId] = finding - } - return findingsMap -} - // IdentifyResults produces a map containing the threshold result and a result used for comparison func IdentifyResults(assessmentMap map[string]*oscalTypes_1_1_2.AssessmentResults) (map[string]*oscalTypes_1_1_2.Result, error) { resultMap := make(map[string]*oscalTypes_1_1_2.Result) @@ -178,58 +171,83 @@ func IdentifyResults(assessmentMap map[string]*oscalTypes_1_1_2.AssessmentResult } } -func EvaluateResults(thresholdResult *oscalTypes_1_1_2.Result, newResult *oscalTypes_1_1_2.Result) (bool, map[string][]oscalTypes_1_1_2.Finding, error) { +func EvaluateResults(thresholdResult *oscalTypes_1_1_2.Result, newResult *oscalTypes_1_1_2.Result) (bool, map[string]result.ResultComparisonMap, error) { + var status bool = true + if thresholdResult.Findings == nil || newResult.Findings == nil { return false, nil, fmt.Errorf("results must contain findings to evaluate") } - // Store unique findings for review here - findings := make(map[string][]oscalTypes_1_1_2.Finding, 0) - result := true - - findingMapThreshold := GenerateFindingsMap(*thresholdResult.Findings) - findingMapNew := GenerateFindingsMap(*newResult.Findings) - - // For a given oldResult - we need to prove that the newResult implements all of the oldResult findings/controls - // We are explicitly iterating through the findings in order to collect a delta to display - - for targetId, finding := range findingMapThreshold { - if _, ok := findingMapNew[targetId]; !ok { - // If the new result does not contain the finding of the old result - // set result to fail, add finding to the findings map and continue - result = false - findings[targetId] = append(findings["no-longer-satisfied"], finding) - } else { - // If the finding is present in each map - we need to check if the state has changed from "not-satisfied" to "satisfied" - if finding.Target.Status.State == "satisfied" { - // Was previously satisfied - compare state - if findingMapNew[targetId].Target.Status.State == "not-satisfied" { - // If the new finding is now not-satisfied - set result to false and add to findings - result = false - findings["no-longer-satisfied"] = append(findings["no-longer-satisfied"], finding) - } - } else { - // was previously not-satisfied but now is satisfied - if findingMapNew[targetId].Target.Status.State == "satisfied" { - // If the new finding is now satisfied - add to new-passing-findings - findings["new-passing-findings"] = append(findings["new-passing-findings"], finding) - } - } - delete(findingMapNew, targetId) - } + // Compare threshold result to new result and vice versa + comparedToThreshold := result.NewResultComparisonMap(*newResult, *thresholdResult) + + // Group by categories + categories := []struct { + name string + stateChange result.StateChange + satisfied bool + status bool + }{ + { + name: "new-satisfied", + stateChange: result.NEW, + satisfied: true, + status: true, + }, + { + name: "new-not-satisfied", + stateChange: result.NEW, + satisfied: false, + status: true, + }, + { + name: "no-longer-satisfied", + stateChange: result.SATISFIED_TO_NOT_SATISFIED, + satisfied: false, + status: false, + }, + { + name: "now-satisfied", + stateChange: result.NOT_SATISFIED_TO_SATISFIED, + satisfied: true, + status: true, + }, + { + name: "unchanged-not-satisfied", + stateChange: result.UNCHANGED, + satisfied: false, + status: true, + }, + { + name: "unchanged-satisfied", + stateChange: result.UNCHANGED, + satisfied: true, + status: true, + }, + { + name: "removed-not-satisfied", + stateChange: result.REMOVED, + satisfied: false, + status: false, + }, + { + name: "removed-satisfied", + stateChange: result.REMOVED, + satisfied: true, + status: false, + }, } - // All remaining findings in the new map are new findings - for _, finding := range findingMapNew { - if finding.Target.Status.State == "satisfied" { - findings["new-passing-findings"] = append(findings["new-passing-findings"], finding) - } else { - findings["new-failing-findings"] = append(findings["new-failing-findings"], finding) + categorizedResultComparisons := make(map[string]result.ResultComparisonMap) + for _, c := range categories { + results := result.GetResultComparisonMap(comparedToThreshold, c.stateChange, c.satisfied) + categorizedResultComparisons[c.name] = results + if len(results) > 0 && !c.status { + status = false } - } - return result, findings, nil + return status, categorizedResultComparisons, nil } func MakeAssessmentResultsDeterministic(assessment *oscalTypes_1_1_2.AssessmentResults) { diff --git a/src/pkg/common/oscal/assessment-results_test.go b/src/pkg/common/oscal/assessment-results_test.go index 3155ff38..1f9762a7 100644 --- a/src/pkg/common/oscal/assessment-results_test.go +++ b/src/pkg/common/oscal/assessment-results_test.go @@ -300,7 +300,7 @@ func TestIdentifyResults(t *testing.T) { t.Fatalf("Expected results to be evaluated as failing") } - if len(findings["new-passing-findings"]) == 0 { + if len(findings["now-satisfied"]) == 0 { t.Fatalf("Expected new passing findings to be found") } }) @@ -464,7 +464,7 @@ func TestEvaluateResultsNewFindings(t *testing.T) { t.Fatal("error - evaluation failed") } - if len(findings["new-passing-findings"]) != 1 { + if len(findings["new-satisfied"]) != 1 { t.Fatal("error - expected 1 new finding, got ", len(findings["new-passing-findings"])) } diff --git a/src/pkg/common/result/observation-pair.go b/src/pkg/common/result/observation-pair.go new file mode 100644 index 00000000..d485aa0a --- /dev/null +++ b/src/pkg/common/result/observation-pair.go @@ -0,0 +1,140 @@ +package result + +import ( + "strings" + + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" +) + +type ObservationPair struct { + StateChange StateChange + Satisfied bool + Name string + Observation string + ComparedObservation string +} + +// CreateObservationPairs creates a slice of observation pairs from a slice of observations and compared observations +func CreateObservationPairs(observations []*oscalTypes_1_1_2.Observation, comparedObservations []*oscalTypes_1_1_2.Observation) []*ObservationPair { + observationPairs := make([]*ObservationPair, 0) + + // Add all observations to the observation pairs + for _, observation := range observations { + comparedObservation := findObservation(observation, comparedObservations) + observationPair := newObservationPair(observation, comparedObservation) + observationPairs = append(observationPairs, observationPair) + } + + // Add all compared observations that are not in the observations + for _, comparedObservation := range comparedObservations { + observation := findObservation(comparedObservation, observations) + if observation == nil { + observationPair := newObservationPair(nil, comparedObservation) + observationPairs = append(observationPairs, observationPair) + } + } + return observationPairs +} + +// NewObservationPair -> create a new observation pair from a given observation and slice of comparedObservations +func newObservationPair(observation *oscalTypes_1_1_2.Observation, comparedObservation *oscalTypes_1_1_2.Observation) *ObservationPair { + // Calculate the state change + var state StateChange + var result bool + var observationRemarks, comparedObservationRemarks, name string + prefix := "[TEST]: " + + if observation != nil { + name = strings.TrimPrefix(observation.Description, prefix) + observationRemarks = getRemarks(observation.RelevantEvidence) + result = getObservationResult(observation.RelevantEvidence) + if comparedObservation == nil { + state = NEW + } else { + comparedObservationRemarks = getRemarks(comparedObservation.RelevantEvidence) + state = getStateChange(observation, comparedObservation) + } + } else { + if comparedObservation != nil { + name = strings.TrimPrefix(comparedObservation.Description, prefix) + comparedObservationRemarks = getRemarks(comparedObservation.RelevantEvidence) + state = REMOVED + } else { + state = UNCHANGED + } + } + + return &ObservationPair{ + StateChange: state, + Satisfied: result, + Name: name, + Observation: observationRemarks, + ComparedObservation: comparedObservationRemarks, + } +} + +// findObservation finds an observation in a slice of observations +func findObservation(observation *oscalTypes_1_1_2.Observation, observations []*oscalTypes_1_1_2.Observation) *oscalTypes_1_1_2.Observation { + for _, comparedObservation := range observations { + if observation.Description == comparedObservation.Description { + return comparedObservation + } + } + return nil +} + +// getStateChange compares the relevant evidence of two observations and calculates the state change between the two +func getStateChange(observation *oscalTypes_1_1_2.Observation, comparedObservation *oscalTypes_1_1_2.Observation) StateChange { + var state StateChange = UNCHANGED + relevantEvidence := observation.RelevantEvidence + comparedRelevantEvidence := comparedObservation.RelevantEvidence + + if relevantEvidence == nil { + if comparedRelevantEvidence != nil { + state = REMOVED + } + } else { + if comparedRelevantEvidence == nil { + state = NEW + } else { + state = compareRelevantEvidence(relevantEvidence, comparedRelevantEvidence) + } + } + + return state +} + +func compareRelevantEvidence(relevantEvidence *[]oscalTypes_1_1_2.RelevantEvidence, comparedRelevantEvidence *[]oscalTypes_1_1_2.RelevantEvidence) StateChange { + var state StateChange = UNCHANGED + + reResults := getObservationResult(relevantEvidence) + compReResults := getObservationResult(comparedRelevantEvidence) + + if reResults && !compReResults { + state = NOT_SATISFIED_TO_SATISFIED + } else if !reResults && compReResults { + state = SATISFIED_TO_NOT_SATISFIED + } + + return state +} + +func getObservationResult(relevantEvidence *[]oscalTypes_1_1_2.RelevantEvidence) bool { + var satisfied bool + if relevantEvidence != nil { + for _, re := range *relevantEvidence { + if !strings.Contains(re.Description, "not-satisfied") { + satisfied = true + } + } + } + return satisfied +} + +func getRemarks(relevantEvidence *[]oscalTypes_1_1_2.RelevantEvidence) string { + var remarks string + if relevantEvidence != nil { + remarks = (*relevantEvidence)[0].Remarks + } + return remarks +} diff --git a/src/pkg/common/result/observation-pair_test.go b/src/pkg/common/result/observation-pair_test.go new file mode 100644 index 00000000..8f196d73 --- /dev/null +++ b/src/pkg/common/result/observation-pair_test.go @@ -0,0 +1,116 @@ +package result_test + +import ( + "fmt" + "testing" + + "github.com/defenseunicorns/go-oscal/src/pkg/uuid" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/common/result" +) + +func createObservation(description, satisfaction string) *oscalTypes_1_1_2.Observation { + return &oscalTypes_1_1_2.Observation{ + UUID: uuid.NewUUID(), + Description: description, + RelevantEvidence: &[]oscalTypes_1_1_2.RelevantEvidence{ + { + Description: fmt.Sprintf("Result: %s", satisfaction), + Remarks: "Some remarks about this observation", + }, + }, + } +} + +func TestCreateObservationPairs(t *testing.T) { + // tests different variations of observation pairs + tests := []struct { + name string + observations []*oscalTypes_1_1_2.Observation + compareObservations []*oscalTypes_1_1_2.Observation + expectedPairs int + expectedStateChange map[string]result.StateChange + }{ + { + name: "One observation pair, not satisfied to satisfied", + observations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "satisfied"), + }, + compareObservations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "not-satisfied"), + }, + expectedPairs: 1, + expectedStateChange: map[string]result.StateChange{ + "test-1": result.NOT_SATISFIED_TO_SATISFIED, + }, + }, + { + name: "One observation pair, satisfied to not-satisfied", + observations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "not-satisfied"), + }, + compareObservations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "satisfied"), + }, + expectedPairs: 1, + expectedStateChange: map[string]result.StateChange{ + "test-1": result.SATISFIED_TO_NOT_SATISFIED, + }, + }, + { + name: "Two observation pairs", + observations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "satisfied"), + }, + compareObservations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "satisfied"), + createObservation("test-2", "not-satisfied"), + }, + expectedPairs: 2, + expectedStateChange: map[string]result.StateChange{ + "test-1": result.UNCHANGED, + "test-2": result.REMOVED, + }, + }, + { + name: "Three observation pairs", + observations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-1", "satisfied"), + createObservation("test-3", "not-satisfied"), + }, + compareObservations: []*oscalTypes_1_1_2.Observation{ + createObservation("test-2", "not-satisfied"), + createObservation("test-3", "not-satisfied"), + }, + expectedPairs: 3, + expectedStateChange: map[string]result.StateChange{ + "test-1": result.NEW, + "test-2": result.REMOVED, + "test-3": result.UNCHANGED, + }, + }, + { + name: "No observation pairs", + observations: []*oscalTypes_1_1_2.Observation{}, + compareObservations: []*oscalTypes_1_1_2.Observation{}, + expectedPairs: 0, + expectedStateChange: map[string]result.StateChange{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + observationPairs := result.CreateObservationPairs(tt.observations, tt.compareObservations) + + if len(observationPairs) != tt.expectedPairs { + t.Errorf("Expected %d pairs, but got %d pairs", tt.expectedPairs, len(observationPairs)) + } + + for _, op := range observationPairs { + if op.StateChange != tt.expectedStateChange[op.Name] { + t.Errorf("Expected %s, but got %s", tt.expectedStateChange[op.Name], op.StateChange) + } + } + }) + } +} diff --git a/src/pkg/common/result/result-comparison.go b/src/pkg/common/result/result-comparison.go new file mode 100644 index 00000000..10615f3c --- /dev/null +++ b/src/pkg/common/result/result-comparison.go @@ -0,0 +1,277 @@ +package result + +import ( + "strconv" + "strings" + + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/message" +) + +type StateChange string + +const ( + NOT_SATISFIED_TO_SATISFIED StateChange = "NOT SATISFIED TO SATISFIED" + SATISFIED_TO_NOT_SATISFIED StateChange = "SATISFIED TO NOT SATISFIED" + NEW StateChange = "NEW" + REMOVED StateChange = "REMOVED" + UNCHANGED StateChange = "UNCHANGED" +) + +type ResultComparison struct { + StateChange StateChange + Satisfied bool + Finding *oscalTypes_1_1_2.Finding + ComparedFinding *oscalTypes_1_1_2.Finding + ObservationPairs []*ObservationPair +} + +// PrintResultComparisonTable prints a table output of compared results +func (r ResultComparison) PrintResultComparisonTable(changedOnly bool) { + header := []string{"Observation", "Satisfied", "Change", "New Remarks", "Threshold Remarks"} + rows := make([][]string, 0) + columnSize := []int{20, 10, 15, 25, 30} + + for _, observationPair := range r.ObservationPairs { + if changedOnly && observationPair.StateChange == UNCHANGED { + continue + } + + rows = append(rows, []string{ + observationPair.Name, + convertSatisfied(observationPair.Satisfied, observationPair.StateChange), + string(observationPair.StateChange), + observationPair.Observation, + observationPair.ComparedObservation, + }) + } + if len(rows) != 0 { + message.Table(header, rows, columnSize) + } +} + +type ResultComparisonMap map[string]ResultComparison + +// PrintObservationComparisonTable prints a table output of compared observations, per control +func (rm ResultComparisonMap) PrintObservationComparisonTable(changedOnly bool, skipRemoved bool, failedOnly bool) []string { + header := []string{"Control ID(s)", "Observation", "Satisfied", "Change", "New Remarks", "Threshold Remarks"} + rows := make([][]string, 0) + columnSize := []int{10, 20, 5, 15, 25, 25} + + // map[string]ObservationPairs (observationUUIDs -> observationPairs), map[string][]string (observationUUIDs -> controlIds) + observationPairMap, controlObservationMap, noObservations := RefactorObservationsByControls(rm) + + for id, observationPair := range observationPairMap { + if changedOnly && observationPair.StateChange == UNCHANGED { + continue + } + if skipRemoved && observationPair.StateChange == REMOVED { + continue + } + if failedOnly && observationPair.Satisfied { + continue + } + controlIds := strings.Join(controlObservationMap[id], ", ") + rows = append(rows, []string{ + controlIds, + observationPair.Name, + convertSatisfied(observationPair.Satisfied, observationPair.StateChange), + string(observationPair.StateChange), + observationPair.Observation, + observationPair.ComparedObservation, + }) + } + if len(rows) != 0 { + message.Table(header, rows, columnSize) + } + + return noObservations +} + +// NewResultComparisonMap -> create a map of result comparisons from two OSCAL results +func NewResultComparisonMap(result oscalTypes_1_1_2.Result, comparedResult oscalTypes_1_1_2.Result) map[string]ResultComparison { + findingMap := generateFindingMap(*result.Findings) + comparedFindingMap := generateFindingMap(*comparedResult.Findings) + + relatedObservationsMap := make(map[string][]*oscalTypes_1_1_2.Observation) + comparedRelatedObservationsMap := make(map[string][]*oscalTypes_1_1_2.Observation) + resultComparisonMap := make(map[string]ResultComparison) + + if result.Observations != nil { + relatedObservationsMap = generateRelatedObservationsMap(findingMap, generateObservationMap(*result.Observations)) + } + if comparedResult.Observations != nil { + comparedRelatedObservationsMap = generateRelatedObservationsMap(comparedFindingMap, generateObservationMap(*comparedResult.Observations)) + } + + for targetId, finding := range findingMap { + comparedFinding, found := comparedFindingMap[targetId] + if !found { + // Capture new findings that were not found in the compared findings + resultComparisonMap[targetId] = newResultComparison(finding, nil, relatedObservationsMap[targetId], nil) + } else { + // Both findings exist, compare them + resultComparisonMap[targetId] = newResultComparison(finding, comparedFinding, relatedObservationsMap[targetId], comparedRelatedObservationsMap[targetId]) + } + } + + for targetId, comparedFinding := range comparedFindingMap { + _, found := findingMap[targetId] + if !found { + // Capture compared findings that were removed/missing from result + resultComparisonMap[targetId] = newResultComparison(nil, comparedFinding, nil, comparedRelatedObservationsMap[targetId]) + } + } + + return resultComparisonMap +} + +// GetResultComparisonMap gets the result comparison category from the result comparison map +func GetResultComparisonMap(resultComparisonMap map[string]ResultComparison, stateChange StateChange, satisfied bool) ResultComparisonMap { + ResultComparisonMap := make(ResultComparisonMap) + for targetId, resultComparison := range resultComparisonMap { + if resultComparison.StateChange == stateChange && resultComparison.Satisfied == satisfied { + ResultComparisonMap[targetId] = resultComparison + } + } + return ResultComparisonMap +} + +// Collapse map[string]ResultComparisonMap to single ResultComparisonMap +// ** Note this function assumes all unique entities in each ResultComparisonMap +func Collapse(mapResultComparisonMap map[string]ResultComparisonMap) ResultComparisonMap { + resultComparisonMap := make(ResultComparisonMap) + for _, v := range mapResultComparisonMap { + for k, v := range v { + resultComparisonMap[k] = v + } + } + return resultComparisonMap +} + +// Refactor observations by controls +func RefactorObservationsByControls(ResultComparisonMap ResultComparisonMap) (map[string]ObservationPair, map[string][]string, []string) { + // for each category, add the observationpair and add controlId + observationPairMap := make(map[string]ObservationPair) + controlObservationMap := make(map[string][]string) + noObservations := make([]string, 0) + + for targetId, r := range ResultComparisonMap { + for _, o := range r.ObservationPairs { + observationPairMap[o.Name] = *o + controlObservationMap[o.Name] = append(controlObservationMap[o.Name], targetId) + } + if len(r.ObservationPairs) == 0 { + noObservations = append(noObservations, targetId) + } + } + + return observationPairMap, controlObservationMap, noObservations +} + +// newResultComparison create new result comparison from two findings +func newResultComparison(finding *oscalTypes_1_1_2.Finding, comparedFinding *oscalTypes_1_1_2.Finding, relatedObservations []*oscalTypes_1_1_2.Observation, comparedRelatedObservations []*oscalTypes_1_1_2.Observation) ResultComparison { + var state StateChange + var satisfied bool + observationPairs := CreateObservationPairs(relatedObservations, comparedRelatedObservations) + + if comparedFinding == nil { + state = NEW + satisfied = finding.Target.Status.State == "satisfied" + } else if finding == nil { + state = REMOVED + } else { + state = compareFindings(finding, comparedFinding) + satisfied = finding.Target.Status.State == "satisfied" + } + + resultComparison := ResultComparison{ + StateChange: state, + Satisfied: satisfied, + Finding: finding, + ComparedFinding: comparedFinding, + ObservationPairs: observationPairs, + } + return resultComparison +} + +// generateFindingMap creates a finding map on the TargetId +// ** Note: this assumes 1:1 relationship between targetId and finding +func generateFindingMap(findings []oscalTypes_1_1_2.Finding) map[string]*oscalTypes_1_1_2.Finding { + findingMap := make(map[string]*oscalTypes_1_1_2.Finding, len(findings)) + for i := range findings { + finding := &findings[i] + findingMap[finding.Target.TargetId] = finding + } + return findingMap +} + +// generateObservationMap creates observations map on a slice of observations +func generateObservationMap(observations []oscalTypes_1_1_2.Observation) map[string]*oscalTypes_1_1_2.Observation { + observationMap := make(map[string]*oscalTypes_1_1_2.Observation, len(observations)) + + for i := range observations { + observation := &observations[i] + observationMap[observation.UUID] = observation + } + + return observationMap +} + +// generateRelatedObservationsMap creates observations map on the TargetId from the findingMap and observationMap +// ** Note: this assumes 1:1 relationship between targetId and finding +func generateRelatedObservationsMap(findingMap map[string]*oscalTypes_1_1_2.Finding, observationMap map[string]*oscalTypes_1_1_2.Observation) map[string][]*oscalTypes_1_1_2.Observation { + relatedObservationsMap := make(map[string][]*oscalTypes_1_1_2.Observation, len(findingMap)) + + for i := range findingMap { + relatedObservations := findingMap[i].RelatedObservations + observations := make([]*oscalTypes_1_1_2.Observation, 0) + if relatedObservations != nil { + for _, relatedObservation := range *relatedObservations { + if observation, ok := observationMap[relatedObservation.ObservationUuid]; ok { + if observation != nil { + observations = append(observations, observation) + } + } + } + } + relatedObservationsMap[i] = observations + } + + return relatedObservationsMap +} + +// compareFindings compares the target.status.state of two findings and calculates the state change between the two +func compareFindings(finding *oscalTypes_1_1_2.Finding, comparedFinding *oscalTypes_1_1_2.Finding) StateChange { + var state StateChange = UNCHANGED + + if finding == nil { + if comparedFinding != nil { + state = REMOVED + } + } else { + if comparedFinding == nil { + state = NEW + } else { + status := finding.Target.Status.State + comparedStatus := comparedFinding.Target.Status.State + + if status == "not-satisfied" && comparedStatus == "satisfied" { + state = SATISFIED_TO_NOT_SATISFIED + } else if status == "satisfied" && comparedStatus == "not-satisfied" { + state = NOT_SATISFIED_TO_SATISFIED + } + } + } + + return state +} + +// convertSatisfied converts the satisfied boolean to a string +func convertSatisfied(satisfied bool, stateChange StateChange) string { + if stateChange == REMOVED { + return "N/A" + } else { + return strconv.FormatBool(satisfied) + } +} diff --git a/src/pkg/common/result/result-comparison_test.go b/src/pkg/common/result/result-comparison_test.go new file mode 100644 index 00000000..0abc7680 --- /dev/null +++ b/src/pkg/common/result/result-comparison_test.go @@ -0,0 +1,212 @@ +package result_test + +import ( + "fmt" + "testing" + + "github.com/defenseunicorns/go-oscal/src/pkg/uuid" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/common/result" +) + +func createTestResult(findingId, observationId, findingState, observationSatisfaction string) oscalTypes_1_1_2.Result { + observationUuid := uuid.NewUUID() + return oscalTypes_1_1_2.Result{ + Findings: &[]oscalTypes_1_1_2.Finding{ + { + Target: oscalTypes_1_1_2.FindingTarget{ + TargetId: findingId, + Status: oscalTypes_1_1_2.ObjectiveStatus{ + State: findingState, + }, + }, + RelatedObservations: &[]oscalTypes_1_1_2.RelatedObservation{ + { + ObservationUuid: observationUuid, + }, + }, + }, + }, + Observations: &[]oscalTypes_1_1_2.Observation{ + { + UUID: observationUuid, + Description: observationId, + RelevantEvidence: &[]oscalTypes_1_1_2.RelevantEvidence{ + { + Description: fmt.Sprintf("Result: %s", observationSatisfaction), + Remarks: "Some remarks about this observation", + }, + }, + }, + }, + } +} + +func createTestResultNoObs(findingId, findingState string) oscalTypes_1_1_2.Result { + return oscalTypes_1_1_2.Result{ + Findings: &[]oscalTypes_1_1_2.Finding{ + { + Target: oscalTypes_1_1_2.FindingTarget{ + TargetId: findingId, + Status: oscalTypes_1_1_2.ObjectiveStatus{ + State: findingState, + }, + }, + }, + }, + } +} + +// Helper function to check if a slice contains a specific string +func contains(slice []string, item string) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} + +func TestGetResultComparisonMap(t *testing.T) { + // Tests both creating a results comparison map and testing getting the right comparisons + tests := []struct { + name string + thresholdResult oscalTypes_1_1_2.Result + result oscalTypes_1_1_2.Result + expectedStateChange result.StateChange + expectedSatisfaction bool + expectedId string + }{ + { + name: "Unchanged, satisfied result", + thresholdResult: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + result: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + expectedStateChange: result.UNCHANGED, + expectedSatisfaction: true, + expectedId: "id-1", + }, + { + name: "Changed, not satisfied to satisfied", + thresholdResult: createTestResult("id-1", "test-1", "not-satisfied", "not-satisfied"), + result: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + expectedStateChange: result.NOT_SATISFIED_TO_SATISFIED, + expectedSatisfaction: true, + expectedId: "id-1", + }, + { + name: "Changed, satisfied to not-satisfied", + thresholdResult: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + result: createTestResult("id-1", "test-1", "not-satisfied", "not-satisfied"), + expectedStateChange: result.SATISFIED_TO_NOT_SATISFIED, + expectedSatisfaction: false, + expectedId: "id-1", + }, + { + name: "Removed finding, satisfied", + thresholdResult: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + result: createTestResult("id-2", "test-2", "satisfied", "satisfied"), + expectedStateChange: result.REMOVED, + expectedSatisfaction: false, // this is not-satisfied because it was removed, even though it was originally satisfied + expectedId: "id-1", + }, + { + name: "New finding, satisfied", + thresholdResult: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + result: createTestResult("id-2", "test-2", "satisfied", "satisfied"), + expectedStateChange: result.NEW, + expectedSatisfaction: true, + expectedId: "id-2", + }, + { + name: "Removed finding, not-satisfied", + thresholdResult: createTestResult("id-1", "test-1", "not-satisfied", "not-satisfied"), + result: createTestResult("id-2", "test-2", "satisfied", "satisfied"), + expectedStateChange: result.REMOVED, + expectedSatisfaction: false, + expectedId: "id-1", + }, + { + name: "New finding, not-satisfied", + thresholdResult: createTestResult("id-1", "test-1", "satisfied", "satisfied"), + result: createTestResult("id-2", "test-2", "not-satisfied", "not-satisfied"), + expectedStateChange: result.NEW, + expectedSatisfaction: false, + expectedId: "id-2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resultComparisonMap := result.NewResultComparisonMap(tt.result, tt.thresholdResult) + subSetMap := result.GetResultComparisonMap(resultComparisonMap, tt.expectedStateChange, tt.expectedSatisfaction) + + if len(subSetMap) == 0 { + t.Error("Expected subset populated, but it's empty") + } + if len(subSetMap) != 1 { + t.Errorf("Expected subset to have 1 element, but has %d", len(subSetMap)) + } + for id := range subSetMap { + if id != tt.expectedId { + t.Errorf("Expected id %s, but got %s", tt.expectedId, id) + } + } + }) + } +} + +func TestRefactorObservationsByControls(t *testing.T) { + // create a bunch of result-comparisons for each ID... + result1 := createTestResult("id-1", "test-1", "satisfied", "satisfied") + thresholdResult1 := createTestResult("id-1", "test-1", "satisfied", "satisfied") + resultComparisonMap1 := result.NewResultComparisonMap(result1, thresholdResult1) + + result2 := createTestResult("id-2", "test-1", "satisfied", "satisfied") + thresholdResult2 := createTestResult("id-2", "test-1", "not-satisfied", "not-satisfied") + resultComparisonMap2 := result.NewResultComparisonMap(result2, thresholdResult2) + + result3 := createTestResult("id-3", "test-2", "satisfied", "satisfied") + thresholdResult3 := createTestResult("id-4", "test-2", "not-satisfied", "not-satisfied") + resultComparisonMap3 := result.NewResultComparisonMap(result3, thresholdResult3) + + result4 := createTestResultNoObs("id-5", "satisfied") + thresholdResult4 := createTestResultNoObs("id-5", "not-satisfied") + resultComparisonMap4 := result.NewResultComparisonMap(result4, thresholdResult4) + + mapResultComparionMaps := map[string]result.ResultComparisonMap{ + "unchanged": resultComparisonMap1, + "changed": resultComparisonMap2, + "new-and-removed": resultComparisonMap3, + "no-observations": resultComparisonMap4, + } + + collapsedMap := result.Collapse(mapResultComparionMaps) + observationPairMap, controlObservationMap, noObservations := result.RefactorObservationsByControls(collapsedMap) + + if len(observationPairMap) != 2 { + t.Errorf("Expected 2 observation pairs, but got %d", len(observationPairMap)) + } + for id := range observationPairMap { + controls, ok := controlObservationMap[id] + if !ok { + t.Error("Expected controls to be in controlObservationMap, but it's not", id) + } + // check the observations are mapped to the controls correctly + if id == "test-1" { + if !contains(controls, "id-1") || !contains(controls, "id-2") { + t.Errorf("Expected test-1 to contain id-1 and id-2, but got %v", controls) + } + } + if id == "test-2" { + if !contains(controls, "id-3") || !contains(controls, "id-4") { + t.Errorf("Expected test-2 to contain id-3 and id-4, but got %v", controls) + } + } + } + if len(noObservations) != 1 { + t.Errorf("Expected 1 no observation, but got %d", len(noObservations)) + } + if !contains(noObservations, "id-5") { + t.Errorf("Expected id-5 to be in no observations, but it's not") + } +} diff --git a/src/pkg/message/message.go b/src/pkg/message/message.go index ac99681d..b479689e 100644 --- a/src/pkg/message/message.go +++ b/src/pkg/message/message.go @@ -189,6 +189,19 @@ func Infof(format string, a ...any) { } } +// Detail prints detail message. +func Detail(message string) { + Detailf("%s", message) +} + +// Detailf prints a detail message preserving newlines +func Detailf(format string, a ...any) { + if logLevel > 0 { + message := fmt.Sprintf(format, a...) + pterm.Info.Println(message) + } +} + // Success prints a success message. func Success(message string) { Successf("%s", message) @@ -305,11 +318,17 @@ func Truncate(text string, length int, invert bool) string { } // Table prints a padded table containing the specified header and data -func Table(header []string, data [][]string) { +// Note - columnSize should be an array of ints that add up to 100 +func Table(header []string, data [][]string, columnSize []int) { pterm.Println() + termWidth := pterm.GetTerminalWidth() - 10 // Subtract 10 for padding - if len(header) > 0 { - header[0] = fmt.Sprintf(" %s", header[0]) + if len(columnSize) != len(header) { + Warn("The number of columns does not match the number of headers") + columnSize = make([]int, len(header)) + for i := range columnSize { + columnSize[i] = (len(header) / termWidth) * 100 // make them all equal + } } table := pterm.TableData{ @@ -317,13 +336,72 @@ func Table(header []string, data [][]string) { } for _, row := range data { - if len(row) > 0 { - row[0] = fmt.Sprintf(" %s", row[0]) + for i, cell := range row { + row[i] = addLineBreaks(strings.Replace(cell, "\n", " ", -1), (columnSize[i]*termWidth)/100) } table = append(table, pterm.TableData{row}...) } - pterm.DefaultTable.WithHasHeader().WithData(table).Render() + pterm.DefaultTable.WithHasHeader().WithData(table).WithRowSeparator("-").Render() +} + +// Add line breaks for table + +func addLineBreaks(input string, maxLineLength int) string { + // words := splitWords(input) // Split the input into words, handling hyphens + words := strings.Fields(input) + var result strings.Builder // Use a strings.Builder for efficient string concatenation + currentLineLength := 0 + + for _, word := range words { + if currentLineLength+len(word) > maxLineLength { + // additionally split the word if it contains a hyphen + firstPart, secondPart := splitHyphenedWords(word, currentLineLength, maxLineLength) + if firstPart != "" { + if currentLineLength > 0 { + result.WriteString(" ") + } + result.WriteString(firstPart + "-") + } + result.WriteString("\n") + currentLineLength = 0 + + if secondPart != "" { + word = secondPart + } + } + if currentLineLength > 0 { + result.WriteString(" ") + currentLineLength++ + } + result.WriteString(word) + currentLineLength += len(word) + } + + return result.String() +} + +func splitHyphenedWords(input string, currentLength int, maxLength int) (firstPart string, secondPart string) { + // get the indicies of all the hyphens + hyphenIndicies := []int{} + for i, char := range input { + if char == '-' { + hyphenIndicies = append(hyphenIndicies, i) + } + } + + if len(hyphenIndicies) != 0 { + // starting from the last index, find the largest firstPart that fits within the maxLength + for i := len(hyphenIndicies) - 1; i >= 0; i-- { + hyphenIndex := hyphenIndicies[i] + firstPart = input[:hyphenIndex] + secondPart = input[hyphenIndex+1:] + if len(firstPart)+currentLength <= maxLength { + return firstPart, secondPart + } + } + } + return "", input } func debugPrinter(offset int, a ...any) {