diff --git a/internal/evaluation_target/application_snapshot_image/__snapshots__/application_snapshot_image_test.snap b/internal/evaluation_target/application_snapshot_image/__snapshots__/application_snapshot_image_test.snap index 03cb67c68..6776396ad 100755 --- a/internal/evaluation_target/application_snapshot_image/__snapshots__/application_snapshot_image_test.snap +++ b/internal/evaluation_target/application_snapshot_image/__snapshots__/application_snapshot_image_test.snap @@ -11,7 +11,7 @@ } --- -[TestWriteInputFile/single_attestations - 1] +[TestBuildInput/single_attestations - 1] { "attestations": [ { @@ -55,7 +55,7 @@ } --- -[TestWriteInputFile/multiple_attestations - 1] +[TestBuildInput/multiple_attestations - 1] { "attestations": [ { @@ -115,7 +115,7 @@ } --- -[TestWriteInputFile/image_signatures - 1] +[TestBuildInput/image_signatures - 1] { "attestations": [ { @@ -173,7 +173,7 @@ } --- -[TestWriteInputFile/image_config - 1] +[TestBuildInput/image_config - 1] { "attestations": null, "image": { @@ -205,7 +205,7 @@ } --- -[TestWriteInputFile/parent_image_config - 1] +[TestBuildInput/parent_image_config - 1] { "attestations": null, "image": { @@ -240,7 +240,7 @@ } --- -[TestWriteInputFile/attestation_with_signature - 1] +[TestBuildInput/attestation_with_signature - 1] { "attestations": [ { @@ -300,51 +300,7 @@ } --- -[TestWriteInputFileMultipleAttestations - 1] -{ - "attestations": [ - { - "statement": { - "_type": "https://in-toto.io/Statement/v0.1", - "predicate": { - "buildType": "https://tekton.dev/attestations/chains/pipelinerun@v2", - "builder": { - "id": "" - }, - "invocation": { - "configSource": {} - } - }, - "predicateType": "https://slsa.dev/provenance/v0.2", - "subject": null - } - } - ], - "image": { - "ref": "registry.io/repository/image:tag", - "source": {} - }, - "policy_spec": {}, - "snapshot": { - "application": "", - "artifacts": {}, - "components": [ - { - "containerImage": "registry.io/repository/image:tag", - "name": "", - "source": {} - }, - { - "containerImage": "registry.io/other-repository/image2:tag", - "name": "", - "source": {} - } - ] - } -} ---- - -[TestWriteInputFile/component_with_source - 1] +[TestBuildInput/component_with_source - 1] { "attestations": null, "image": { @@ -376,7 +332,7 @@ } --- -[TestWriteInputFile/component_name_in_input - 1] +[TestBuildInput/component_name_in_input - 1] { "attestations": null, "component_name": "my-component", @@ -404,7 +360,7 @@ } --- -[TestWriteInputFile/policy_spec_with_volatile_config - 1] +[TestBuildInput/policy_spec_with_volatile_config - 1] { "attestations": null, "component_name": "test-component", @@ -452,3 +408,47 @@ } } --- + +[TestBuildInputMultipleAttestations - 1] +{ + "attestations": [ + { + "statement": { + "_type": "https://in-toto.io/Statement/v0.1", + "predicate": { + "buildType": "https://tekton.dev/attestations/chains/pipelinerun@v2", + "builder": { + "id": "" + }, + "invocation": { + "configSource": {} + } + }, + "predicateType": "https://slsa.dev/provenance/v0.2", + "subject": null + } + } + ], + "image": { + "ref": "registry.io/repository/image:tag", + "source": {} + }, + "policy_spec": {}, + "snapshot": { + "application": "", + "artifacts": {}, + "components": [ + { + "containerImage": "registry.io/repository/image:tag", + "name": "", + "source": {} + }, + { + "containerImage": "registry.io/other-repository/image2:tag", + "name": "", + "source": {} + } + ] + } +} +--- diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go index 3149d6839..96f8b3f9c 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go @@ -22,8 +22,6 @@ import ( "encoding/json" "errors" "fmt" - "os" - "path" "runtime/trace" ecc "github.com/conforma/crds/api/v1alpha1" @@ -34,7 +32,6 @@ import ( cosignOCI "github.com/sigstore/cosign/v3/pkg/oci" ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" log "github.com/sirupsen/logrus" - "github.com/spf13/afero" "github.com/conforma/cli/internal/attestation" "github.com/conforma/cli/internal/evaluator" @@ -42,7 +39,6 @@ import ( "github.com/conforma/cli/internal/fetchers/oci/files" "github.com/conforma/cli/internal/policy" "github.com/conforma/cli/internal/signature" - "github.com/conforma/cli/internal/utils" "github.com/conforma/cli/internal/utils/oci" "github.com/conforma/cli/pkg/schema" ) @@ -419,15 +415,17 @@ type Input struct { PolicySpec ecc.EnterpriseContractPolicySpec `json:"policy_spec,omitempty"` } -// WriteInputFile writes the JSON from the attestations to input.json in a random temp dir -func (a *ApplicationSnapshotImage) WriteInputFile(ctx context.Context) (string, []byte, error) { - log.Debugf("Attempting to write %d attestations to input file", len(a.attestations)) +// BuildInput constructs the OPA input as a Go map and JSON bytes without disk I/O. +// The JSON marshal/unmarshal round-trip ensures correct types for OPA (e.g. numbers +// as float64, consistent key ordering). +func (a *ApplicationSnapshotImage) BuildInput(_ context.Context) (map[string]any, []byte, error) { + log.Debugf("Building input for %d attestations", len(a.attestations)) var attestations []attestationData - for _, a := range a.attestations { + for _, att := range a.attestations { attestations = append(attestations, attestationData{ - Statement: a.Statement(), - Signatures: a.Signatures(), + Statement: att.Statement(), + Signatures: att.Signatures(), }) } @@ -452,31 +450,15 @@ func (a *ApplicationSnapshotImage) WriteInputFile(ctx context.Context) (string, } } - fs := utils.FS(ctx) - inputDir, err := afero.TempDir(fs, "", "ecp_input.") - if err != nil { - log.Debug("Problem making temp dir!") - return "", nil, err - } - log.Debugf("Created dir %s", inputDir) - inputJSONPath := path.Join(inputDir, "input.json") - - f, err := fs.OpenFile(inputJSONPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644) - if err != nil { - log.Debugf("Problem creating file in %s", inputDir) - return "", nil, err - } - defer f.Close() - inputJSON, err := json.Marshal(input) if err != nil { - return "", nil, fmt.Errorf("input to JSON: %w", err) + return nil, nil, fmt.Errorf("input to JSON: %w", err) } - if _, err := f.Write(inputJSON); err != nil { - return "", nil, fmt.Errorf("write input to file: %w", err) + var parsed map[string]any + if err := json.Unmarshal(inputJSON, &parsed); err != nil { + return nil, nil, fmt.Errorf("parse input JSON: %w", err) } - log.Debugf("Done preparing input file:\n%s", inputJSONPath) - return inputJSONPath, inputJSON, nil + return parsed, inputJSON, nil } diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go index 5cefaa2f0..c8714bc9c 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go @@ -46,7 +46,6 @@ import ( "github.com/sigstore/cosign/v3/pkg/oci/static" cosignTypes "github.com/sigstore/cosign/v3/pkg/types" "github.com/sigstore/sigstore/pkg/signature/payload" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -122,7 +121,7 @@ func createSimpleAttestation(statement *in_toto.ProvenanceStatementSLSA02, o ... return a } -func TestWriteInputFile(t *testing.T) { +func TestBuildInput(t *testing.T) { cases := []struct { name string snapshot ApplicationSnapshotImage @@ -260,8 +259,7 @@ func TestWriteInputFile(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - fs := afero.NewMemMapFs() - ctx := utils.WithFS(context.Background(), fs) + ctx := context.Background() tt.snapshot.snapshot = app.SnapshotSpec{ Components: []app.SnapshotComponent{ { @@ -272,25 +270,16 @@ func TestWriteInputFile(t *testing.T) { }, }, } - inputPath, inputJSON, err := tt.snapshot.WriteInputFile(ctx) + inputMap, inputJSON, err := tt.snapshot.BuildInput(ctx) assert.NoError(t, err) - assert.NotEmpty(t, inputPath) - assert.Regexp(t, `/ecp_input.\d+/input.json`, inputPath) - fileExists, err := afero.Exists(fs, inputPath) - assert.NoError(t, err) - assert.True(t, fileExists) - - bytes, err := afero.ReadFile(fs, inputPath) - assert.NoError(t, err) - snaps.MatchJSON(t, bytes) - - assert.JSONEq(t, string(inputJSON), string(bytes)) + assert.NotNil(t, inputMap) + snaps.MatchJSON(t, inputJSON) }) } } -func TestWriteInputFileMultipleAttestations(t *testing.T) { +func TestBuildInputMultipleAttestations(t *testing.T) { att := createSimpleAttestation(nil) snapshot := app.SnapshotSpec{ Components: []app.SnapshotComponent{ @@ -308,22 +297,12 @@ func TestWriteInputFileMultipleAttestations(t *testing.T) { snapshot: snapshot, } - fs := afero.NewMemMapFs() - ctx := utils.WithFS(context.Background(), fs) - inputPath, inputJSON, err := a.WriteInputFile(ctx) - - assert.NoError(t, err) - assert.NotEmpty(t, inputPath) - assert.Regexp(t, `/ecp_input.\d+/input.json`, inputPath) - fileExists, err := afero.Exists(fs, inputPath) - assert.NoError(t, err) - assert.True(t, fileExists) + ctx := context.Background() + inputMap, inputJSON, err := a.BuildInput(ctx) - bytes, err := afero.ReadFile(fs, inputPath) assert.NoError(t, err) - snaps.MatchJSON(t, bytes) - - assert.JSONEq(t, string(inputJSON), string(bytes)) + assert.NotNil(t, inputMap) + snaps.MatchJSON(t, inputJSON) } func TestNewApplicationSnapshotImage(t *testing.T) { diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index f2e434ae6..51d725418 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -17,6 +17,7 @@ package evaluator import ( + "bytes" "context" "encoding/json" "fmt" @@ -25,16 +26,18 @@ import ( "os" "path" "path/filepath" + "regexp" "runtime/trace" "strings" + "sync" "time" ecc "github.com/conforma/crds/api/v1alpha1" - "github.com/open-policy-agent/conftest/output" + conftestParser "github.com/open-policy-agent/conftest/parser" conftest "github.com/open-policy-agent/conftest/policy" - "github.com/open-policy-agent/conftest/runner" "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/storage" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/topdown/print" log "github.com/sirupsen/logrus" "github.com/spf13/afero" "k8s.io/apimachinery/pkg/util/sets" @@ -170,7 +173,6 @@ type testRunner interface { const ( effectiveOnFormat = "2006-01-02T15:04:05Z" effectiveOnTimeout = -90 * 24 * time.Hour // keep effective_on metadata up to 90 days - metadataQuery = "query" metadataCode = "code" metadataCollections = "collections" metadataDependsOn = "depends_on" @@ -209,88 +211,18 @@ type conftestEvaluator struct { namespace []string source ecc.Source policyResolver PolicyResolver // Unified policy resolver for both pre and post-evaluation filtering -} -type conftestRunner struct { - runner.TestRunner -} - -func (r conftestRunner) Run(ctx context.Context, fileList []string) (result []Outcome, err error) { - r.Trace = tracing.FromContext(ctx).Enabled(tracing.Opa) - - var conftestResult []output.CheckResult - conftestResult, err = r.TestRunner.Run(ctx, fileList) - if err != nil { - return - } - - for _, res := range conftestResult { - if log.IsLevelEnabled(log.TraceLevel) { - for _, q := range res.Queries { - for _, t := range q.Traces { - log.Tracef("[%s] %s", q.Query, t) - } - } - } - if log.IsLevelEnabled(log.DebugLevel) { - for _, q := range res.Queries { - for _, o := range q.Outputs { - log.Debugf("[%s] %s", q.Query, o) - } - } - } - - result = append(result, Outcome{ - FileName: res.FileName, - Namespace: res.Namespace, - // Conftest doesn't give us a list of successes, just a count. Here we turn that count - // into a placeholder slice of that size to make processing easier later on. - Successes: make([]Result, res.Successes), - Skipped: toRules(res.Skipped), - Warnings: toRules(res.Warnings), - Failures: toRules(res.Failures), - Exceptions: toRules(res.Exceptions), - }) - } - - // we can't reference the engine from the test runner or from the results so - // we need to recreate it, this needs to remain the same as in - // runner.TestRunner's Run function - var engine *conftest.Engine - capabilities, err := conftest.LoadCapabilities(r.Capabilities) - if err != nil { - return - } - compilerOptions := conftest.CompilerOptions{ - Strict: r.Strict, - RegoVersion: r.RegoVersion, - Capabilities: capabilities, - } - engine, err = conftest.LoadWithData(r.Policy, r.Data, compilerOptions) - if err != nil { - return - } - - store := engine.Store() - - var txn storage.Transaction - txn, err = store.NewTransaction(ctx) - if err != nil { - return - } - - ids := []string{} // everything - - d, err := store.Read(ctx, txn, ids) - if err != nil { - return - } - - if _, ok := d.(map[string]any); !ok { - err = fmt.Errorf("could not retrieve data from the policy engine: Data is: %v", d) - } - - return + // Pre-computed policy data (set once in constructor, read-only during Evaluate) + rules policyRules + nonAnnotated nonAnnotatedRules + allRules policyRules + dataSourceDirs []string + + // Pre-compiled OPA engine (set once on first evaluate, read-only during Evaluate) + engine *conftest.Engine + trace bool + initOnce *sync.Once + initErr error } // NewConftestEvaluator returns initialized conftestEvaluator implementing @@ -362,8 +294,10 @@ func NewConftestEvaluatorWithNamespaceAndFilterType( return nil, err } + c.initOnce = &sync.Once{} + log.Debug("Conftest test runner created") - return c, nil + return &c, nil } // set the policy namespace @@ -372,158 +306,86 @@ func NewConftestEvaluatorWithNamespace(ctx context.Context, policySources []sour return NewConftestEvaluatorWithNamespaceAndFilterType(ctx, policySources, p, source, namespace, "include-exclude") } -// Destroy removes the working directory -func (c conftestEvaluator) Destroy() { - if os.Getenv("EC_DEBUG") == "" { - _ = c.fs.RemoveAll(c.workDir) - } -} - -func (c conftestEvaluator) CapabilitiesPath() string { - return path.Join(c.workDir, "capabilities.json") -} - -type policyRules map[string]rule.Info - -// Add a new type to track non-annotated rules separately -type nonAnnotatedRules map[string]bool - -func (r *policyRules) collect(a *ast.AnnotationsRef) error { - if a.Annotations == nil { - return nil - } - - info := rule.RuleInfo(a) - - if info.ShortName == "" { - // no short name matching with the code from Metadata will not be - // deterministic - return nil - } - - code := info.Code - - if _, ok := (*r)[code]; ok { - return fmt.Errorf("found a second rule with the same code: `%s`", code) - } - - (*r)[code] = info - return nil -} - -func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget) ([]Outcome, error) { - var results []Outcome - - if trace.IsEnabled() { - region := trace.StartRegion(ctx, "ec:conftest-evaluate") - defer region.End() - } +// downloadAndInspectPolicies downloads all policy sources, inspects their annotations, +// and populates c.rules, c.nonAnnotated, c.allRules, and c.dataSourceDirs. +// Called once from the constructor; the results are read-only during Evaluate. +func (c *conftestEvaluator) downloadAndInspectPolicies(ctx context.Context) error { + c.rules = policyRules{} + c.nonAnnotated = nonAnnotatedRules{} + c.dataSourceDirs = []string{} - // hold all rule annotations from all policy sources - // NOTE: emphasis on _all rules from all sources_; meaning that if two rules - // exist with the same code in two separate sources the collected rule - // information is not deterministic - rules := policyRules{} - // Track non-annotated rules separately for filtering purposes only - nonAnnotatedRules := nonAnnotatedRules{} - // Track data source directories for prepareDataDirs - dataSourceDirs := []string{} - // Download all sources for _, s := range c.policySources { dir, err := s.GetPolicy(ctx, c.workDir, false) if err != nil { log.Debugf("Unable to download source from %s!", s.PolicyUrl()) - // TODO do we want to download other policies instead of erroring out? - return nil, err + return err } - // Track data source directories - these are the actual directories returned by GetPolicy - // which may be symlinks, so we need to use them directly rather than walking if s.Subdir() == "data" { - dataSourceDirs = append(dataSourceDirs, dir) + c.dataSourceDirs = append(c.dataSourceDirs, dir) } annotations := []*ast.AnnotationsRef{} fs := utils.FS(ctx) - // We only want to inspect the directory of policy subdirs, not config or data subdirs. if s.Subdir() == "policy" { annotations, err = opa.InspectDir(fs, dir) if err != nil { errMsg := err if err.Error() == "no rego files found in policy subdirectory" { - // Let's try to give some more robust messaging to the user. policyURL, err := url.Parse(s.PolicyUrl()) if err != nil { - return nil, errMsg + return errMsg } - // Do we have a prefix at the end of the URL path? - // If not, this means we aren't trying to access a specific file. - // TODO: Determine if we want to check for a .git suffix as well? pos := strings.LastIndex(policyURL.Path, ".") if pos == -1 { - // Are we accessing a GitHub or GitLab URL? If so, are we beginning with 'https' or 'http'? if (policyURL.Host == "github.com" || policyURL.Host == "gitlab.com") && (policyURL.Scheme == "https" || policyURL.Scheme == "http") { log.Debug("Git Hub or GitLab, http transport, and no file extension, this could be a problem.") errMsg = fmt.Errorf("%s.\nYou've specified a %s URL with an %s:// scheme.\nDid you mean: %s instead?", errMsg, policyURL.Hostname(), policyURL.Scheme, fmt.Sprint(policyURL.Host+policyURL.RequestURI())) } } } - return nil, errMsg + return errMsg } } - // Collect ALL rules for filtering purposes - both with and without annotations - // This ensures that rules without metadata (like fail_with_data.rego) are properly included for _, a := range annotations { if a.Annotations != nil { - // Rules with annotations - collect full metadata - if err := rules.collect(a); err != nil { - return nil, err + if err := c.rules.collect(a); err != nil { + return err } } else { - // Rules without annotations - track for filtering only, not for success computation ruleRef := a.GetRule() if ruleRef != nil { - // Extract package name from the rule path packageName := "" if len(a.Path) > 1 { - // Path format is typically ["data", "package", "rule"] - // We want the package part (index 1) if len(a.Path) >= 2 { packageName = strings.ReplaceAll(a.Path[1].String(), `"`, "") } } - // Try to extract code from rule body first, fallback to rule name code := extractCodeFromRuleBody(ruleRef) - // If no code found in body, use rule name if code == "" { shortName := ruleRef.Head.Name.String() code = fmt.Sprintf("%s.%s", packageName, shortName) } - // Debug: Print non-annotated rule processing log.Debugf("Non-annotated rule: packageName=%s, code=%s", packageName, code) - // Track for filtering but don't add to rules map for success computation - nonAnnotatedRules[code] = true + c.nonAnnotated[code] = true } } } } - // Prepare all rules for policy resolution (both annotated and non-annotated) - // Combine annotated and non-annotated rules for filtering - allRules := make(policyRules) - for code, rule := range rules { - allRules[code] = rule + c.allRules = make(policyRules) + for code, r := range c.rules { + c.allRules[code] = r } - // Add non-annotated rules as minimal rule.Info for filtering - for code := range nonAnnotatedRules { + for code := range c.nonAnnotated { parts := strings.Split(code, ".") if len(parts) >= 2 { packageName := parts[len(parts)-2] shortName := parts[len(parts)-1] - allRules[code] = rule.Info{ + c.allRules[code] = rule.Info{ Code: code, Package: packageName, ShortName: shortName, @@ -531,12 +393,375 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget } } + return nil +} + +// compileEngine pre-compiles all policies and data into a conftest Engine. +// The resulting engine's Compiler and Store are used read-only during Evaluate, +// making concurrent evaluation safe without the store mutations that +// engine.Check/addFileInfo would cause. +func (c *conftestEvaluator) compileEngine(ctx context.Context) error { + dataDirs, err := c.prepareDataDirs(ctx, c.dataSourceDirs) + if err != nil { + return err + } + + capabilities, err := conftest.LoadCapabilities(c.CapabilitiesPath()) + if err != nil { + return fmt.Errorf("load capabilities: %w", err) + } + + opts := conftest.CompilerOptions{ + RegoVersion: "v1", + Capabilities: capabilities, + } + + engine, err := conftest.LoadWithData([]string{c.policyDir}, dataDirs, opts) + if err != nil { + return fmt.Errorf("load: %w", err) + } + + engine.EnableInterQueryCache() + c.trace = tracing.FromContext(ctx).Enabled(tracing.Opa) + if c.trace { + engine.EnableTracing() + } + + c.engine = engine + return nil +} + +var ( + opaFailureRegex = regexp.MustCompile("^(deny|violation)(_[a-zA-Z0-9]+)*$") + opaWarningRegex = regexp.MustCompile("^warn(_[a-zA-Z0-9]+)*$") +) + +func isOPAFailureRule(name string) bool { return opaFailureRegex.MatchString(name) } +func isOPAWarningRule(name string) bool { return opaWarningRegex.MatchString(name) } + +func stripOPARulePrefix(name string) string { + if name == "violation" || name == "deny" || name == "warn" { + return "" + } + name = strings.TrimPrefix(name, "violation_") + name = strings.TrimPrefix(name, "deny_") + name = strings.TrimPrefix(name, "warn_") + return name +} + +// ensureInitialized downloads policies and compiles the OPA engine on first call. +// The context from the first caller is used for the one-time initialization. +func (c *conftestEvaluator) ensureInitialized(ctx context.Context) error { + c.initOnce.Do(func() { + if err := c.downloadAndInspectPolicies(ctx); err != nil { + c.initErr = err + return + } + if err := c.compileEngine(ctx); err != nil { + c.initErr = err + } + }) + return c.initErr +} + +func (c *conftestEvaluator) evaluateWithEngine(ctx context.Context, target EvaluationTarget, filteredNamespaces []string) ([]Outcome, error) { + if err := c.ensureInitialized(ctx); err != nil { + return nil, err + } + if c.engine == nil { + return nil, fmt.Errorf("OPA engine not compiled; ensure policies are on the real filesystem") + } + + namespacesToUse := c.namespace + if len(filteredNamespaces) > 0 { + namespacesToUse = filteredNamespaces + } else if len(namespacesToUse) == 0 { + namespacesToUse = c.engine.Namespaces() + } + + log.Debugf("Engine namespaces to use: %v", namespacesToUse) + + var configs map[string]any + if target.ParsedInput != nil { + configs = map[string]any{"": target.ParsedInput} + } else { + var err error + configs, err = parseInputFiles(target.Inputs) + if err != nil { + return nil, fmt.Errorf("parse inputs: %w", err) + } + } + + var results []Outcome + for _, ns := range namespacesToUse { + for filePath, config := range configs { + if subconfigs, ok := config.([]any); ok { + outcome := Outcome{FileName: filePath, Namespace: ns} + for _, subconfig := range subconfigs { + sub, err := c.queryNamespace(ctx, filePath, subconfig, ns) + if err != nil { + return nil, err + } + outcome.Successes = append(outcome.Successes, sub.Successes...) + outcome.Failures = append(outcome.Failures, sub.Failures...) + outcome.Warnings = append(outcome.Warnings, sub.Warnings...) + outcome.Exceptions = append(outcome.Exceptions, sub.Exceptions...) + } + results = append(results, outcome) + } else { + outcome, err := c.queryNamespace(ctx, filePath, config, ns) + if err != nil { + return nil, err + } + results = append(results, outcome) + } + } + } + return results, nil +} + +// parseInputFiles reads and parses input files from the given paths, +// supporting JSON, YAML, and other formats via conftest's parser. +// Directories are expanded to their contained files first. +func parseInputFiles(inputs []string) (map[string]any, error) { + var files []string + for _, input := range inputs { + info, err := os.Stat(input) + if err != nil { + return nil, err + } + if info.IsDir() { + entries, err := os.ReadDir(input) + if err != nil { + return nil, err + } + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, filepath.Join(input, entry.Name())) + } + } + } else { + files = append(files, input) + } + } + return conftestParser.ParseConfigurations(files) +} + +// queryNamespace evaluates all deny/warn rules in a single OPA namespace +// against the given input. This replicates conftest's engine.check() logic +// but without the store-mutating addFileInfo() call. +func (c *conftestEvaluator) queryNamespace(ctx context.Context, fileName string, input any, namespace string) (Outcome, error) { + outcome := Outcome{ + FileName: fileName, + Namespace: namespace, + } + + var ruleNames []string + var ruleCount int + for _, module := range c.engine.Modules() { + ns := strings.Replace(module.Package.Path.String(), "data.", "", 1) + if ns != namespace { + continue + } + for _, r := range module.Rules { + name := r.Head.Name.String() + if !isOPAFailureRule(name) && !isOPAWarningRule(name) { + continue + } + ruleCount++ + found := false + for _, existing := range ruleNames { + if strings.EqualFold(existing, name) { + found = true + break + } + } + if !found { + ruleNames = append(ruleNames, name) + } + } + } + + var successes int + for _, ruleName := range ruleNames { + exceptionQuery := fmt.Sprintf("data.%s.exception[_][_] == %q", namespace, stripOPARulePrefix(ruleName)) + exceptionResults, err := c.evalOPAQuery(ctx, input, exceptionQuery) + if err != nil { + return Outcome{}, fmt.Errorf("query exception: %w", err) + } + + var exceptions []Result + for _, er := range exceptionResults { + if er.Message == "" && len(er.Metadata) == 0 { + exceptions = append(exceptions, Result{Message: exceptionQuery}) + } + } + + ruleQuery := fmt.Sprintf("data.%s.%s", namespace, ruleName) + ruleResults, err := c.evalOPAQuery(ctx, input, ruleQuery) + if err != nil { + return Outcome{}, fmt.Errorf("query rule: %w", err) + } + + for _, rr := range ruleResults { + if len(exceptions) > 0 { + continue + } + if rr.Message == "" && len(rr.Metadata) == 0 { + successes++ + continue + } + if isOPAFailureRule(ruleName) { + outcome.Failures = append(outcome.Failures, rr) + } else { + outcome.Warnings = append(outcome.Warnings, rr) + } + } + outcome.Exceptions = append(outcome.Exceptions, exceptions...) + } + + resultCount := len(outcome.Failures) + len(outcome.Warnings) + len(outcome.Exceptions) + successes + if resultCount < ruleCount { + successes += ruleCount - resultCount + } + outcome.Successes = make([]Result, successes) + + return outcome, nil +} + +// evalOPAQuery executes a single OPA query against the pre-compiled engine's +// compiler and store. Safe for concurrent use since it only reads from the +// compiler and store (no writes). +func (c *conftestEvaluator) evalOPAQuery(ctx context.Context, input any, query string) ([]Result, error) { + ph := opaPrintHook{s: &[]string{}} + options := []func(r *rego.Rego){ + rego.Input(input), + rego.Query(query), + rego.Compiler(c.engine.Compiler()), + rego.Store(c.engine.Store()), + rego.Trace(c.trace), + rego.PrintHook(ph), + } + + regoInstance := rego.New(options...) + + resultSet, err := regoInstance.Eval(ctx) + if err != nil { + return nil, fmt.Errorf("evaluating policy: %w", err) + } + + if c.trace && log.IsLevelEnabled(log.TraceLevel) { + buf := new(bytes.Buffer) + rego.PrintTrace(buf, regoInstance) + for _, line := range strings.Split(buf.String(), "\n") { + if len(line) > 0 { + log.Tracef("[%s] %s", query, line) + } + } + } + if log.IsLevelEnabled(log.DebugLevel) { + for _, o := range *ph.s { + log.Debugf("[%s] %s", query, o) + } + } + + var results []Result + for _, result := range resultSet { + for _, expression := range result.Expressions { + expressionValues, ok := expression.Value.([]any) + if !ok || len(expressionValues) == 0 { + results = append(results, Result{}) + continue + } + for _, v := range expressionValues { + switch val := v.(type) { + case string: + results = append(results, Result{ + Message: val, + Metadata: map[string]any{}, + }) + case map[string]any: + msg, _ := val["msg"].(string) + metadata := make(map[string]any) + for k, v := range val { + if k != "msg" { + metadata[k] = v + } + } + results = append(results, Result{ + Message: msg, + Metadata: metadata, + }) + } + } + } + } + + return results, nil +} + +type opaPrintHook struct { + s *[]string +} + +func (ph opaPrintHook) Print(pctx print.Context, msg string) error { + *ph.s = append(*ph.s, fmt.Sprintf("%v: %s", pctx.Location, msg)) + return nil +} + +// Destroy removes the working directory +func (c *conftestEvaluator) Destroy() { + if os.Getenv("EC_DEBUG") == "" { + _ = c.fs.RemoveAll(c.workDir) + } +} + +func (c *conftestEvaluator) CapabilitiesPath() string { + return path.Join(c.workDir, "capabilities.json") +} + +type policyRules map[string]rule.Info + +// Add a new type to track non-annotated rules separately +type nonAnnotatedRules map[string]bool + +func (r *policyRules) collect(a *ast.AnnotationsRef) error { + if a.Annotations == nil { + return nil + } + + info := rule.RuleInfo(a) + + if info.ShortName == "" { + // no short name matching with the code from Metadata will not be + // deterministic + return nil + } + + code := info.Code + + if _, ok := (*r)[code]; ok { + return fmt.Errorf("found a second rule with the same code: `%s`", code) + } + + (*r)[code] = info + return nil +} + +func (c *conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget) ([]Outcome, error) { + var results []Outcome + + if trace.IsEnabled() { + region := trace.StartRegion(ctx, "ec:conftest-evaluate") + defer region.End() + } + var filteredNamespaces []string if c.policyResolver != nil { // IMPLEMENTATION: Option A - Unified Policy Resolution // Use the same PolicyResolver for both pre-evaluation and post-evaluation filtering // This ensures consistent logic and eliminates duplication - policyResolution := c.policyResolver.ResolvePolicy(allRules, target.Target) + policyResolution := c.policyResolver.ResolvePolicy(c.allRules, target.Target) // Extract included package names for conftest evaluation for pkg := range policyResolution.IncludedPackages { @@ -555,52 +780,15 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget // Instead, we evaluate all namespaces and filter results afterward } - r, ok := ctx.Value(runnerKey).(testRunner) - if r == nil || !ok { - - // Determine which namespaces to use - namespacesToUse := c.namespace - allNamespaces := false - - // If we have filtered namespaces from the filtering system, use those - if len(filteredNamespaces) > 0 { - namespacesToUse = filteredNamespaces - - } else if len(namespacesToUse) == 0 { - // For new filtering with empty namespaces, also evaluate all namespaces - // This ensures backward compatibility with tests that don't specify namespaces - allNamespaces = true - } - - // log the namespaces to use - log.Debugf("Namespaces to use: %v, allNamespaces: %v", namespacesToUse, allNamespaces) - - // Prepare the list of data dirs - dataDirs, err := c.prepareDataDirs(ctx, dataSourceDirs) - if err != nil { - return nil, err - } - - r = &conftestRunner{ - runner.TestRunner{ - Data: dataDirs, - Policy: []string{c.policyDir}, - Namespace: namespacesToUse, - AllNamespaces: allNamespaces, // Use all namespaces for legacy filtering - NoFail: true, - Output: c.outputFormat, - Capabilities: c.CapabilitiesPath(), - RegoVersion: "v1", - }, - } + var runResults []Outcome + var err error + if r, ok := ctx.Value(runnerKey).(testRunner); r != nil && ok { + log.Debugf("inputs: %#v", target.Inputs) + runResults, err = r.Run(ctx, target.Inputs) + } else { + runResults, err = c.evaluateWithEngine(ctx, target, filteredNamespaces) } - - log.Debugf("runner: %#v", r) - log.Debugf("inputs: %#v", target.Inputs) - - runResults, err := r.Run(ctx, target.Inputs) if err != nil { - // TODO do we want to evaluate further policies instead of erroring out? return nil, err } @@ -641,12 +829,12 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget // Add metadata to all results for j := range allResults { - addRuleMetadata(ctx, &allResults[j], rules) + addRuleMetadata(ctx, &allResults[j], c.rules) } // Filter results using the unified filter filteredResults, updatedMissingIncludes := unifiedFilter.FilterResults( - allResults, allRules, target.Target, target.ComponentName, missingIncludes, effectiveTime) + allResults, c.allRules, target.Target, target.ComponentName, missingIncludes, effectiveTime) // Update missing includes missingIncludes = updatedMissingIncludes @@ -661,7 +849,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget result.Skipped = skipped // Replace the placeholder successes slice with the actual successes. - result.Successes = c.computeSuccesses(result, rules, target.Target, target.ComponentName, missingIncludes, unifiedFilter) + result.Successes = c.computeSuccesses(result, c.rules, target.Target, target.ComponentName, missingIncludes, unifiedFilter) totalRules += len(result.Warnings) + len(result.Failures) + len(result.Successes) @@ -695,7 +883,7 @@ func (c conftestEvaluator) Evaluate(ctx context.Context, target EvaluationTarget // dataSourceDirs contains the directories returned by GetPolicy for data sources. // These directories may be symlinks (from cached downloads), but we walk them directly // which ensures we find the files regardless of whether they're symlinks or not. -func (c conftestEvaluator) prepareDataDirs(ctx context.Context, dataSourceDirs []string) ([]string, error) { +func (c *conftestEvaluator) prepareDataDirs(ctx context.Context, dataSourceDirs []string) ([]string, error) { // The reason we do this is to avoid having the names of the subdirs under c.dataDir // converted to keys in the data structure. We want the top level keys in the data files // to be at the top level of the data structure visible to the rego rules. @@ -774,28 +962,10 @@ func (c conftestEvaluator) prepareDataDirs(ctx context.Context, dataSourceDirs [ return dataDirs, nil } -func toRules(results []output.Result) []Result { - var eResults []Result - for _, r := range results { - // Newer conftest adds this key to the metadata. A typical value might - // be "data.main.deny". Currently we don't use it so let's remove it - // rather than change a bunch of snapshot files and test assertions. - delete(r.Metadata, metadataQuery) - - eResults = append(eResults, Result{ - Message: r.Message, - Metadata: r.Metadata, - Outputs: r.Outputs, - }) - } - - return eResults -} - // computeSuccesses generates success results, these are not provided in the // Conftest results, so we reconstruct these from the parsed rules, any rule // that hasn't been touched by adding metadata must have succeeded -func (c conftestEvaluator) computeSuccesses( +func (c *conftestEvaluator) computeSuccesses( result Outcome, rules policyRules, imageRef string, @@ -1121,7 +1291,7 @@ func isResultEffective(failure Result, now time.Time) bool { // isResultIncluded returns whether or not the result should be included or // discarded based on the policy configuration. // 'missingIncludes' is a list of include directives that gets pruned if the result is matched -func (c conftestEvaluator) isResultIncluded(result Result, imageRef string, componentName string, missingIncludes map[string]bool) bool { +func (c *conftestEvaluator) isResultIncluded(result Result, imageRef string, componentName string, missingIncludes map[string]bool) bool { ruleMatchers := LegacyMakeMatchers(result) includeScore := LegacyScoreMatches(ruleMatchers, c.include.get(imageRef, componentName), missingIncludes) excludeScore := LegacyScoreMatches(ruleMatchers, c.exclude.get(imageRef, componentName), map[string]bool{}) diff --git a/internal/evaluator/conftest_evaluator_integration_basic_test.go b/internal/evaluator/conftest_evaluator_integration_basic_test.go index 21f2f539c..934083e0f 100644 --- a/internal/evaluator/conftest_evaluator_integration_basic_test.go +++ b/internal/evaluator/conftest_evaluator_integration_basic_test.go @@ -214,7 +214,7 @@ deny contains result if { defer evaluator.Destroy() // Debug: Check exclude criteria - conftestEval := evaluator.(conftestEvaluator) + conftestEval := evaluator.(*conftestEvaluator) t.Logf("Exclude componentItems: %+v", conftestEval.exclude.componentItems) t.Logf("Exclude defaultItems: %+v", conftestEval.exclude.defaultItems) t.Logf("Exclude digestItems: %+v", conftestEval.exclude.digestItems) diff --git a/internal/evaluator/conftest_evaluator_unit_core_test.go b/internal/evaluator/conftest_evaluator_unit_core_test.go index a690b4ee4..b9fbe16d4 100644 --- a/internal/evaluator/conftest_evaluator_unit_core_test.go +++ b/internal/evaluator/conftest_evaluator_unit_core_test.go @@ -423,7 +423,7 @@ func TestUnconformingRule(t *testing.T) { p, err := policy.NewInertPolicy(ctx, "") require.NoError(t, err) - evaluator, err := NewConftestEvaluatorWithNamespace(ctx, []source.PolicySource{ + ev, err := NewConftestEvaluatorWithNamespace(ctx, []source.PolicySource{ &source.PolicyUrl{ Url: rules, Kind: source.PolicyKind, @@ -431,7 +431,9 @@ func TestUnconformingRule(t *testing.T) { }, p, ecc.Source{}, []string{}) require.NoError(t, err) - _, err = evaluator.Evaluate(ctx, EvaluationTarget{Inputs: []string{path.Join(dir, "inputs")}}) + _, err = ev.Evaluate(ctx, EvaluationTarget{ + Inputs: []string{path.Join(dir, "inputs", "data.json")}, + }) require.Error(t, err) assert.EqualError(t, err, `the rule "deny = true if { true }" returns an unsupported value, at no_msg.rego:5`) } diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index 793cfe084..ee19225c9 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -24,6 +24,7 @@ type EvaluationTarget struct { Inputs []string Target string ComponentName string + ParsedInput map[string]any } type Evaluator interface { diff --git a/internal/evaluator/filters_test.go b/internal/evaluator/filters_test.go index 293b2eb59..eef635536 100644 --- a/internal/evaluator/filters_test.go +++ b/internal/evaluator/filters_test.go @@ -1362,7 +1362,7 @@ func TestConftestEvaluator_FilterType_ECPolicy(t *testing.T) { assert.NotNil(t, evaluator, "evaluator should not be nil") t.Logf("Evaluator type: %T", evaluator) - conftestEval, ok := evaluator.(conftestEvaluator) + conftestEval, ok := evaluator.(*conftestEvaluator) t.Logf("Type assertion result: ok=%v, conftestEval=%v", ok, conftestEval) assert.True(t, ok, "evaluator should be conftestEvaluator") @@ -1388,7 +1388,7 @@ func TestConftestEvaluator_FilterType_IncludeExclude(t *testing.T) { evaluator, err := NewConftestEvaluatorWithFilterType(ctx, policySources, configProvider, sourceConfig, "include-exclude") assert.NoError(t, err) - conftestEval, ok := evaluator.(conftestEvaluator) + conftestEval, ok := evaluator.(*conftestEvaluator) assert.True(t, ok, "evaluator should be conftestEvaluator") // Should use IncludeExcludePolicyResolver which ignores pipeline intentions @@ -1413,7 +1413,7 @@ func TestConftestEvaluator_FilterType_Default(t *testing.T) { evaluator, err := NewConftestEvaluatorWithFilterType(ctx, policySources, configProvider, sourceConfig, "unknown-type") assert.NoError(t, err) - conftestEval, ok := evaluator.(conftestEvaluator) + conftestEval, ok := evaluator.(*conftestEvaluator) assert.True(t, ok, "evaluator should be conftestEvaluator") // Should default to IncludeExcludePolicyResolver diff --git a/internal/image/validate.go b/internal/image/validate.go index cf80840fa..f8e494d49 100644 --- a/internal/image/validate.go +++ b/internal/image/validate.go @@ -70,6 +70,7 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, snap *app.Sn if err := a.FetchParentImageConfig(ctx); err != nil { log.Debugf("Unable to fetch parent's image config: %s", err) } + if err := a.FetchImageFiles(ctx); err != nil { log.Debugf("Unable to fetch image manifests: %s", err) } @@ -112,18 +113,17 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, snap *app.Sn return out, nil } - inputPath, inputJSON, err := a.WriteInputFile(ctx) + inputMap, inputJSON, err := a.BuildInput(ctx) if err != nil { - log.Debug("Problem writing input files!") + log.Debug("Problem building input!") return nil, err } var allResults []evaluator.Outcome for _, e := range evaluators { - // Todo maybe: Handle each one concurrently target := evaluator.EvaluationTarget{ - Inputs: []string{inputPath}, + ParsedInput: inputMap, ComponentName: comp.Name, } if ref := a.ImageReference(ctx); ref == "" {