Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
ceiling) is refused, logged at WARNING naming both registrants, and listed
by `darnit list` and in the warnings of an audit of that plugin's framework
(or a framework composing it); the existing step type is unchanged.
- One policy governs every import of a `module:attribute` path from
configuration (MCP tool handlers, handler references, Python adapters):
the module's top-level package must be `darnit` or the package of an
installed implementation, read from the `darnit.implementations` entry
points. Before, the MCP tool loader imported any module named in
`[mcp.tools]`, and the other loaders checked hardcoded prefix lists that
missed most shipped implementations. A refused path raises
`HandlerImportRefused` naming the path and the allowed packages; at server
start the tool is not registered and the refusal is logged at ERROR.
`HandlerRegistry.get_handler` raises it instead of returning `None` (#490).

### Removed

Expand All @@ -49,6 +59,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`darnit.config.context_resolve.resolve_context` (read) and
`darnit.config.context_writes` (write); `detect_ci_provider` is the one CI
detector.
- `ALLOWED_MODULE_PREFIXES` on `HandlerRegistry`, `PluginRegistry`, and
`AdapterRegistry`. The allowed packages come from installed
implementations; there is no list to extend (#490).

### Added

Expand Down
173 changes: 29 additions & 144 deletions THREAT_MODEL.md

Large diffs are not rendered by default.

38 changes: 14 additions & 24 deletions docs/IMPLEMENTATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1560,23 +1560,16 @@ Or by full module path:
handler = "darnit_mystandard.tools:audit_mystandard"
```

### Module allowlist security
### Module path policy

When handlers are referenced by `module:function` path in TOML, the registry only
allows imports from approved module prefixes. The default allowlist in
`packages/darnit/src/darnit/core/handlers.py`:

```python
ALLOWED_MODULE_PREFIXES = (
"darnit.",
"darnit_baseline.",
"darnit_testchecks.",
)
```

If your implementation uses `module:function` references, you'll need to add your
module prefix to this allowlist. Using short names (via `register_handler()`) avoids
this restriction entirely.
When a handler is referenced by `module:function` path in TOML, darnit imports it
only if the module's top-level package is `darnit` or the package of an installed
implementation, read from the `darnit.implementations` entry points. If your entry
point is `mystandard = "darnit_mystandard:register"`, any `darnit_mystandard.*`
module may be named; nothing needs to be added to darnit. Any other path is refused
with `HandlerImportRefused`, and an MCP tool that names one does not load
(framework-design.md 6.5). Short names (via `register_handler()`) need no import at
all and remain the recommended form.

> **Reference**: See `packages/darnit-baseline/src/darnit_baseline/implementation.py:92`
> for the OpenSSF Baseline's `register_handlers()` method and
Expand Down Expand Up @@ -1755,15 +1748,12 @@ def my_handler(config: dict, context: HandlerContext) -> HandlerResult:
)
```

### Module allowlist for dynamic loading

If you reference handlers by `module:function` path in TOML, the handler registry
enforces a module allowlist. Your module must start with an approved prefix
(`darnit.`, `darnit_baseline.`, `darnit_testchecks.`). For new implementations,
either:
### Module path policy for dynamic loading

1. Use short names via `register_handler()` (recommended), or
2. Add your module prefix to `HandlerRegistry.ALLOWED_MODULE_PREFIXES`
A `module:function` handler path must name a module in `darnit` or in your own
implementation's package (the module of your `darnit.implementations` entry point).
A path into any other package is refused at load time. Prefer short names via
`register_handler()`.

### TOML path resolution

Expand Down
81 changes: 14 additions & 67 deletions docs/SECURITY_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,67 +18,25 @@ This document describes security considerations, best practices, and configurati

## Dynamic Module Loading Security

Darnit uses dynamic module loading to instantiate adapters defined in configuration files. To prevent arbitrary code execution, **module paths are validated against a allowlist** before loading.
Darnit can import a Python attribute named in configuration as `package.module:attribute`: MCP tool handlers in `[mcp.tools]`, handler references, and `type = "python"` adapters. Every such import goes through one function, `darnit.core.handlers.resolve_module_path` (framework-design.md 6.5).

### Allowed Module Prefixes
### Resolution Policy

By default, only modules from these prefixes can be dynamically loaded:
- The path must be `a.b.c:attr`, with every part a Python identifier. Relative paths, empty parts, and dotted attributes are refused.
- The module's top-level package must be `darnit`, or the package of an implementation that discovery loaded from the `darnit.implementations` entry points (`darnit_csl:register` allows `darnit_csl.*`).
- The allowed set is derived from installed entry point metadata. There is no list to extend: installing an implementation package allows its modules, and nothing else does.
- The check runs before the import, so a refused module is never imported.

```python
ALLOWED_MODULE_PREFIXES = (
"darnit.",
"darnit_baseline.",
"darnit_plugins.",
"darnit_testchecks.",
)
```

### Security Implications

- **Configuration-defined adapters** must reference modules within the allowed prefixes
- **Malicious configurations** cannot load arbitrary Python code
- **Custom adapters** must be installed as proper Python packages with `darnit_` prefix

### Extending the Whitelist
### Failure Behavior

If you need to use custom adapters from your own packages, you have two options:
- A refused path raises `HandlerImportRefused` (a `ValueError`) naming the path and the allowed packages, logged at WARNING.
- At MCP server start, a tool whose handler is refused is not registered, and the server logs an ERROR naming the tool.

#### Option 1: Use the `darnit_` Prefix Convention (Recommended)

Name your custom adapter package with the `darnit_` prefix:

```
darnit_mycompany/
├── adapters/
│ └── custom.py
└── __init__.py
```

This automatically allows your module to be loaded:

```toml
# Framework TOML shipped in your plugin package
[adapters.mycompany]
type = "python"
module = "darnit_mycompany.adapters.custom"
class = "MyCustomAdapter"
```

#### Option 2: Modify the Whitelist (Advanced)

For enterprise deployments, you can subclass `AdapterRegistry` or `PluginRegistry` to extend the allowlist:

```python
from darnit.core.registry import PluginRegistry

class EnterprisePluginRegistry(PluginRegistry):
ALLOWED_MODULE_PREFIXES = PluginRegistry.ALLOWED_MODULE_PREFIXES + (
"mycompany.",
"mycompany_compliance.",
)
```
### Security Implications

> **Warning**: Extending the allowlist increases your attack surface. Only add trusted module prefixes.
- A configuration string cannot reach `os`, `subprocess`, or any other package that is not darnit or an installed implementation.
- The policy is not a sandbox. Allowed packages are code the operator installed, and `[mcp.tools]` is read only from an installed framework TOML or an operator-supplied `darnit serve <config.toml>`, never from the audited repository.
- To use your own adapter or tool module, ship it in a package registered under `darnit.implementations`.

---

Expand Down Expand Up @@ -437,18 +395,7 @@ dev_verifier = PluginVerifier(dev_config)

### Handler Registration Security

Plugins register handlers using the `@register_handler` decorator. Only modules matching the allowlist can register handlers.

#### Allowlist

```python
ALLOWED_MODULE_PREFIXES = (
"darnit.", # Core framework
"darnit_baseline.", # OpenSSF Baseline implementation
"darnit_plugins.", # Official plugins
"darnit_testchecks.",# Test utilities
)
```
Plugins register handlers using the `@register_handler` decorator or `register_handlers()`. Registration needs no import by name. A handler referenced by `module:function` path instead is resolved under the policy in [Dynamic Module Loading Security](#dynamic-module-loading-security).

#### Registering Handlers

Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/example-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,11 @@ The implementation SHALL provide a `register_handlers()` method that registers a
- **THEN** the returned path has filename `example-hygiene.toml` and `path.exists()` is `True`

### Requirement: Framework integration with minimal changes
The package SHALL integrate into the darnit workspace with only two framework-side changes: adding `"darnit_example."` to `ALLOWED_MODULE_PREFIXES` in handlers.py, and adding `darnit-example` to the root `pyproject.toml` workspace sources and ruff config.
The package SHALL integrate into the darnit workspace with only one framework-side change: adding `darnit-example` to the root `pyproject.toml` workspace sources and ruff config. Its modules are importable by handler path because `darnit_example` is the module of its `darnit.implementations` entry point (framework-design.md 6.5); no list in the framework names it.

#### Scenario: Module prefix allowlisted
#### Scenario: Module path permitted by the entry point
- **WHEN** handler resolution attempts to load a `darnit_example.*` module
- **THEN** the security allowlist permits the import
- **THEN** the module resolution policy permits the import

### Requirement: Documentation cross-references
The package README SHALL map each section of `docs/IMPLEMENTATION_GUIDE.md` to its corresponding example file. The implementation guide SHALL reference `packages/darnit-example/` as a working companion example.
Expand Down
30 changes: 24 additions & 6 deletions docs/architecture/framework-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1436,16 +1436,34 @@ handler = "my_audit" # Short name instead of "my_plugin.tools:my_audit"

### 6.5 Function Reference Security

TOML can reference Python functions via `module:function` syntax:
A TOML handler reference may name a Python attribute as `package.module:attribute` instead of a registered short name (Section 6.4):

```toml
api_check = "darnit_baseline.checks:check_branch_protection"
[mcp.tools.remediate_community_spec]
handler = "darnit_csl.mcp_tools:remediate_community_spec"
```

**Security Rules**:
- Only whitelisted module prefixes are allowed
- Base whitelist: `darnit.`, `darnit_baseline.`, `darnit_plugins.`
- Additional prefixes discovered from registered entry points
Every place that turns such a string into an import SHALL resolve it through one function, `darnit.core.handlers.resolve_module_path`. That covers MCP tool handlers (`ToolRegistry.load_handler`), handler-registry lookups (`HandlerRegistry.get_handler`), and Python adapter configuration (`PluginRegistry` and `AdapterRegistry`). No other code in the framework SHALL call `importlib.import_module` on a configured string.

**Resolution policy**:
- The path SHALL have the form `a.b.c:attr`: exactly one `:`, every dotted module part and the attribute a Python identifier. A relative path (leading `.`), an empty part, a dotted attribute, or any other form is refused.
- The module's top-level package SHALL be `darnit`, or the top-level package of an implementation that discovery loaded from the `darnit.implementations` entry point group (Section 6.2). The set is read from the entry points' module names (`darnit_csl:register` gives `darnit_csl`), not from a list in code, so an installed third-party implementation is covered and an uninstalled one is not. It is computed once per process with the implementation cache and recomputed when that cache is cleared.
- The policy is checked before any import. A refused module is never imported.

**Failure behavior**:
- A refused path SHALL raise `HandlerImportRefused` (a `ValueError`) whose message names the path and the allowed top-level packages, logged at WARNING. A refusal is an error, never "not found": `HandlerRegistry.get_handler` raises it rather than returning `None`.
- An allowed path whose module or attribute does not exist raises `ImportError` or `AttributeError`. `HandlerRegistry.get_handler` reports that as not found (`None`, with a warning).
- At MCP server start a refused tool handler SHALL NOT be registered, and the server SHALL log an ERROR naming the tool and the refused path. The remaining tools still load.

This policy limits which installed code a configured string can reach. It is not a sandbox: the allowed packages are code the operator installed, and `[mcp.tools]` comes only from an installed framework TOML or an operator-supplied `darnit serve <config.toml>`, never from the audited repository (Section 14).

#### Scenario: Shipped plugin tool by module path
- **WHEN** the community-spec server starts and its TOML names `darnit_csl.mcp_tools:remediate_community_spec`
- **THEN** the tool SHALL load, because `darnit_csl` is the module of the `community-spec` entry point

#### Scenario: Tool handler outside the policy
- **WHEN** an `[mcp.tools]` entry names `os:system`, or a module of a package that is not an installed implementation
- **THEN** the module SHALL NOT be imported, the tool SHALL NOT be registered, and server start SHALL report the refusal at ERROR

### 6.6 Plugin Verification with Sigstore

Expand Down
25 changes: 6 additions & 19 deletions packages/darnit/src/darnit/core/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
- Resolution functions for loading adapters from configuration
"""

import importlib
import json
import logging
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any

from darnit.core.handlers import resolve_module_path
from darnit.core.models import (
AdapterCapability,
CheckResult,
Expand Down Expand Up @@ -453,14 +453,6 @@ class AdapterRegistry:
# Config-based adapter definitions
_adapter_configs: dict[str, dict[str, Any]] = field(default_factory=dict)

# Allowed module prefixes for dynamic imports (security allowlist)
ALLOWED_MODULE_PREFIXES: tuple = (
"darnit.",
"darnit_baseline.",
"darnit_plugins.",
"darnit_testchecks.",
)

def register_check_adapter(
self,
name: str,
Expand Down Expand Up @@ -646,6 +638,10 @@ def _load_python_adapter(

Returns:
Adapter instance or None

Raises:
HandlerImportRefused: If the module is outside the module
resolution policy
"""
module_path = config.get("module")
class_name = config.get("class", "Adapter")
Expand All @@ -654,17 +650,8 @@ def _load_python_adapter(
logger.error(f"Adapter {name} missing 'module' in config")
return None

# Security: Validate module path against allowlist to prevent arbitrary code loading
if not any(module_path.startswith(prefix) for prefix in self.ALLOWED_MODULE_PREFIXES):
logger.error(
f"Adapter {name}: module '{module_path}' not in allowed prefixes. "
f"Allowed: {self.ALLOWED_MODULE_PREFIXES}"
)
return None

try:
module = importlib.import_module(module_path)
adapter_class = getattr(module, class_name)
adapter_class = resolve_module_path(f"{module_path}:{class_name}")

if not issubclass(adapter_class, expected_type):
logger.error(
Expand Down
21 changes: 19 additions & 2 deletions packages/darnit/src/darnit/core/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@

# Cache for discovered implementations
_implementations: dict[str, ComplianceImplementation] | None = None
_implementation_packages: frozenset[str] = frozenset()


def discover_implementations() -> dict[str, ComplianceImplementation]:
"""Discover compliance implementations from entry points."""
global _implementations
global _implementations, _implementation_packages

if _implementations is not None:
return _implementations

_implementations = {}
packages: set[str] = set()

# Use importlib.metadata for Python 3.9+
from importlib.metadata import entry_points
Expand Down Expand Up @@ -66,6 +68,7 @@ def discover_implementations() -> dict[str, ComplianceImplementation]:

if isinstance(impl, ComplianceImplementation):
_implementations[impl.name] = impl
packages.add(ep.module.partition(".")[0])
logger.info(f"Discovered implementation: {impl.name} v{impl.version}")
else:
logger.warning(
Expand All @@ -80,10 +83,22 @@ def discover_implementations() -> dict[str, ComplianceImplementation]:
logger.error(f"Error occurred while verifying or loading plugin '{ep.name}': {e}")
continue

_implementation_packages = frozenset(packages)
logger.info(f"Discovered {len(_implementations)} implementation(s)")
return _implementations


def implementation_packages() -> frozenset[str]:
"""Top-level packages of the discovered implementations' entry points.

Read from entry point metadata (``darnit_csl:register`` gives
``darnit_csl``), so the framework names no implementation package
itself. Only implementations that discovery accepted are included.
"""
discover_implementations()
return _implementation_packages


def get_implementation(name: str) -> ComplianceImplementation | None:
"""Get a specific implementation by name.

Expand Down Expand Up @@ -165,13 +180,15 @@ def clear_cache() -> None:

Useful for testing or when implementations may have changed.
"""
global _implementations
global _implementations, _implementation_packages
_implementations = None
_implementation_packages = frozenset()


__all__ = [
"clear_cache",
"discover_implementations",
"get_implementation",
"implementation_packages",
"register_implementation_handlers",
]
Loading
Loading