Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]

### Added
- `core`: Provider results can now carry a `context` object saying where the evaluated value came
from. It is rendered into the front of the result `message` and kept as structured fields in the
result document
- `terraform_plan`: Result messages now name the resource address, its planned action and the
attribute being evaluated, e.g. ``[aws_s3_bucket.example (create)] acl: `"public-read"` is not
equal to `"private"` `` instead of just ``` `"public-read"` is not equal to `"private"` ```

### Changed
- `terraform_plan`: A wildcard attribute now reports the index it resolved to
(`ebs_block_device.0.tags.application_acronym`), so results from the same resource can be told
apart
- `terraform_plan`: An "attribute is not found" error now names the resource it is about, instead
of repeating the same text once per resource
- `core`: "Could not find input value" now names the provider arguments that produced no value

## [1.0.5] - 2025-11-19

### Fixed
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,14 @@ JSON Output:
"result": [
{
"passed": true,
"message": "default is equal to default",
"message": "[aws_vpc.this[0] (create)] instance_tenancy: `\"default\"` is equal to `\"default\"`",
"context": {
"operation_type": "attribute",
"resource_type": "aws_vpc",
"resource_address": "aws_vpc.this[0]",
"action": "create",
"attribute": "instance_tenancy"
},
"meta": {
"address": "aws_vpc.this[0]",
"mode": "managed",
Expand Down Expand Up @@ -416,7 +423,14 @@ JSON Output:
},
{
"passed": true,
"message": "default is equal to default",
"message": "[aws_vpc.this[0] (create)] instance_tenancy: `\"default\"` is equal to `\"default\"`",
"context": {
"operation_type": "attribute",
"resource_type": "aws_vpc",
"resource_address": "aws_vpc.this[0]",
"action": "create",
"attribute": "instance_tenancy"
},
"meta": {
"address": "aws_vpc.this[0]",
"mode": "managed",
Expand Down
55 changes: 52 additions & 3 deletions src/tirith/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@

from typing import Any, Dict, List, Tuple, Optional

from tirith.providers.common import ProviderError
from tirith.providers.common import ProviderError, format_context_prefix
from ..providers import PROVIDERS_DICT
from .evaluators import EVALUATORS_DICT
from .policy_parameterization import get_policy_with_vars_replaced


logger = logging.getLogger(__name__)

# Provider arguments named in the "could not find input value" message, in the order they
# are rendered. Providers use different argument names, so only the present ones are used.
_NO_INPUT_VALUE_DESCRIBED_ARGS = (
"operation_type",
"terraform_resource_type",
"terraform_resource_attribute",
"terraform_provider_full_name",
"kubernetes_kind",
"attribute_path",
"attribute",
)


def get_evaluator_inputs_from_provider_inputs(provider_inputs, provider_module, input_data):
# TODO: Get the inputs from given providers
Expand All @@ -26,6 +38,34 @@
return provider_func(provider_inputs, input_data)


def _no_input_value_message(provider_inputs: Optional[Dict]) -> str:
"""
Build the message used when a provider returns no inputs at all.

The bare "Could not find input value" says nothing about what was looked for, so name
the provider arguments that produced no value whenever they are available.

:param provider_inputs: The `provider_args` of the evaluator
:type provider_inputs: Optional[Dict]

:returns: The message to report against the failed evaluation
:rtype: str
"""
BASE_MESSAGE = "Could not find input value"

if not provider_inputs:
return BASE_MESSAGE

described_args = ", ".join(
f"{key}: '{provider_inputs[key]}'" for key in _NO_INPUT_VALUE_DESCRIBED_ARGS if provider_inputs.get(key)
)

if not described_args:
return BASE_MESSAGE

return f"{BASE_MESSAGE} for {described_args}"


def generate_evaluator_result(evaluator_obj, input_data, provider_module):
DEFAULT_ERROR_TOLERANCE = 0

Expand Down Expand Up @@ -60,28 +100,37 @@
# In this case, the evaluation should fail
if not evaluator_inputs:
has_evaluation_passed = False
evaluation_results = [{"passed": False, "message": "Could not find input value"}]
evaluation_results = [{"passed": False, "message": _no_input_value_message(provider_inputs)}]
else:
# Track if we've had at least one valid evaluation (not skipped)
has_valid_evaluation = False

for evaluator_input in evaluator_inputs:
if isinstance(evaluator_input["value"], ProviderError) and evaluator_input.get("err", None):
severity_value = evaluator_input["value"].severity_value
err_result = dict(message=evaluator_input["err"])
context = evaluator_input.get("context")
err_result = dict(message=format_context_prefix(context) + evaluator_input["err"])

Check warning on line 112 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaBHm3NYf88RLafToKGQ&open=AaBHm3NYf88RLafToKGQ&pullRequest=286
if context:
err_result["context"] = context

if severity_value > evaluator_error_tolerance:
err_result.update(dict(passed=False))

Check warning on line 117 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaBHm3NYf88RLafToKGR&open=AaBHm3NYf88RLafToKGR&pullRequest=286
evaluation_results.append(err_result)
has_evaluation_passed = False
continue
# Mark as skipped evaluation
err_result.update(dict(passed=None))

Check warning on line 122 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaBHm3NYf88RLafToKGS&open=AaBHm3NYf88RLafToKGS&pullRequest=286
evaluation_results.append(err_result)
has_evaluation_passed = None
continue

evaluation_result = evaluator_instance.evaluate(evaluator_input["value"], evaluator_data)
context = evaluator_input.get("context")
if context:
# Say which resource and attribute the evaluated value came from, both in the
# message and as structured fields for whoever reads the result document
evaluation_result["message"] = format_context_prefix(context) + evaluation_result["message"]
evaluation_result["context"] = context
evaluation_result["meta"] = evaluator_input.get("meta")
evaluation_results.append(evaluation_result)
has_valid_evaluation = True
Expand Down Expand Up @@ -188,7 +237,7 @@
for key in eval_id_values:
regex_string = "\\b" + key + "\\b"
eval_string = re.sub(regex_string, str(eval_id_values[key]), eval_string)
# eval_string = eval_string.replace(key, str(eval_id_values[key]["passed"]))

Check warning on line 240 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaBHm3NYf88RLafToKGT&open=AaBHm3NYf88RLafToKGT&pullRequest=286
# print (eval_string)

# TODO: shall we use and, or and not instead of symbols?
Expand Down Expand Up @@ -234,7 +283,7 @@
# TODO: validate policy_data against schema

with open(input_path) as f:
if input_path.endswith(".yaml") or input_path.endswith(".yml"):

Check warning on line 286 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace chained "endswith" calls with a single call using a tuple argument.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaBHm3NYf88RLafToKGU&open=AaBHm3NYf88RLafToKGU&pullRequest=286
input_data = list(yaml.safe_load_all(f))
if len(input_data) == 1:
input_data = input_data[0]
Expand Down
66 changes: 65 additions & 1 deletion src/tirith/providers/common.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import pydash

from typing import Dict, Any
from typing import Dict, Any, Optional


def create_result_dict(value=None, meta=None, err=None) -> Dict:
return dict(value=value, meta=meta, err=err)

Check warning on line 7 in src/tirith/providers/common.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AaBHm3Hff88RLafToKGN&open=AaBHm3Hff88RLafToKGN&pullRequest=286


class PydashPathNotFound:
Expand Down Expand Up @@ -167,3 +167,67 @@

def __init__(self, severity_value: int) -> None:
self.severity_value = severity_value


def format_context_prefix(context: Optional[Dict]) -> str:
"""
Render a provider result ``context`` dictionary as a message prefix.

Providers may attach a ``context`` dictionary to each of their outputs to describe where
the evaluated value came from. The core prepends the rendered prefix to the evaluator
message, so that a result reads ``[aws_s3_bucket.example (create)] acl: `"public-read"` is
not equal to `"private"``` instead of just ```"public-read"` is not equal to `"private"```.

Recognised keys:

``resource_address``
Address of the resource the value belongs to. Rendered as the bracketed subject.
``label``
Fallback subject when there is no single resource address (for example a resource
count). Only used when ``resource_address`` is absent.
``action``
Planned action(s) for the resource. Rendered next to the subject.
``attribute``
Name of the attribute being evaluated.

Any other key is ignored here but is still carried into the result document, so providers
can supply structured detail without it showing up in the message.

:param context: The context dictionary attached by the provider, or None
:type context: Optional[Dict]

:returns: The prefix to prepend to a message, or an empty string when there is no context
:rtype: str

**Examples:**

>>> format_context_prefix({"resource_address": "aws_vpc.main", "action": "create", "attribute": "cidr_block"})
'[aws_vpc.main (create)] cidr_block: '
>>> format_context_prefix({"resource_address": "aws_vpc.main", "attribute": "action"})
'[aws_vpc.main] action: '
>>> format_context_prefix({"label": "aws_vpc", "attribute": "count"})
'[aws_vpc] count: '
>>> format_context_prefix({"attribute": "terraform_version"})
'terraform_version: '
>>> format_context_prefix({"resource_address": "aws_vpc.main", "action": "create"})
'[aws_vpc.main (create)] '
>>> format_context_prefix(None)
''
"""
if not context:
return ""

subject = context.get("resource_address") or context.get("label")
action = context.get("action")
attribute = context.get("attribute")

subject_prefix = ""
if subject:
subject_prefix = "[{} ({})] ".format(subject, action) if action else "[{}] ".format(subject)

if not attribute:
# Without an attribute to name, the message that follows reads as a sentence of its
# own, so the subject is left as a bare lead-in rather than being followed by a colon
return subject_prefix

return "{}{}: ".format(subject_prefix, attribute)
Loading
Loading