Skip to content
Draft
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
75 changes: 75 additions & 0 deletions prowler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,78 @@ 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.

## Prowler 5.36 CLI compatibility

CHK.004 targets the installed `prowler` distribution version 5.36.0. Its
Kubernetes parser registers `--context` for selecting a kubeconfig context;
`--kube-context` is not registered. The adapter therefore emits
`--kubeconfig-file <temporary path> --context <name>`. This installed parser
evidence supersedes the stale proof-of-concept/contract spelling.

## Contract credential-file lifecycle

AWS and Azure credentials remain `SecretStr` environment values and are not
written to files. Prowler 5.36 requires filesystem paths for GCP service-account
JSON and Kubernetes kubeconfig input. Immediately before launching Prowler, one
contract execution therefore writes that plaintext credential to a randomly
named file inside a unique OS temporary directory. The file is closed before
the subprocess starts so native Windows can reopen it. It persists for the
Prowler command runtime and is deleted in `finally`, followed by its private
directory, whether execution returns or raises. The immutable command
specification and result may retain the now-stale temporary path, but never the
file content.

On POSIX, the directory is mode `0700` and the file is mode `0600`. Native
Windows relies on the current user's temp-directory ACL. Python `chmod` cannot
guarantee POSIX-equivalent ACL semantics on Windows, so this injector does not
claim that Windows permissions are owner-only. Docker and Kubernetes pod
ephemeral storage can reduce exposure, but does not eliminate it.

An abrupt interpreter crash, forced kill, host failure, or power loss can occur
before `finally` and leave plaintext residue in the OS temp location. Operators
must secure and preferably encrypt the temp volume and clean stale files under
their own retention policy. The injector deliberately performs no broad stale
cleanup that could delete unrelated files.

Each created client is one-shot and releases its copied provider input on the
first terminal run path. Python immutable strings and copies cannot be
guaranteed to be zeroized; the upstream OpenAEV injection payload may retain
credential values until `process_message` returns.

## Assessment output storage

CHK.004 does not parse Prowler's console stream as OCSF. Each assessment owns a
unique controlled temporary output directory and tells Prowler to write the
single expected artifact as `findings.ocsf.json` (`--output-filename findings`
with `-M json-ocsf`). Console stdout and stderr remain bounded diagnostics; the
artifact is opened only at its exact path as a regular, non-symlink file and is
read incrementally to its separate 100 MiB limit. CHK.004 debug metadata reports
only the artifact byte size; record counting belongs to the downstream mapper.

On Linux/POSIX, a writable directory at `/dev/shm` is preferred and labelled
`memory_tmpfs`, keeping normal output in memory-backed temporary storage. It is
selected only when a safe free-capacity probe reports at least the 100 MiB
artifact limit plus a 16 MiB safety margin for Prowler's additional temporary or
nested output. If `/dev/shm` is absent, unsuitable, undersized, cannot be probed,
or fails workspace creation, the injector makes one attempt in the portable
system temporary location labelled `filesystem_temp`, which may be disk-backed.
Windows always uses that system-temp fallback and relies on its native
temporary-directory ACL rather than making a POSIX permission claim. Owned
output directories use mode `0700` on POSIX.

On native Windows, Python does not expose the POSIX `O_NOFOLLOW` guarantee used
to reject symlink substitution at open time. Regular-file checks before and
after open, plus device/inode identity checks, are therefore a best-effort
reparse-point defence inside the randomly named controlled directory. This
fallback does not claim atomic reparse-point exclusion on native Windows;
operators must secure the system temporary-directory ACL against untrusted
writers.

