Skip to content
Draft
3 changes: 3 additions & 0 deletions prowler/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ OPENAEV_TENANT_ID=ChangeMe
INJECTOR_ID=ChangeMe
INJECTOR_NAME=Prowler
INJECTOR_LOG_LEVEL=error

# Prowler runtime (must be a nonblank absolute path)
PROWLER_EXECUTABLE_PATH=/usr/local/bin/prowler
26 changes: 23 additions & 3 deletions prowler/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# OpenAEV Prowler Injector

The Prowler injector foundation registers Prowler with OpenAEV. Assessment
providers, credentials, execution, mapping, routes, and contracts are outside
CHK.001 and are intentionally not configured here.
The Prowler injector foundation registers Prowler with OpenAEV. Provider and
account inputs, credentials, execution, mapping, routes, and contracts are not
startup configuration and remain outside this chunk.

## Configuration

Expand All @@ -14,10 +14,15 @@ CHK.001 and are intentionally not configured here.
| `INJECTOR_ID` | `injector.id` | Unique injector identifier |
| `INJECTOR_NAME` | `injector.name` | Injector display name |
| `INJECTOR_LOG_LEVEL` | `injector.log_level` | Runtime log level |
| `PROWLER_EXECUTABLE_PATH` | `prowler.executable_path` | Absolute path to the Prowler executable (default: `/usr/local/bin/prowler`) |

Copy `config.yml.sample` to the ignored `config.yml` for local use, or supply
the equivalent environment variables. Never commit real tokens.

`prowler.executable_path` must be nonblank and absolute. Startup does not
require the file to exist; executable resolution happens immediately before a
future assessment execution.

## Run

