first commit
This commit is contained in:
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
76
core/base.py
Normal file
76
core/base.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.plugin_types import EmptyPluginConfig
|
||||
from core.schemas.results import AuditResults
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.schemas.config import ResolvedPluginConfig
|
||||
from reporting.models import AuditReport, RenderedReport
|
||||
|
||||
TReportTransport = TypeVar("TReportTransport", bound=BaseModel)
|
||||
TWorkerConfig = TypeVar("TWorkerConfig", bound=BaseModel)
|
||||
|
||||
|
||||
class BaseWorker(ABC):
|
||||
config_model: type[BaseModel] = EmptyPluginConfig
|
||||
|
||||
def __init__(self, config: BaseModel | dict[str, Any] | None = None) -> None:
|
||||
"""
|
||||
Config dict will be provided on initialization, providing settings, parsed from config.yaml
|
||||
"""
|
||||
self.raw_config = config
|
||||
self.config: BaseModel | dict[str, Any]
|
||||
if isinstance(config, BaseModel):
|
||||
self.config = config.model_dump(mode="python")
|
||||
else:
|
||||
self.config = config or {}
|
||||
|
||||
@abstractmethod
|
||||
def run(self) -> AuditResults:
|
||||
"""
|
||||
Executes the scanning.
|
||||
Method should return AuditResults object, otherwise the output won't be processed.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class BaseRenderer(ABC):
|
||||
content_type: str
|
||||
media_type: str
|
||||
file_extension: str
|
||||
aliases: tuple[str, ...] = ()
|
||||
config_model: type[BaseModel] = EmptyPluginConfig
|
||||
|
||||
@abstractmethod
|
||||
def render(self, report: AuditReport, config: BaseModel) -> RenderedReport: ...
|
||||
|
||||
|
||||
class BaseTransport(ABC):
|
||||
transport_type: str
|
||||
config_model: type[BaseModel] = EmptyPluginConfig
|
||||
|
||||
@abstractmethod
|
||||
def publish(self, rendered_report: RenderedReport, transport: ResolvedPluginConfig) -> str: ...
|
||||
|
||||
|
||||
class TypedTransport(BaseTransport, Generic[TReportTransport], ABC):
|
||||
config_model: type[TReportTransport]
|
||||
|
||||
def publish(self, rendered_report: RenderedReport, transport: ResolvedPluginConfig) -> str:
|
||||
if not isinstance(transport.config, self.config_model):
|
||||
raise TypeError(
|
||||
f"Transport '{self.transport_type}' expected {self.config_model.__name__}, "
|
||||
f"got {type(transport.config).__name__}"
|
||||
)
|
||||
|
||||
return self.publish_typed(rendered_report, cast(TReportTransport, transport.config))
|
||||
|
||||
@abstractmethod
|
||||
def publish_typed(
|
||||
self, rendered_report: RenderedReport, transport: TReportTransport
|
||||
) -> str: ...
|
||||
26
core/evaluation.py
Normal file
26
core/evaluation.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from core.schemas.config import Config, ResolvedConfig
|
||||
from core.schemas.results import AuditResults
|
||||
from core.schemas.status import AuditSeverity
|
||||
|
||||
SEVERITY_WEIGHTS = {
|
||||
AuditSeverity.LOW: 1,
|
||||
AuditSeverity.MEDIUM: 2,
|
||||
AuditSeverity.HIGH: 3,
|
||||
AuditSeverity.CRITICAL: 4,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_exit_code(results: list[AuditResults], config: Config | ResolvedConfig) -> int:
|
||||
severity_threshold = SEVERITY_WEIGHTS.get(
|
||||
config.allow_failures_below, SEVERITY_WEIGHTS[AuditSeverity.HIGH]
|
||||
)
|
||||
|
||||
threshold_crossed = any(
|
||||
SEVERITY_WEIGHTS.get(finding.severity, 1) >= severity_threshold
|
||||
for result in results
|
||||
for finding in result.findings
|
||||
)
|
||||
|
||||
if threshold_crossed and config.strict_mode:
|
||||
return 1
|
||||
return 0
|
||||
199
core/module_loader.py
Normal file
199
core/module_loader.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from importlib.metadata import entry_points
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.base import BaseWorker
|
||||
from core.plugin_types import HYDROGEN_API_VERSION, EmptyPluginConfig
|
||||
from core.schemas import ModuleManifest
|
||||
from core.schemas.config import Config, ResolvedConfig
|
||||
from core.schemas.modules import LoadedModule, LoadedWorker, ModuleLoadResult
|
||||
from reporting.models import PluginRuntimeError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_module(package_name: str) -> LoadedModule | None:
|
||||
module = importlib.import_module(package_name)
|
||||
raw_manifest = getattr(module, "MANIFEST", None)
|
||||
|
||||
if raw_manifest is None:
|
||||
logger.warning("module package %s does not export MANIFEST", package_name)
|
||||
return None
|
||||
|
||||
manifest = ModuleManifest.model_validate(raw_manifest)
|
||||
if manifest.api_version != HYDROGEN_API_VERSION:
|
||||
raise ValueError(
|
||||
f"module package {package_name} targets api_version={manifest.api_version}, "
|
||||
f"expected {HYDROGEN_API_VERSION}"
|
||||
)
|
||||
|
||||
builder = getattr(module, "build_worker", None)
|
||||
|
||||
if builder is None:
|
||||
logger.warning("module package %s does not export build_worker", package_name)
|
||||
return None
|
||||
|
||||
if not callable(builder):
|
||||
logger.warning("build_worker in package %s is not callable", package_name)
|
||||
return None
|
||||
|
||||
config_model = getattr(module, "CONFIG_MODEL", None)
|
||||
if config_model is None:
|
||||
config_model = getattr(getattr(builder, "__self__", None), "config_model", None)
|
||||
if config_model is None:
|
||||
config_model = EmptyPluginConfig
|
||||
if not isinstance(config_model, type) or not issubclass(config_model, BaseModel):
|
||||
raise TypeError(f"module package {package_name} exports invalid CONFIG_MODEL")
|
||||
|
||||
return LoadedModule(
|
||||
package_name=package_name,
|
||||
manifest=manifest,
|
||||
build_worker=builder,
|
||||
config_model=config_model,
|
||||
)
|
||||
|
||||
|
||||
def build_worker(
|
||||
loaded_module: LoadedModule, config: BaseModel | dict[str, Any]
|
||||
) -> LoadedWorker | None:
|
||||
worker = loaded_module.build_worker(config)
|
||||
if not isinstance(worker, BaseWorker):
|
||||
logger.warning(
|
||||
"build_worker in package %s did not return BaseWorker", loaded_module.package_name
|
||||
)
|
||||
return None
|
||||
|
||||
return LoadedWorker(worker=worker, manifest=loaded_module.manifest, config=config)
|
||||
|
||||
|
||||
def discover_modules(path: Path, config: Config, package_prefix: str = "modules") -> list[str]:
|
||||
package_names: list[str] = []
|
||||
|
||||
if path.exists():
|
||||
for entry in path.iterdir():
|
||||
if not entry.is_dir() or entry.name.startswith("__"):
|
||||
continue
|
||||
if not (entry / "__init__.py").exists():
|
||||
continue
|
||||
package_names.append(f"{package_prefix}.{entry.name}")
|
||||
|
||||
package_names.extend(config.plugin_packages.modules)
|
||||
|
||||
try:
|
||||
module_entry_points = entry_points(group="hydrogen.modules")
|
||||
except TypeError:
|
||||
module_entry_points = entry_points().select(group="hydrogen.modules")
|
||||
|
||||
for module_entry_point in module_entry_points:
|
||||
package_names.append(module_entry_point.value.partition(":")[0])
|
||||
|
||||
return list(dict.fromkeys(package_names))
|
||||
|
||||
|
||||
def load_modules(path: Path, config: Config, package_prefix: str = "modules") -> ModuleLoadResult:
|
||||
workers: list[LoadedWorker] = []
|
||||
errors: list[PluginRuntimeError] = []
|
||||
|
||||
for package_name in discover_modules(path, config, package_prefix):
|
||||
try:
|
||||
module = load_module(package_name)
|
||||
if module is None:
|
||||
continue
|
||||
|
||||
if module.manifest.category in config.exclude_categories:
|
||||
logger.info(
|
||||
"module %s was skipped due to config exclusion", module.manifest.identifier
|
||||
)
|
||||
continue
|
||||
|
||||
module_config = config.modules.get(module.manifest.identifier, {})
|
||||
validated_config = module.config_model.model_validate(module_config)
|
||||
worker = build_worker(module, validated_config)
|
||||
if worker is not None:
|
||||
workers.append(worker)
|
||||
logger.info("module %s has been successfully loaded!", module.manifest.identifier)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to load module package %s", package_name)
|
||||
errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=package_name,
|
||||
stage="load",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return ModuleLoadResult(loaded_workers=workers, errors=errors)
|
||||
|
||||
|
||||
def load_prevalidated_workers(
|
||||
loaded_modules: list[LoadedModule],
|
||||
resolved_config: ResolvedConfig,
|
||||
) -> ModuleLoadResult:
|
||||
workers: list[LoadedWorker] = []
|
||||
errors: list[PluginRuntimeError] = []
|
||||
|
||||
for loaded_module in loaded_modules:
|
||||
if loaded_module.manifest.category in resolved_config.exclude_categories:
|
||||
continue
|
||||
|
||||
module_config = resolved_config.modules.get(loaded_module.manifest.identifier)
|
||||
if module_config is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
worker = build_worker(loaded_module, module_config)
|
||||
if worker is None:
|
||||
errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=loaded_module.manifest.identifier,
|
||||
stage="build_worker",
|
||||
message="build_worker returned an invalid worker instance",
|
||||
)
|
||||
)
|
||||
continue
|
||||
workers.append(worker)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to build worker for module %s", loaded_module.package_name)
|
||||
errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=loaded_module.manifest.identifier,
|
||||
stage="build_worker",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return ModuleLoadResult(loaded_workers=workers, errors=errors)
|
||||
|
||||
|
||||
def load_module_descriptors(
|
||||
path: Path, config: Config, package_prefix: str = "modules"
|
||||
) -> tuple[list[LoadedModule], list[PluginRuntimeError]]:
|
||||
modules: list[LoadedModule] = []
|
||||
errors: list[PluginRuntimeError] = []
|
||||
|
||||
for package_name in discover_modules(path, config, package_prefix):
|
||||
try:
|
||||
module = load_module(package_name)
|
||||
if module is not None:
|
||||
modules.append(module)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to discover module package %s", package_name)
|
||||
errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=package_name,
|
||||
stage="discovery",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return modules, errors
|
||||
155
core/plugin_types.py
Normal file
155
core/plugin_types.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from importlib.metadata import entry_points
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TPlugin = TypeVar("TPlugin", bound=object)
|
||||
|
||||
HYDROGEN_API_VERSION = "1"
|
||||
|
||||
|
||||
class EmptyPluginConfig(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadError:
|
||||
plugin_kind: str
|
||||
source: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginDescriptor:
|
||||
plugin: object
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginGroupConfig:
|
||||
package_names: list[str]
|
||||
entry_point_group: str
|
||||
plugin_kind: str
|
||||
|
||||
|
||||
def discover_plugins(
|
||||
group_config: PluginGroupConfig,
|
||||
base_class: type[TPlugin],
|
||||
) -> tuple[list[PluginDescriptor], list[PluginLoadError]]:
|
||||
descriptors: list[PluginDescriptor] = []
|
||||
errors: list[PluginLoadError] = []
|
||||
|
||||
for package_name in group_config.package_names:
|
||||
try:
|
||||
for plugin_class in _discover_plugin_classes(package_name, base_class):
|
||||
descriptors.append(
|
||||
PluginDescriptor(plugin=plugin_class(), source=f"package:{package_name}")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"failed to discover %s package %s", group_config.plugin_kind, package_name
|
||||
)
|
||||
errors.append(
|
||||
PluginLoadError(
|
||||
plugin_kind=group_config.plugin_kind,
|
||||
source=f"package:{package_name}",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
available_entry_points = entry_points(group=group_config.entry_point_group)
|
||||
except TypeError:
|
||||
available_entry_points = entry_points().select(group=group_config.entry_point_group)
|
||||
|
||||
for entry_point in available_entry_points:
|
||||
try:
|
||||
candidate = entry_point.load()
|
||||
plugin = _instantiate_entry_point_plugin(candidate, base_class)
|
||||
descriptors.append(
|
||||
PluginDescriptor(
|
||||
plugin=plugin,
|
||||
source=f"entry-point:{group_config.entry_point_group}:{entry_point.name}",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"failed to load %s entry point %s",
|
||||
group_config.plugin_kind,
|
||||
entry_point.name,
|
||||
)
|
||||
errors.append(
|
||||
PluginLoadError(
|
||||
plugin_kind=group_config.plugin_kind,
|
||||
source=f"entry-point:{group_config.entry_point_group}:{entry_point.name}",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return descriptors, errors
|
||||
|
||||
|
||||
def ensure_plugin_config_model(plugin: object) -> type[BaseModel]:
|
||||
config_model = getattr(plugin, "config_model", EmptyPluginConfig)
|
||||
if not inspect.isclass(config_model) or not issubclass(config_model, BaseModel):
|
||||
raise TypeError(f"plugin {type(plugin).__name__} has invalid config_model")
|
||||
return config_model
|
||||
|
||||
|
||||
def _instantiate_entry_point_plugin(candidate: Any, base_class: type[TPlugin]) -> TPlugin:
|
||||
if inspect.isclass(candidate):
|
||||
if not issubclass(candidate, base_class):
|
||||
raise TypeError(f"{candidate.__name__} is not a {base_class.__name__}")
|
||||
if inspect.isabstract(candidate):
|
||||
raise TypeError(f"{candidate.__name__} is abstract")
|
||||
return candidate()
|
||||
|
||||
if not isinstance(candidate, base_class):
|
||||
raise TypeError(f"entry point did not return {base_class.__name__}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _discover_plugin_classes(
|
||||
package_name: str, base_class: type[TPlugin]
|
||||
) -> tuple[type[TPlugin], ...]:
|
||||
package = import_module(package_name)
|
||||
modules = [package, *(_load_modules(package))]
|
||||
discovered: list[type[TPlugin]] = []
|
||||
seen: set[type[object]] = set()
|
||||
|
||||
for module in modules:
|
||||
for _, candidate in inspect.getmembers(module, inspect.isclass):
|
||||
if candidate in seen:
|
||||
continue
|
||||
if candidate is base_class or not issubclass(candidate, base_class):
|
||||
continue
|
||||
if inspect.isabstract(candidate):
|
||||
continue
|
||||
if candidate.__module__ != module.__name__:
|
||||
continue
|
||||
|
||||
seen.add(candidate)
|
||||
discovered.append(candidate)
|
||||
|
||||
return tuple(discovered)
|
||||
|
||||
|
||||
def _load_modules(package: ModuleType) -> list[ModuleType]:
|
||||
if not hasattr(package, "__path__"):
|
||||
return []
|
||||
|
||||
modules: list[ModuleType] = []
|
||||
for module_info in pkgutil.walk_packages(package.__path__, prefix=f"{package.__name__}."):
|
||||
modules.append(import_module(module_info.name))
|
||||
|
||||
return modules
|
||||
171
core/runner.py
Normal file
171
core/runner.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
|
||||
from core.evaluation import evaluate_exit_code
|
||||
from core.module_loader import load_module_descriptors, load_prevalidated_workers
|
||||
from core.runtime import resolve_config, validate_reporting_plugins
|
||||
from core.schemas.config import Config
|
||||
from core.schemas.results import AuditResults
|
||||
from core.schemas.status import AuditStatus
|
||||
from reporting.bootstrap import bootstrap_reporting
|
||||
from reporting.models import ModuleAuditResult, ModuleExecutionStats, PluginRuntimeError
|
||||
from reporting.service import ReportService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_audit(modules_path: Path, config: Config, package_prefix: str = "modules") -> int:
|
||||
_configure_logging(config)
|
||||
|
||||
loaded_modules, module_discovery_errors = load_module_descriptors(
|
||||
modules_path, config, package_prefix
|
||||
)
|
||||
reporting_bootstrap = bootstrap_reporting(config.plugin_packages)
|
||||
reporting_validation_errors = validate_reporting_plugins(
|
||||
reporting_bootstrap.renderer_registry,
|
||||
reporting_bootstrap.transport_registry,
|
||||
)
|
||||
resolved_config, config_errors = resolve_config(
|
||||
config,
|
||||
loaded_modules,
|
||||
reporting_bootstrap.renderer_registry,
|
||||
reporting_bootstrap.transport_registry,
|
||||
reporting_bootstrap.errors,
|
||||
)
|
||||
|
||||
worker_load_result = load_prevalidated_workers(loaded_modules, resolved_config)
|
||||
plugin_errors = [
|
||||
*module_discovery_errors,
|
||||
*reporting_validation_errors,
|
||||
*config_errors,
|
||||
*worker_load_result.errors,
|
||||
]
|
||||
|
||||
results = _run_workers(worker_load_result.loaded_workers, resolved_config)
|
||||
exit_code = evaluate_exit_code([r.result for r in results], resolved_config)
|
||||
report_service = ReportService(
|
||||
renderer_registry=reporting_bootstrap.renderer_registry,
|
||||
transport_registry=reporting_bootstrap.transport_registry,
|
||||
)
|
||||
report = report_service.build_report(results, exit_code, plugin_errors)
|
||||
|
||||
if resolved_config.dry_run:
|
||||
logger.info("dry_run enabled, skipping report publish")
|
||||
else:
|
||||
publish_errors = _publish_reports(report_service, report, resolved_config.reports)
|
||||
if publish_errors:
|
||||
report.plugin_errors.extend(publish_errors)
|
||||
|
||||
return report.exit_code
|
||||
|
||||
|
||||
def _run_workers(workers: list, config) -> list[ModuleAuditResult]:
|
||||
if not workers:
|
||||
return []
|
||||
|
||||
indexed_workers = list(enumerate(workers))
|
||||
results_by_index: dict[int, ModuleAuditResult] = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=config.max_concurrency) as executor:
|
||||
pending: dict[Future[ModuleAuditResult], int] = {}
|
||||
next_index = 0
|
||||
|
||||
while next_index < len(indexed_workers) and len(pending) < config.max_concurrency:
|
||||
index, worker = indexed_workers[next_index]
|
||||
pending[executor.submit(_run_single_worker, worker)] = index
|
||||
next_index += 1
|
||||
|
||||
stop_submitting = False
|
||||
while pending:
|
||||
done, _ = wait(tuple(pending), return_when=FIRST_COMPLETED)
|
||||
for future in done:
|
||||
index = pending.pop(future)
|
||||
result = future.result()
|
||||
results_by_index[index] = result
|
||||
|
||||
if config.fail_fast and result.result.status is AuditStatus.FAIL:
|
||||
stop_submitting = True
|
||||
|
||||
while (
|
||||
not stop_submitting
|
||||
and next_index < len(indexed_workers)
|
||||
and len(pending) < config.max_concurrency
|
||||
):
|
||||
index, worker = indexed_workers[next_index]
|
||||
pending[executor.submit(_run_single_worker, worker)] = index
|
||||
next_index += 1
|
||||
|
||||
return [results_by_index[index] for index in sorted(results_by_index)]
|
||||
|
||||
|
||||
def _run_single_worker(worker) -> ModuleAuditResult:
|
||||
started_at = perf_counter()
|
||||
try:
|
||||
result = worker.worker.run()
|
||||
duration_seconds = perf_counter() - started_at
|
||||
counts = Counter(finding.severity for finding in result.findings)
|
||||
return ModuleAuditResult(
|
||||
module=worker.manifest,
|
||||
result=result,
|
||||
stats=ModuleExecutionStats(
|
||||
duration_seconds=duration_seconds, finding_counts=dict(counts)
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("worker %s failed during execution", worker.manifest.identifier)
|
||||
duration_seconds = perf_counter() - started_at
|
||||
failed_result = AuditResults(status=AuditStatus.FAIL, findings=[], risk_level=1.0)
|
||||
return ModuleAuditResult(
|
||||
module=worker.manifest,
|
||||
result=failed_result,
|
||||
stats=ModuleExecutionStats(duration_seconds=duration_seconds, finding_counts={}),
|
||||
error=PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=worker.manifest.identifier,
|
||||
stage="run",
|
||||
message=str(exc),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configure_logging(config: Config) -> None:
|
||||
level_name = config.logging.level.upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
root_logger = logging.getLogger()
|
||||
stream = sys.stdout if config.logging.output.value == "stdout" else sys.stderr
|
||||
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
||||
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(level)
|
||||
|
||||
|
||||
def _publish_reports(report_service, report, outputs) -> list[PluginRuntimeError]:
|
||||
errors: list[PluginRuntimeError] = []
|
||||
for output in outputs:
|
||||
try:
|
||||
report_service.publish(report, output)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"failed to publish output %s via %s",
|
||||
output.renderer.type,
|
||||
output.transport.type,
|
||||
)
|
||||
errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="report_output",
|
||||
plugin_name=f"{output.renderer.type}->{output.transport.type}",
|
||||
stage="publish",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
return errors
|
||||
148
core/runtime.py
Normal file
148
core/runtime.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from core.base import BaseRenderer, BaseTransport
|
||||
from core.plugin_types import PluginLoadError, ensure_plugin_config_model
|
||||
from core.schemas.config import Config, ResolvedConfig, ResolvedPluginConfig, ResolvedReportOutput
|
||||
from core.schemas.modules import LoadedModule
|
||||
from reporting.models import PluginRuntimeError
|
||||
from reporting.registry import RendererRegistry
|
||||
from reporting.transport_registry import TransportRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_config(
|
||||
config: Config,
|
||||
loaded_modules: list[LoadedModule],
|
||||
renderer_registry: RendererRegistry,
|
||||
transport_registry: TransportRegistry,
|
||||
plugin_errors: list[PluginLoadError] | None = None,
|
||||
) -> tuple[ResolvedConfig, list[PluginRuntimeError]]:
|
||||
runtime_errors = [_plugin_load_error_to_runtime_error(error) for error in plugin_errors or ()]
|
||||
resolved_modules: dict[str, BaseModel] = {}
|
||||
seen_module_ids: set[str] = set()
|
||||
|
||||
for module in loaded_modules:
|
||||
if module.manifest.identifier in seen_module_ids:
|
||||
runtime_errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=module.manifest.identifier,
|
||||
stage="discovery",
|
||||
message="duplicate module identifier",
|
||||
)
|
||||
)
|
||||
continue
|
||||
seen_module_ids.add(module.manifest.identifier)
|
||||
|
||||
raw_module_config = config.modules.get(module.manifest.identifier, {})
|
||||
try:
|
||||
resolved_modules[module.manifest.identifier] = module.config_model.model_validate(
|
||||
raw_module_config
|
||||
)
|
||||
except ValidationError as exc:
|
||||
runtime_errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="module",
|
||||
plugin_name=module.manifest.identifier,
|
||||
stage="config_validation",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
resolved_outputs: list[ResolvedReportOutput] = []
|
||||
for output in config.reports.outputs:
|
||||
try:
|
||||
renderer = renderer_registry.get(output.renderer.type)
|
||||
transport = transport_registry.get(output.transport.type)
|
||||
resolved_outputs.append(
|
||||
ResolvedReportOutput(
|
||||
renderer=_resolve_plugin_config(
|
||||
output.renderer.type, output.renderer.payload(), renderer
|
||||
),
|
||||
transport=_resolve_plugin_config(
|
||||
output.transport.type, output.transport.payload(), transport
|
||||
),
|
||||
)
|
||||
)
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
runtime_errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind="report_output",
|
||||
plugin_name=f"{output.renderer.type}->{output.transport.type}",
|
||||
stage="config_validation",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
resolved_config = ResolvedConfig(
|
||||
exclude_categories=config.exclude_categories,
|
||||
allow_failures_below=config.allow_failures_below,
|
||||
strict_mode=config.strict_mode,
|
||||
fail_fast=config.fail_fast,
|
||||
dry_run=config.dry_run,
|
||||
max_concurrency=config.max_concurrency,
|
||||
logging=config.logging,
|
||||
plugin_packages=config.plugin_packages,
|
||||
reports=resolved_outputs,
|
||||
modules=resolved_modules,
|
||||
)
|
||||
return resolved_config, runtime_errors
|
||||
|
||||
|
||||
def validate_reporting_plugins(
|
||||
renderer_registry: RendererRegistry,
|
||||
transport_registry: TransportRegistry,
|
||||
) -> list[PluginRuntimeError]:
|
||||
errors: list[PluginRuntimeError] = []
|
||||
|
||||
errors.extend(_validate_registry_plugins("renderer", renderer_registry._renderers.values()))
|
||||
errors.extend(_validate_registry_plugins("transport", transport_registry._transports.values()))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_registry_plugins(plugin_kind: str, plugins: object) -> list[PluginRuntimeError]:
|
||||
seen: set[type[object]] = set()
|
||||
errors: list[PluginRuntimeError] = []
|
||||
|
||||
for plugin in plugins:
|
||||
plugin_type = type(plugin)
|
||||
if plugin_type in seen:
|
||||
continue
|
||||
seen.add(plugin_type)
|
||||
try:
|
||||
ensure_plugin_config_model(plugin)
|
||||
except TypeError as exc:
|
||||
errors.append(
|
||||
PluginRuntimeError(
|
||||
plugin_kind=plugin_kind,
|
||||
plugin_name=plugin_type.__name__,
|
||||
stage="plugin_validation",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _resolve_plugin_config(
|
||||
plugin_type: str,
|
||||
payload: dict[str, object],
|
||||
plugin: BaseRenderer | BaseTransport,
|
||||
) -> ResolvedPluginConfig:
|
||||
config_model = ensure_plugin_config_model(plugin)
|
||||
return ResolvedPluginConfig(type=plugin_type, config=config_model.model_validate(payload))
|
||||
|
||||
|
||||
def _plugin_load_error_to_runtime_error(error: PluginLoadError) -> PluginRuntimeError:
|
||||
return PluginRuntimeError(
|
||||
plugin_kind=error.plugin_kind,
|
||||
plugin_name=error.source,
|
||||
stage="discovery",
|
||||
message=error.message,
|
||||
)
|
||||
5
core/schemas/__init__.py
Normal file
5
core/schemas/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .manifest import ModuleManifest
|
||||
from .results import AuditFindings, AuditResults
|
||||
from .status import AuditSeverity, AuditStatus
|
||||
|
||||
__all__ = ["AuditFindings", "AuditResults", "AuditSeverity", "AuditStatus", "ModuleManifest"]
|
||||
83
core/schemas/config.py
Normal file
83
core/schemas/config.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from core.schemas.status import AuditSeverity
|
||||
|
||||
|
||||
class LoggingOutput(StrEnum):
|
||||
STDOUT = "stdout"
|
||||
STDERR = "stderr"
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
level: str = Field("INFO", min_length=1)
|
||||
output: LoggingOutput = Field(LoggingOutput.STDOUT)
|
||||
|
||||
|
||||
class PluginPackagesConfig(BaseModel):
|
||||
renderers: list[str] = Field(default_factory=lambda: ["reporting.exporters"])
|
||||
transports: list[str] = Field(default_factory=lambda: ["reporting.transports"])
|
||||
modules: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginConfigRef(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
type: str = Field(min_length=1)
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return dict(self.model_extra or {})
|
||||
|
||||
|
||||
class ReportOutputConfig(BaseModel):
|
||||
renderer: PluginConfigRef = Field()
|
||||
transport: PluginConfigRef = Field()
|
||||
|
||||
|
||||
class ReportsConfig(BaseModel):
|
||||
outputs: list[ReportOutputConfig] = Field(min_length=1)
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
exclude_categories: list[str] = Field(default_factory=list)
|
||||
|
||||
allow_failures_below: AuditSeverity = Field()
|
||||
strict_mode: bool = Field()
|
||||
fail_fast: bool = Field(False)
|
||||
dry_run: bool = Field(False)
|
||||
max_concurrency: int = Field(4, ge=1)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
plugin_packages: PluginPackagesConfig = Field(default_factory=PluginPackagesConfig)
|
||||
reports: ReportsConfig = Field()
|
||||
|
||||
modules: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedPluginConfig:
|
||||
type: str
|
||||
config: BaseModel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedReportOutput:
|
||||
renderer: ResolvedPluginConfig
|
||||
transport: ResolvedPluginConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedConfig:
|
||||
exclude_categories: list[str]
|
||||
allow_failures_below: AuditSeverity
|
||||
strict_mode: bool
|
||||
fail_fast: bool
|
||||
dry_run: bool
|
||||
max_concurrency: int
|
||||
logging: LoggingConfig
|
||||
plugin_packages: PluginPackagesConfig
|
||||
reports: list[ResolvedReportOutput]
|
||||
modules: dict[str, BaseModel]
|
||||
15
core/schemas/manifest.py
Normal file
15
core/schemas/manifest.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ModuleManifest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
identifier: str = Field(pattern=r"^[a-z][a-z0-9_-]*$")
|
||||
name: str = Field(min_length=1)
|
||||
category: str = Field(min_length=1)
|
||||
version: str = Field(pattern=r"^\d+\.\d+\.\d+$")
|
||||
api_version: str = Field("1", pattern=r"^\d+$")
|
||||
description: str = ""
|
||||
32
core/schemas/modules.py
Normal file
32
core/schemas/modules.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.base import BaseWorker
|
||||
from core.schemas import ModuleManifest
|
||||
from reporting.models import PluginRuntimeError
|
||||
|
||||
WorkerBuilder = Callable[[BaseModel | dict[str, Any]], BaseWorker]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedModule:
|
||||
package_name: str
|
||||
manifest: ModuleManifest
|
||||
build_worker: WorkerBuilder
|
||||
config_model: type[BaseModel]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedWorker:
|
||||
worker: BaseWorker
|
||||
manifest: ModuleManifest
|
||||
config: BaseModel | dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModuleLoadResult:
|
||||
loaded_workers: list[LoadedWorker]
|
||||
errors: list[PluginRuntimeError]
|
||||
17
core/schemas/results.py
Normal file
17
core/schemas/results.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.schemas.status import AuditSeverity, AuditStatus
|
||||
|
||||
|
||||
class AuditFindings(BaseModel):
|
||||
name: str = Field()
|
||||
description: str = Field("")
|
||||
severity: AuditSeverity = Field()
|
||||
|
||||
|
||||
class AuditResults(BaseModel):
|
||||
status: AuditStatus = Field()
|
||||
findings: list[AuditFindings] = Field()
|
||||
risk_level: float = Field(
|
||||
description="value from 0.0 to 1.0, indicating how dangerous the auditresults are."
|
||||
)
|
||||
14
core/schemas/status.py
Normal file
14
core/schemas/status.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AuditStatus(StrEnum):
|
||||
PASS = "pass"
|
||||
SKIPPED = "skipped"
|
||||
FAIL = "fail"
|
||||
|
||||
|
||||
class AuditSeverity(StrEnum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
Reference in New Issue
Block a user