The complete owned output tree, including any nested compliance output, is
recursively removed on every normal success or failure path. Cleanup is
idempotent and never scans or deletes sibling temporary paths. An abrupt crash,
forced kill, host failure, or power loss can still bypass controlled cleanup and
leave output residue. Operators must protect both `/dev/shm` and the portable
disk fallback according to the sensitivity of assessment findings and apply
their own stale-file policy after abnormal termination.
47 changes: 47 additions & 0 deletions prowler/prowler/_core/prowler_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Canonical synchronous Prowler client API."""

from .client import (
DEFAULT_MAXIMUM_ACCEPTED_CONSOLE_BYTES,
DEFAULT_MAXIMUM_ACCEPTED_OUTPUT_BYTES,
DEFAULT_TIMEOUT_SECONDS,
ProwlerClient,
ProwlerClientConsumedError,
)
from .credentials import (
CredentialCleanupError,
TemporaryCredentialLease,
TemporaryCredentialLeaseFactory,
)
from .factory import ProwlerClientFactory
from .output_workspace import (
DEFAULT_MAXIMUM_ARTIFACT_BYTES,
DEFAULT_MEMORY_TMPFS_SAFETY_MARGIN_BYTES,
OUTPUT_ARTIFACT_BASENAME,
OUTPUT_ARTIFACT_FILENAME,
OutputArtifactError,
OutputWorkspaceCleanupError,
OutputWorkspacePreparationError,
TemporaryOutputWorkspace,
TemporaryOutputWorkspaceFactory,
)

__all__ = [
"DEFAULT_MAXIMUM_ACCEPTED_CONSOLE_BYTES",
"DEFAULT_MAXIMUM_ACCEPTED_OUTPUT_BYTES",
"DEFAULT_MAXIMUM_ARTIFACT_BYTES",
"DEFAULT_MEMORY_TMPFS_SAFETY_MARGIN_BYTES",
"DEFAULT_TIMEOUT_SECONDS",
"CredentialCleanupError",
"OUTPUT_ARTIFACT_BASENAME",
"OUTPUT_ARTIFACT_FILENAME",
"OutputArtifactError",
"OutputWorkspaceCleanupError",
"OutputWorkspacePreparationError",
"ProwlerClient",
"ProwlerClientConsumedError",
"ProwlerClientFactory",
"TemporaryCredentialLease",
"TemporaryCredentialLeaseFactory",
"TemporaryOutputWorkspace",
"TemporaryOutputWorkspaceFactory",
]
236 changes: 236 additions & 0 deletions prowler/prowler/_core/prowler_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"""Synchronous Prowler client over the safe CLI engine."""

import logging
from collections.abc import Sequence
from dataclasses import replace
from threading import Lock
from time import monotonic

from prowler._core.cli_engine import (
CommandResult,
OutputSpecification,
ValidatedCommandRequest,
)
from prowler.models.configs.config_loader import ProwlerConfig
from prowler.models.provider_inputs import ProviderInput

from .contracts import CliEnginePort, OutputWorkspaceFactoryPort
from .credentials import CredentialCleanupError
from .output_workspace import (
DEFAULT_MAXIMUM_ARTIFACT_BYTES,
OUTPUT_ARTIFACT_BASENAME,
OutputArtifactError,
OutputWorkspaceCleanupError,
)
from .provider_adapter import ProviderInvocationAdapter

# A full multi-provider assessment may legitimately run for a substantial period.
DEFAULT_TIMEOUT_SECONDS = 3_600.0
# Console output is diagnostic only; OCSF records have a separate artifact bound.
DEFAULT_MAXIMUM_ACCEPTED_CONSOLE_BYTES = 4 * 1024 * 1024
# Compatibility alias retained for callers that imported the former console bound.
DEFAULT_MAXIMUM_ACCEPTED_OUTPUT_BYTES = DEFAULT_MAXIMUM_ACCEPTED_CONSOLE_BYTES

_ALL_SEVERITIES = (
"--severity",
"critical",
"high",
"medium",
"low",
"informational",
)
_OUTPUT_ARGUMENTS_PREFIX = (
"--output-filename",
OUTPUT_ARTIFACT_BASENAME,
"-z",
"--only-logs",
"--no-color",
)
_OUTPUT_FORMAT_ARGUMENTS = ("-M", "json-ocsf")
_NARROWING_OPTIONS = frozenset(
{
"-c",
"--check",
"--checks",
"-s",
"--service",
"--services",
"--compliance",
}
)
_LOGGER = logging.getLogger(__name__)


def _safe_log(level: int, message: str, **metadata: object) -> None:
"""Best-effort fixed logging whose failures cannot affect execution."""
try:
_LOGGER.log(level, message, extra={"prowler_metadata": metadata})
except BaseException:
return


class ProwlerClientConsumedError(RuntimeError):
"""Reject reuse of a client whose provider input was already consumed."""


class ProwlerClient:
"""Run one frozen provider input synchronously through Prowler."""

def __init__(
self,
*,
config: ProwlerConfig,
provider: ProviderInput,
engine: CliEnginePort,
provider_adapter: ProviderInvocationAdapter,
output_workspace_factory: OutputWorkspaceFactoryPort,
) -> None:
self._config = config.model_copy(deep=True)
self._provider: ProviderInput | None = provider.model_copy(deep=True)
self._engine = engine
self._provider_adapter = provider_adapter
self._output_workspace_factory = output_workspace_factory
self._consumption_lock = Lock()

def run(self, check_filters: Sequence[str] = ()) -> CommandResult:
"""Run one assessment and capture its controlled OCSF artifact."""
with self._consumption_lock:
provider = self._provider
if provider is None:
raise ProwlerClientConsumedError(
"this Prowler client has already been consumed"
)
self._provider = None

invocation = None
workspace = None
result: CommandResult | None = None
primary_error: BaseException | None = None
try:
filters = tuple(check_filters)
if any(not isinstance(item, str) or not item.strip() for item in filters):
raise ValueError("check filters must be nonblank strings")

_safe_log(logging.INFO, "Preparing Prowler output workspace")
workspace = self._output_workspace_factory.create()
_safe_log(
logging.DEBUG,
"Prowler output workspace metadata",
backend=workspace.backend,
provider=provider.provider,
check_selector=bool(filters),
)

invocation = self._provider_adapter.adapt(provider)
filter_arguments = ("-c", *filters) if filters else ()
provider_and_selectors = (*invocation.arguments, *filter_arguments)
narrowed = any(
argument in _NARROWING_OPTIONS for argument in provider_and_selectors
)
_safe_log(
logging.DEBUG,
"Prowler selector metadata",
selector_present=narrowed,
)
severity_arguments = () if narrowed else _ALL_SEVERITIES
request = ValidatedCommandRequest(
executable=str(self._config.executable_path),
arguments=(
*provider_and_selectors,
*severity_arguments,
"--output-directory",
str(workspace.directory),
*_OUTPUT_ARGUMENTS_PREFIX,
*_OUTPUT_FORMAT_ARGUMENTS,
),
environment=invocation.environment,
working_directory=None,
input_bytes=b"",
output=OutputSpecification(parser="raw"),
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
maximum_accepted_output_bytes=DEFAULT_MAXIMUM_ACCEPTED_CONSOLE_BYTES,
)
started = monotonic()
try:
result = self._engine.run(request)
finally:
duration_ms = max(0, int((monotonic() - started) * 1000))
_safe_log(logging.INFO, "Prowler process completed")
_safe_log(
logging.DEBUG,
"Prowler process metadata",
duration_ms=duration_ms,
return_code=(result.return_code if result is not None else None),
stdout_bytes=(len(result.stdout) if result is not None else 0),
stderr_bytes=(len(result.stderr) if result is not None else 0),
)
del request

if result.error is not None or result.return_code != 0:
return result

try:
artifact = workspace.read_artifact(
maximum_bytes=DEFAULT_MAXIMUM_ARTIFACT_BYTES
)
except OutputArtifactError as error:
_safe_log(
logging.ERROR,
"Prowler output artifact capture failed",
kind=error.kind,
)
raise
_safe_log(logging.INFO, "Prowler output artifact captured")
_safe_log(
logging.DEBUG,
"Prowler output artifact metadata",
artifact_bytes=len(artifact),
)
return replace(result, parsed=artifact)
except BaseException as error:
primary_error = error
raise
finally:
cleanup_failures: list[tuple[str, RuntimeError]] = []
if invocation is not None:
for lease in invocation.credential_leases:
try:
lease.cleanup()
except BaseException:
cleanup_failures.append(
("credential", CredentialCleanupError())
)
if workspace is not None:
try:
workspace.cleanup()
except BaseException:
cleanup_failures.append(
("output_workspace", OutputWorkspaceCleanupError())
)
else:
_safe_log(logging.INFO, "Prowler output workspace cleaned")

if cleanup_failures:
result_is_primary_failure = result is not None and (
result.error is not None or result.return_code != 0
)
for resource, _cleanup_error in cleanup_failures:
_safe_log(
logging.WARNING,
"Secondary Prowler output cleanup failure",
resource=resource,
)
if primary_error is not None:
for resource, _cleanup_error in cleanup_failures:
note = (
"temporary credential cleanup also failed"
if resource == "credential"
else "temporary output workspace cleanup also failed"
)
primary_error.add_note(note)
elif not result_is_primary_failure:
raise cleanup_failures[0][1] from None

del provider
if invocation is not None:
del invocation
Loading
Loading