```shell
Expand All @@ -26,3 +31,18 @@ python -m prowler

The CHK.001 foundation starts with zero assessment contracts. Contract catalog
registration is deferred to CHK.006.

## Provider input boundary

Provider selection, account or target values, and credentials are not injector
startup configuration. They will be supplied per OpenAEV form contract so that
credential changes do not require redeploying the injector. CHK.002 provides
only reusable, strict provider input models; it does not register forms, routes,
or contracts.

`aws_endpoint_url` is an optional per-assessment provider input for AWS, like
its credentials. When supplied, it must be an absolute HTTP or HTTPS URL with a
host and must not contain user information, a query, a fragment, or whitespace.
Paths and valid ports are allowed, including endpoints on localhost, private
networks, and container services. The accepted value remains an ordinary string,
and validation does not check network reachability.
4 changes: 4 additions & 0 deletions prowler/config.yml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ injector:
id: 'ChangeMe'
name: 'Prowler'
log_level: 'error'

prowler:
# Must be a nonblank absolute path. Existence is checked only before execution.
executable_path: '/usr/local/bin/prowler'
26 changes: 24 additions & 2 deletions prowler/prowler/models/configs/config_loader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Configuration foundation for the Prowler injector."""

from pydantic import Field
from pathlib import Path

from pydantic import BaseModel, Field, field_validator
from pyoaev.configuration import (
ConfigLoaderCollector,
ConfigLoaderOAEV,
Expand All @@ -18,11 +20,31 @@ class InjectorConfig(ConfigLoaderCollector):
)


class ProwlerConfig(BaseModel):
"""Prowler command runtime settings."""

executable_path: Path = Field(
default=Path("/usr/local/bin/prowler"),
description="Absolute path to the Prowler executable.",
)

@field_validator("executable_path", mode="before")
@classmethod
def validate_executable_path(cls, value: object) -> object:
"""Reject blank and non-absolute executable paths."""
if isinstance(value, str) and not value.strip():
raise ValueError("executable path must not be blank")
if isinstance(value, (str, Path)) and not Path(value).is_absolute():
raise ValueError("executable path must be absolute")
return value


class ConfigLoader(SettingsLoader):
"""Load only the standard OpenAEV and injector settings."""
"""Load standard settings and the Prowler runtime section."""

openaev: ConfigLoaderOAEV = Field(default_factory=ConfigLoaderOAEV)
injector: InjectorConfig = Field(default_factory=InjectorConfig)
prowler: ProwlerConfig = Field(default_factory=ProwlerConfig)

def to_daemon_config(self) -> Configuration:
"""Translate settings into the OpenAEV daemon configuration."""
Expand Down
134 changes: 134 additions & 0 deletions prowler/prowler/models/provider_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Strict, secret-safe provider inputs for future OpenAEV form contracts."""

from typing import Annotated, Any, Literal, NoReturn
from urllib.parse import urlsplit

from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
SecretStr,
TypeAdapter,
field_validator,
)


def _reject_blank(value: object) -> object:
"""Reject blank strings before they can enter a provider field."""
if isinstance(value, str) and not value.strip():
raise ValueError("value must not be blank")
return value


NonBlankStr = Annotated[str, BeforeValidator(_reject_blank)]
NonBlankSecretStr = Annotated[SecretStr, BeforeValidator(_reject_blank)]


class ImmutableProviderInput(BaseModel):
"""Provider boundary that rejects assignment without retaining its value."""

model_config = ConfigDict(
extra="forbid", strict=True, hide_input_in_errors=True, frozen=True
)

def __setattr__(self, name: str, value: Any) -> NoReturn:
"""Reject all post-construction assignment with a value-free error."""
raise TypeError("Provider inputs are immutable")


class AwsProviderInput(ImmutableProviderInput):
"""AWS provider form input."""

model_config = ConfigDict(
extra="forbid", strict=True, hide_input_in_errors=True, frozen=True
)

provider: Literal["aws"]
aws_access_key_id: NonBlankStr
aws_secret_access_key: NonBlankSecretStr
aws_account_id: NonBlankStr
aws_region: NonBlankStr
aws_session_token: NonBlankSecretStr | None = None
aws_endpoint_url: str | None = None

@field_validator("aws_endpoint_url", mode="before")
@classmethod
def validate_aws_endpoint_url(cls, value: object) -> object:
"""Accept only absolute HTTP(S) endpoints without unsafe URL extras."""
if value is None:
return None
if not isinstance(value, str):
raise ValueError("AWS endpoint URL must be a string")
if not value.strip():
raise ValueError("AWS endpoint URL must not be blank")
if any(character.isspace() for character in value):
raise ValueError("AWS endpoint URL must not contain whitespace")
if "?" in value or "#" in value:
raise ValueError("AWS endpoint URL must not include query or fragment")
try:
endpoint = urlsplit(value)
if endpoint.netloc.rsplit("@", maxsplit=1)[-1].endswith(":"):
raise ValueError("AWS endpoint URL port must not be empty")
port = endpoint.port
except ValueError as error:
raise ValueError("AWS endpoint URL must be valid") from error
if endpoint.scheme not in {"http", "https"}:
raise ValueError("AWS endpoint URL must use HTTP or HTTPS")
if not endpoint.netloc or endpoint.hostname is None:
raise ValueError("AWS endpoint URL must include a host")
if endpoint.username is not None or endpoint.password is not None:
raise ValueError("AWS endpoint URL must not include user information")
if port is not None and not 1 <= port <= 65535:
raise ValueError("AWS endpoint URL port must be between 1 and 65535")
return value


class AzureProviderInput(ImmutableProviderInput):
"""Azure provider form input."""

model_config = ConfigDict(
extra="forbid", strict=True, hide_input_in_errors=True, frozen=True
)

provider: Literal["azure"]
azure_tenant_id: NonBlankStr
azure_client_id: NonBlankStr
azure_client_secret: NonBlankSecretStr
azure_subscription_id: NonBlankStr
azure_provider: NonBlankStr


class GcpProviderInput(ImmutableProviderInput):
"""GCP provider form input."""

model_config = ConfigDict(
extra="forbid", strict=True, hide_input_in_errors=True, frozen=True
)

provider: Literal["gcp"]
gcp_service_account_json: NonBlankSecretStr
gcp_project_id: NonBlankStr


class KubernetesProviderInput(ImmutableProviderInput):
"""Kubernetes provider form input."""

model_config = ConfigDict(
extra="forbid", strict=True, hide_input_in_errors=True, frozen=True
)

provider: Literal["kubernetes"]
kubernetes_kubeconfig: NonBlankSecretStr
kubernetes_context: NonBlankStr


ProviderInput = Annotated[
AwsProviderInput | AzureProviderInput | GcpProviderInput | KubernetesProviderInput,
Field(discriminator="provider"),
]

PROVIDER_INPUT_ADAPTER: TypeAdapter[ProviderInput] = TypeAdapter(
ProviderInput,
config=ConfigDict(hide_input_in_errors=True),
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Feature: Prowler catalog registration and project scaffold
Scenario: Foundation configuration excludes future provider settings
Given CHK.001 owns only the injector configuration foundation
When the available configuration is inspected
Then only the standard OpenAEV and injector settings are present
Then only the standard OpenAEV and injector settings plus the Prowler runtime section are present

Scenario: Foundation startup registers no assessment contracts
Given assessment contracts are deferred to CHK.006
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"INJECTOR_ID",
"INJECTOR_NAME",
"INJECTOR_LOG_LEVEL",
"PROWLER_EXECUTABLE_PATH",
}
VALIDATION_CANARY = "PYDANTIC_VALIDATION_CANARY"
UNEXPECTED_CANARY = "UNEXPECTED_EXCEPTION_CANARY"
Expand Down Expand Up @@ -61,7 +62,7 @@ def _when_sample_environment_is_loaded(project_root: Path) -> set[str]:
}


def _then_only_standard_settings_are_available(settings: set[str]) -> None:
def _then_only_standard_and_prowler_settings_are_available(settings: set[str]) -> None:
assert settings == STANDARD_ENV_SETTINGS


Expand Down Expand Up @@ -134,7 +135,7 @@ def test_foundation_configuration_excludes_future_provider_settings() -> None:
"""The foundation exposes only standard injector settings."""
project_root = _given_the_prowler_project()
settings = _when_sample_environment_is_loaded(project_root)
_then_only_standard_settings_are_available(settings)
_then_only_standard_and_prowler_settings_are_available(settings)


def test_foundation_startup_registers_no_assessment_contracts(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""CHK.002 multi-provider configuration behaviour tests."""
Loading
Loading