diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1095bc21..69ce1de9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -121,7 +121,6 @@ baseline-mcp/ │ │ └── formatters/ # SARIF output generation │ │ │ ├── darnit-example/ # Example implementation (docs reference) -│ ├── darnit-plugins/ # Plugin utilities │ └── darnit-testchecks/ # Test implementation (for testing) │ ├── docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a1311e..2d2864ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ALLOWED_MODULE_PREFIXES` on `HandlerRegistry`, `PluginRegistry`, and `AdapterRegistry`. The allowed packages come from installed implementations; there is no list to extend (#490). +- **BREAKING:** the check and remediation adapter system, which no audit or + remediation ever dispatched to (#487). `darnit.core.adapters` + (`CheckAdapter`, `RemediationAdapter`, `CommandCheckAdapter`, + `ScriptCheckAdapter`, `AdapterRegistry`) is removed, with the `darnit.core` + re-exports `CheckAdapter`, `RemediationAdapter`, `AdapterInfo`, + `ENTRY_POINT_CHECK_ADAPTERS`, and `ENTRY_POINT_REMEDIATION_ADAPTERS`, and + `AdapterCapability` and the adapter `RemediationResult` in + `darnit.core.models` (`darnit.remediation.RemediationResult` is + unchanged). The `darnit.check_adapters` and `darnit.remediation_adapters` + entry point groups are no longer read. Verification runs through sieve + handlers; an external tool runs through `exec` or a plugin step type. +- **BREAKING:** `darnit.core.registry.PluginRegistry` keeps only framework + discovery (`discover_frameworks`, `list_frameworks`, + `get_framework_info`, `get_framework_path`, `clear_cache`). Removed: + `AdapterInfo`, `discover_all`, `discover_check_adapters`, + `discover_remediation_adapters`, `list_check_adapters`, + `get_check_adapter_info`, `get_check_adapter`, `has_check_adapter`, the + matching remediation-adapter methods, `register_framework`, + `has_framework`, `register_check_adapter`, + `register_remediation_adapter`, `register_from_adapter_config`, and + `get_plugin_summary` (#487). +- **BREAKING:** `darnit.config` no longer exports `AdapterType`, + `CheckConfig`, `OutputMapping`, or `FrameworkDefaults`, and + `darnit.config.framework_schema` drops them with the adapter config models + (`PythonAdapterConfig`, `CommandAdapterConfig`, `ScriptAdapterConfig`, + `HttpAdapterConfig`, `AdapterConfig`), `FrameworkConfig.defaults`, + `FrameworkConfig.adapters`, `get_adapter_config`, `get_check_adapter`, and + `get_remediation_adapter`. `EffectiveControl` loses `check_adapter`, + `check_handler`, `check_config`, and `remediation_adapter`; + `EffectiveConfig` loses `adapters` and `get_adapter`; `merge_control` no + longer takes `defaults`; and `ControlSpec.metadata` no longer carries + `check_adapter` or `remediation_adapter` (#487). +- **BREAKING:** a control-level `check` key in a framework TOML or an + operator custom control now fails loading, like any unknown control key + (#487). +- **BREAKING:** framework TOML `[adapters]` tables and `[defaults]` + `check_adapter` / `remediation_adapter` are no longer read. A framework + that still has them loads (unknown top-level tables are accepted), and + they have no effect. The shipped frameworks drop their `[defaults]` + blocks (#487). +- **BREAKING:** `CheckContext.locator` and the `darnit.locate` package + (`UnifiedLocator` and its `sync_to_project` writer, `FoundEvidence`, + `LocateResult`, `CheckOutput`, and the tool output normalizer) are + removed; no handler read them. The control `locator` + configuration and `use_locator` are unchanged (#487). +- **BREAKING:** `darnit.storage` (`StorageBackend`, `StorageRecord`, + `FileBackend`, `ArchivistaBackend`, `MemoryBackend`, `get_backend`) and the `storage_config` parameter of + `generate_attestation_from_results` are removed. Use the `darnit.stores` + attestation store (`attestation_store`) (#487). +- **BREAKING:** the `adapter=` control filter (`--tags adapter=...`) is + removed; it never matched in `darnit audit`. `adapter` is now an ordinary + tag key (#487). +- **BREAKING:** `darnit plan` no longer prints `[adapter: ...]` after each + control, and `darnit validate` no longer prints an `Adapters:` count + (#487). +- The `darnit-plugins` workspace package (never published) and the + `darnit-testchecks` check adapters, their `darnit.check_adapters`, + `darnit.remediation_adapters`, and `darnit.adapters` entry points, and its + `[adapters.builtin]` table (#487). +- Unused code: `darnit_baseline.remediation.routing` + (`classify_writeback`), the threat-model classes in + `darnit_baseline.threat_model.models` other than `StrideCategory`, and + `darnit.remediation.RemediationResult.to_markdown` (#487). ### Added diff --git a/README.md b/README.md index 606a6f55..c2a4da72 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,7 @@ The MCP server respects these environment variables: Darnit is designed with security in mind. Key security features include: -- **Module Whitelist**: Dynamic adapter loading is restricted to trusted module prefixes (`darnit.*`, `darnit_baseline.*`, `darnit_plugins.*`, `darnit_testchecks.*`) +- **Module Path Policy**: A configured `module:attribute` handler reference resolves only to `darnit` or a package registered under the `darnit.implementations` entry points - **Dry-Run Mode**: All remediation actions support dry-run to preview changes before applying - **Sigstore Attestations**: Cryptographically signed compliance attestations with transparency logging - **Plugin Verification**: Sigstore-based verification of plugin packages @@ -474,7 +474,7 @@ Default trusted publishers: `kusari-oss`, `kusaridev` - [ ] Use fine-grained GitHub tokens with minimal permissions - [ ] Always use `dry_run=True` first when remediating - [ ] Review `.project/` changes (not-applicable claims) in pull requests -- [ ] Name custom adapter packages with `darnit_` prefix +- [ ] Ship custom handler modules in a package registered under `darnit.implementations` - [ ] Enable plugin verification in production (`allow_unsigned = false`) For comprehensive security guidance, see [docs/SECURITY_GUIDE.md](docs/SECURITY_GUIDE.md). diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index cc8ecbca..6fc07613 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -116,7 +116,7 @@ No authentication decorator was found on this endpoint. If the endpoint handles #### TM-T-001: Potential command injection via subprocess.run **Risk:** HIGH (severity × confidence = 5.40) -**Location:** `packages/darnit/src/darnit/core/adapters.py:231` +**Location:** `packages/darnit/src/darnit/core/adapters.py:231` (removed in 0.2.0, #487) **Source:** `tree_sitter_structural` — query `python.sink.dangerous_attr` [subprocess/dynamic] Entire command built dynamically — highest injection risk without taint confirmation. Command argument is populated from configuration/dict lookup within the same function scope. Opengrep taint analysis will lift confirmed cases to high confidence. @@ -184,7 +184,7 @@ No authentication decorator was found on this endpoint. If the endpoint handles #### TM-T-003: Potential command injection via subprocess.run **Risk:** HIGH (severity × confidence = 5.40) -**Location:** `packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253` +**Location:** `packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253` (removed in 0.2.0, #487) **Source:** `tree_sitter_structural` — query `python.sink.dangerous_attr` [subprocess/dynamic] Entire command built dynamically — highest injection risk without taint confirmation. Command argument is populated from configuration/dict lookup within the same function scope. Opengrep taint analysis will lift confirmed cases to high confidence. @@ -354,7 +354,7 @@ No authentication decorator was found on this endpoint. If the endpoint handles #### TM-T-021: Potential command injection via subprocess.run **Risk:** MEDIUM (severity × confidence = 2.40) -**Location:** `packages/darnit/src/darnit/core/adapters.py:354` +**Location:** `packages/darnit/src/darnit/core/adapters.py:354` (removed in 0.2.0, #487) **Source:** `tree_sitter_structural` — query `python.sink.dangerous_attr` [subprocess/parameterized] Command list contains variable arguments that may originate from external input. Opengrep taint analysis will lift confirmed cases to high confidence. @@ -787,7 +787,7 @@ The `local_path` MCP parameter is the primary trust boundary — the user (MCP c Dynamic imports allow loading arbitrary modules at runtime. If the module name originates from untrusted input, an attacker can achieve arbitrary code execution. -> **Mitigation (verified, #490):** This is the only place darnit-core imports a module named by configuration. `resolve_module_path` serves every caller that turns a `module:attribute` string into an import: MCP tool handlers (`ToolRegistry.load_handler`, `server/registry.py`), handler-registry lookups (`HandlerRegistry.get_handler`), and Python adapter configuration (`PluginRegistry` in `core/registry.py`, `AdapterRegistry` in `core/adapters.py`). Before importing, it requires the form `a.b.c:attr` (identifiers only, no relative or empty parts) and a top-level package that is `darnit` or the package of an implementation discovery loaded from the `darnit.implementations` entry points. The allowed set is derived from installed entry point metadata, not a list in code, so it covers third-party implementations and excludes packages that are not installed implementations. A refused path raises `HandlerImportRefused` naming the path and the allowed packages; at MCP server start the tool is not registered and the refusal is logged at ERROR. Residual risk: an allowed package is code the operator installed, so the policy narrows what a configured string can reach but does not sandbox it. `[mcp.tools]` comes from an installed framework TOML or an operator-supplied `darnit serve `, never from the audited repository (framework-design.md 6.5, 14). +> **Mitigation (verified, #490):** This is the only place darnit-core imports a module named by configuration. `resolve_module_path` serves every caller that turns a `module:attribute` string into an import: MCP tool handlers (`ToolRegistry.load_handler`, `server/registry.py`) and handler-registry lookups (`HandlerRegistry.get_handler`); the Python adapter loaders that also used it were removed in 0.2.0 (#487). Before importing, it requires the form `a.b.c:attr` (identifiers only, no relative or empty parts) and a top-level package that is `darnit` or the package of an implementation discovery loaded from the `darnit.implementations` entry points. The allowed set is derived from installed entry point metadata, not a list in code, so it covers third-party implementations and excludes packages that are not installed implementations. A refused path raises `HandlerImportRefused` naming the path and the allowed packages; at MCP server start the tool is not registered and the refusal is logged at ERROR. Residual risk: an allowed package is code the operator installed, so the policy narrows what a configured string can reach but does not sandbox it. `[mcp.tools]` comes from an installed framework TOML or an operator-supplied `darnit serve `, never from the audited repository (framework-design.md 6.5, 14). ``` 414 | module_path, sep, attr = path.rpartition(":") @@ -812,9 +812,9 @@ No compound attack paths identified. ### Immediate Actions (Critical / High) -1. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/core/adapters.py:231` (mitigated: TOML config source, list-form subprocess) +1. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/core/adapters.py:231` (mitigated: TOML config source, list-form subprocess; file removed in 0.2.0, #487) 2. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/sieve/builtin_handlers.py:137` (mitigated: TOML control definitions, list-form subprocess) -3. **Potential command injection via subprocess.run** — `packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253` (mitigated: hardcoded binary, list-form subprocess) +3. **Potential command injection via subprocess.run** — `packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253` (mitigated: hardcoded binary, list-form subprocess; file removed in 0.2.0, #487) 4. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/server/tools/git_operations.py:382` (mitigated: list-form subprocess, gh CLI validates args) ### Short-term Actions (Medium) @@ -825,7 +825,7 @@ No compound attack paths identified. 4. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/tools/audit_org.py:62` (mitigated: list-form subprocess, gh validates) 5. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/tools/audit_org.py:113` (mitigated: list-form subprocess, gh validates) 6. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/core/utils.py:27` (mitigated: list-form subprocess, gh validates) -7. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/core/adapters.py:354` (mitigated: trusted TOML config) +7. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/core/adapters.py:354` (mitigated: trusted TOML config; file removed in 0.2.0, #487) 8. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/server/tools/test_repository.py:141` (test tool only) 9. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/server/tools/git_operations.py:45` (mitigated: list-form subprocess) 10. **Potential command injection via subprocess.run** — `packages/darnit/src/darnit/server/tools/git_operations.py:53` (mitigated: list-form subprocess) diff --git a/docs/DECISION_FLOWS.md b/docs/DECISION_FLOWS.md index f299fe8f..fc8e1535 100644 --- a/docs/DECISION_FLOWS.md +++ b/docs/DECISION_FLOWS.md @@ -9,10 +9,9 @@ This document maps out the decision-making processes in the OpenSSF Baseline MCP 3. [Project Context Flow](#project-context-flow) *(NEW)* 4. [Context Sieve Flow](#context-sieve-flow) *(NEW - Remediation)* 5. [Project Configuration Lifecycle](#project-configuration-lifecycle) -6. [Adapter Selection & Check Routing](#adapter-selection--check-routing) -7. [Control Applicability](#control-applicability) -8. [CI Discovery](#ci-discovery) -9. [Attestation Generation](#attestation-generation) +6. [Control Applicability](#control-applicability) +7. [CI Discovery](#ci-discovery) +8. [Attestation Generation](#attestation-generation) --- @@ -733,142 +732,6 @@ Only after the person accepts does the agent fill in the digest; the confirmatio --- -## Adapter Selection & Check Routing - -### 5.1 Which Adapter to Use? - -```text -┌──────────────────────────────────────────────────────────────────┐ -│ get_check_adapter(control_id) │ -│ (from ToolRegistry) │ -└──────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Check framework TOML for │ - │ control-specific adapter config │ - └───────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - │ │ │ - ▼ ▼ ▼ - [Custom Adapter] [Default Setting] [No Config] - │ │ │ - │ │ │ - ▼ ▼ ▼ - ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ "kusari" │ │ "builtin" │ │ "builtin" │ - │ "script" │ │ (from │ │ (hardcoded│ - │ "custom" │ │ settings)│ │ default) │ - └───────────┘ └───────────┘ └───────────┘ - │ │ │ - └───────────────┼───────────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Look up adapter instance │ - │ check_adapters[adapter_name] │ - └───────────────────────────────────┘ - │ - ┌─────────┴─────────┐ - │ │ - ▼ ▼ - [Adapter Found] [Adapter Not Found] - │ │ - │ ▼ - │ ┌───────────────────┐ - │ │ Fallback to │ - │ │ "builtin" adapter │ - │ └───────────────────┘ - │ │ - └─────────┬─────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Return CheckAdapter instance │ - └───────────────────────────────────┘ -``` - -### 5.2 Adapter Types - -```text -┌─────────────────────────────────────────────────────────────────────────────┐ -│ ADAPTER TYPES │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ BuiltinCheckAdapter │ │ -│ │ - Default adapter for all controls │ │ -│ │ - Hardcoded check logic in Python │ │ -│ │ - Routes by control prefix (OSPS-AC, OSPS-BR, etc.) │ │ -│ │ - Uses GitHub API + local file analysis │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ KusariAdapter │ │ -│ │ - Integration with Kusari security scanner │ │ -│ │ - Maps controls to Kusari check names │ │ -│ │ - Runs: kusari repo scan --format json │ │ -│ │ - Supports: OSPS-VM-05.*, OSPS-SA-02.* │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ ScriptAdapter │ │ -│ │ - Runs custom shell commands │ │ -│ │ - Passes context via environment variables: │ │ -│ │ OSPS_CONTROL_ID, OSPS_OWNER, OSPS_REPO, OSPS_LOCAL_PATH │ │ -│ │ - Expects JSON output: {status, message, details} │ │ -│ │ - Supports batch mode for multiple controls │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### 5.3 Builtin Adapter - Check Routing - -```text -┌──────────────────────────────────────────────────────────────────┐ -│ BuiltinCheckAdapter.check(control_id) │ -└──────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Extract prefix from control_id │ - │ "OSPS-AC-03.01" → "OSPS-AC" │ - └───────────────────────────────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Map prefix to category: │ - │ │ - │ OSPS-AC → access_control │ - │ OSPS-BR → build_release │ - │ OSPS-DO → documentation │ - │ OSPS-GV → governance │ - │ OSPS-LI → legal │ - │ OSPS-QA → quality │ - │ OSPS-SA → security_architecture │ - │ OSPS-VM → vulnerability │ - └───────────────────────────────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Determine level from control_id │ - │ and call appropriate function: │ - │ │ - │ check_level1_{category}() │ - │ check_level2_{category}() │ - │ check_level3_{category}() │ - └───────────────────────────────────┘ - │ - ▼ - ┌───────────────────────────────────┐ - │ Return List[CheckResult] │ - └───────────────────────────────────┘ -``` - ---- - ## Control Applicability ```text @@ -1526,11 +1389,3 @@ Use the provided verification script instead. | `get_pending_context()` | config/context_storage.py | Pending questions with candidates as data (read-only) | | `context_writes` | config/context_writes.py | The only writer of context values and confirmation records | | `[context.*]` | framework TOML | Context keys, vocabularies, and affected controls | - -### Adapter Functions (Legacy) - -| Function | Location | Purpose | -|----------|----------|---------| -| `ToolRegistry.get_check_adapter()` | main.py | Get adapter for control | -| `BuiltinCheckAdapter.check()` | main.py | Route to builtin checks | -| `check_level{1,2,3}_{category}()` | checks/level{1,2,3}.py | Actual check implementations | diff --git a/docs/IMPLEMENTATION_GUIDE.md b/docs/IMPLEMENTATION_GUIDE.md index 4cbea7a7..94e531b6 100644 --- a/docs/IMPLEMENTATION_GUIDE.md +++ b/docs/IMPLEMENTATION_GUIDE.md @@ -409,10 +409,6 @@ schema_version = "0.1.0-alpha" spec_version = "MySpec v1.0" description = "Compliance controls for My Standard" url = "https://example.com/mystandard" - -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" ``` ### Control definitions diff --git a/docs/SECURITY_GUIDE.md b/docs/SECURITY_GUIDE.md index 77f12b10..ffbe37fc 100644 --- a/docs/SECURITY_GUIDE.md +++ b/docs/SECURITY_GUIDE.md @@ -6,7 +6,7 @@ This document describes security considerations, best practices, and configurati - [Dynamic Module Loading Security](#dynamic-module-loading-security) - [GitHub Token Security](#github-token-security) -- [Custom Adapter Security](#custom-adapter-security) +- [Custom Handler Security](#custom-handler-security) - [Configuration Security](#configuration-security) - [MCP Server Security](#mcp-server-security) - [Plugin Security Model](#plugin-security-model) @@ -18,7 +18,7 @@ This document describes security considerations, best practices, and configurati ## Dynamic Module Loading Security -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). +Darnit can import a Python attribute named in configuration as `package.module:attribute`: MCP tool handlers in `[mcp.tools]` and handler references. Every such import goes through one function, `darnit.core.handlers.resolve_module_path` (framework-design.md 6.5). ### Resolution Policy @@ -36,7 +36,7 @@ Darnit can import a Python attribute named in configuration as `package.module:a - 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 `, never from the audited repository. -- To use your own adapter or tool module, ship it in a package registered under `darnit.implementations`. +- To use your own handler or tool module, ship it in a package registered under `darnit.implementations`. --- @@ -96,11 +96,11 @@ jobs: --- -## Custom Adapter Security +## Custom Handler Security -When creating custom adapters, follow these security guidelines. +When creating custom sieve handlers, follow these security guidelines. -### Adapter Development Checklist +### Handler Development Checklist - [ ] **Validate all inputs** from configuration and control definitions - [ ] **Sanitize file paths** to prevent path traversal attacks @@ -111,26 +111,25 @@ When creating custom adapters, follow these security guidelines. ### Secure Command Execution -For command-based adapters, use safe execution patterns: +For handlers that run external commands, use safe execution patterns: ```python import subprocess -import shlex - -class SecureCommandAdapter(CheckAdapter): - def check(self, control_id, owner, repo, local_path, config): - command = config.get("command", "") - - # NEVER do this - shell injection vulnerability - # subprocess.run(f"tool {local_path}", shell=True) - - # DO this - use list arguments, no shell - subprocess.run( - ["tool", "--path", local_path], - shell=False, - timeout=300, - capture_output=True, - ) + +from darnit.sieve.handler_registry import HandlerContext, HandlerResult + +def my_tool_check(config: dict, context: HandlerContext) -> HandlerResult: + # NEVER do this - shell injection vulnerability + # subprocess.run(f"tool {context.local_path}", shell=True) + + # DO this - use list arguments, no shell + proc = subprocess.run( + ["tool", "--path", context.local_path], + shell=False, + timeout=300, + capture_output=True, + ) + ... ``` ### Input Validation diff --git a/docs/architecture/audit-pipeline.md b/docs/architecture/audit-pipeline.md index 109c77bb..a4b9acf5 100644 --- a/docs/architecture/audit-pipeline.md +++ b/docs/architecture/audit-pipeline.md @@ -35,9 +35,9 @@ The `run_sieve_audit()` function SHALL support optional parameters for features - **WHEN** `evaluate_claims=True` (default) - **THEN** the repository's not-applicable claims (`.project/darnit.yaml` and applicability-changing `.project/` context values) SHALL be assessed under framework-design 14.3, and only an honored claim SHALL mark its control `N/A` -#### Scenario: UnifiedLocator integration +#### Scenario: Locator configuration - **WHEN** the pipeline runs -- **THEN** it SHALL create a `UnifiedLocator` for `.project/` file resolution and pass it to each `CheckContext` +- **THEN** it SHALL pass each control's `locator` configuration to its `CheckContext` as `locator_config` #### Scenario: Tag filtering - **WHEN** a `tags` parameter is provided diff --git a/docs/architecture/framework-design.md b/docs/architecture/framework-design.md index 59677352..7821cfee 100644 --- a/docs/architecture/framework-design.md +++ b/docs/architecture/framework-design.md @@ -1,8 +1,8 @@ # Darnit Framework Design Specification -> **Version**: 1.0.0-alpha.14 +> **Version**: 1.0.0-alpha.15 > **Status**: Authoritative -> **Last Updated**: 2026-10-07 +> **Last Updated**: 2026-10-08 This specification defines the authoritative design of the Darnit framework, including the sieve orchestrator, TOML schema, built-in pass types, remediation actions, and plugin protocol. @@ -79,10 +79,6 @@ spec_version = "Spec v1.0" # OPTIONAL: Upstream spec version description = "..." # OPTIONAL: Framework description url = "https://..." # OPTIONAL: Spec URL -[defaults] -check_adapter = "builtin" # Default check adapter -remediation_adapter = "builtin" # Default remediation adapter - [templates] # Reusable templates for remediation @@ -1466,7 +1462,7 @@ A TOML handler reference may name a Python attribute as `package.module:attribut handler = "darnit_csl.mcp_tools:remediate_community_spec" ``` -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. +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`) and handler-registry lookups (`HandlerRegistry.get_handler`). 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. @@ -1652,7 +1648,7 @@ Each context key has one canonical name and one vocabulary, taken from the frame - Auditing (every driver), listing pending data, report generation, a remediation preview (plan mode, section 4.2), the remediation context guard in every mode, and the harness collect phase SHALL NOT create, modify, or delete any file in the audited repository. - `darnit.config.context_writes` is the only code that writes context values and in-repository confirmation records. It writes only `.project/darnit.yaml` (never `.project/project.yaml`), preserves sections and comments it does not change (including feature 040 `controls:` claims), and refuses every write, returning the errors, when `.project/project.yaml` or `.project/darnit.yaml` is present but unparseable or invalid. The loader distinguishes absent, valid, and invalid files (`load_project_config_checked`). - `init_project_config` (MCP) creates only an empty `.project/darnit.yaml` when `.project/` is absent, and reports instead of overwriting when it is present. -- An applied remediation's `project_update` (written by the remediation executor, section 4.2) and the file-reference sync after a remediation creates a file (`update_config_after_file_create`, `UnifiedLocator.sync_to_project`) write only the dotted paths they target, through the loader's round-trip helper (`update_project_config`): CNCF fields to `.project/project.yaml`, other fields to `.project/darnit.yaml`, preserving comments, ordering, indentation, and fields darnit does not own. An absent `.project/project.yaml` is created with `name` and the targeted fields only. No darnit code replaces a whole project file. The file-reference sync after remediation records only a `project_reference` declared on the `file_create` step, only for a file created in this run, and only when the field is empty or already equal (section 4.3). When either file is present but invalid they write nothing: planning the update (`plan_project_update`, used by the `project_update` handler and the remediation's `project_update`) raises with the validation errors (an applied remediation reports `project_update: failed: `), and the sync functions return false. +- An applied remediation's `project_update` (written by the remediation executor, section 4.2) and the file-reference sync after a remediation creates a file (`update_config_after_file_create`) write only the dotted paths they target, through the loader's round-trip helper (`update_project_config`): CNCF fields to `.project/project.yaml`, other fields to `.project/darnit.yaml`, preserving comments, ordering, indentation, and fields darnit does not own. An absent `.project/project.yaml` is created with `name` and the targeted fields only. No darnit code replaces a whole project file. The file-reference sync after remediation records only a `project_reference` declared on the `file_create` step, only for a file created in this run, and only when the field is empty or already equal (section 4.3). When either file is present but invalid they write nothing: planning the update (`plan_project_update`, used by the `project_update` handler and the remediation's `project_update`) raises with the validation errors (an applied remediation reports `project_update: failed: `), and the sync function returns false. - An audit reads nothing from a present-but-invalid `.project/` file and reports each validation error in the report's `warnings` (JSON) and as a warning line (Markdown). ### 7.10 Confirmation Tool Contract @@ -2247,7 +2243,7 @@ The `confirm_*` tools are unchanged; approvals are not confirmations. Audit resu ## Appendix C: Removed Requirements -The following requirements have been superseded: by the handler dispatch architecture, by the feature 043 remediation design (the `api_call` and `requires_confirmation` entries), and by the operator configuration and trust boundary (the last entry, Section 14). +The following requirements have been superseded: by the handler dispatch architecture, by the feature 043 remediation design (the `api_call` and `requires_confirmation` entries), by the operator configuration and trust boundary (the `.baseline.toml` entry, Section 14), and by the removal of unused subsystems (the adapter and `UnifiedLocator` entries, #487). ### Removed: VerificationPassProtocol **Reason**: Replaced by handler dispatch architecture. Pass classes that implemented this protocol (`DeterministicPass`, `PatternPass`, `LLMPass`, `ManualPass`, `ExecPass`) are superseded by handler functions registered in `SieveHandlerRegistry`. @@ -2319,12 +2315,30 @@ The following requirements have been superseded: by the handler dispatch archite - **THEN** no key in it MUST affect the audit - **AND** the audit MUST report one notice pointing at `darnit config migrate` +### Removed: Check and remediation adapters +**Reason**: No audit or remediation path ever dispatched through them (#487). Verification runs only through the handler pipeline (Sections 3, 5) and remediation only through remediation handlers (Section 4). The adapter classes and registry (`CheckAdapter`, `RemediationAdapter`, `AdapterRegistry`, the `darnit.check_adapters` and `darnit.remediation_adapters` entry point groups), the framework TOML `[adapters]` table, the `[defaults]` keys `check_adapter` and `remediation_adapter`, and the control-level `check` key are removed. +**Migration**: Express verification as `[[controls."ID".passes]]` handler invocations; an external tool runs through `exec` (Section 3.3) or a plugin step type (Section 3.0.3). Delete `[adapters]` and `[defaults]` tables and control-level `check` keys. + +#### Scenario: Control declares a check key +- **WHEN** a framework TOML control declares `check` +- **THEN** loading MUST fail as for any unknown control key (Section 2.3) + +#### Scenario: Framework declares adapter tables +- **WHEN** a framework TOML has an `[adapters]` table or `[defaults]` with `check_adapter` or `remediation_adapter` +- **THEN** the framework MUST load +- **AND** no key in those tables MUST affect an audit or a remediation + +### Removed: UnifiedLocator +**Reason**: `CheckContext` carried a `UnifiedLocator` that no handler read, and its uncalled `sync_to_project` was a second writer of `.project/` file references beside the remediation file-reference sync (Section 7.9). The control-level `locator` configuration and `use_locator` (Section 11) are unaffected. +**Migration**: None. Handlers read `locator.discover` through `use_locator`. + --- ## Version History | Version | Date | Changes | |---------|------|---------| +| 1.0.0-alpha.15 | 2026-10-08 | Removed the never-dispatched adapter system (`[adapters]`, `[defaults]` adapter keys, control-level `check`, `AdapterRegistry`) and `UnifiedLocator` with its `sync_to_project` writer (Sections 2.1, 6.5, 7.9; Appendix C; #487) | | 1.0.0-alpha.14 | 2026-10-07 | `repro_witness_attestation`: Witness/in-toto runtime-trace verification moves out of `repro_hermetic_build` into its own step type with ceiling `{fail}`, bound to the audited commit and reading only the runtime-trace predicate; a verified clean trace is evidence, not PASS (Sections 3.0.1, 12; #553) | | 1.0.0-alpha.13 | 2026-10-06 | Error class `unexpected_exit`: an `exec` step whose undeclared exit code has no identified cause no longer reports `network`; exit code 127 reports `missing_tool` (Sections 3.3, 5.2; #562) | | 1.0.0-alpha.12 | 2026-10-04 | Repository-level `.baseline.toml` is no longer read: one notice points at `darnit config migrate`, framework selection only by `--framework` or a tool argument (Sections 2.3, 10.5, 14.4, 15.1; Appendix C) | diff --git a/docs/examples/declarative-framework/example-framework.toml b/docs/examples/declarative-framework/example-framework.toml index bfebfd90..20030928 100644 --- a/docs/examples/declarative-framework/example-framework.toml +++ b/docs/examples/declarative-framework/example-framework.toml @@ -22,10 +22,6 @@ spec_version = "Example v1.0" description = "A minimal example showing declarative framework definition" url = "https://example.com/compliance" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - # ============================================================================= # Templates - Reusable content for remediation # ============================================================================= diff --git a/docs/plugin-discovery-design.md b/docs/plugin-discovery-design.md index a7dc172a..76bb11a8 100644 --- a/docs/plugin-discovery-design.md +++ b/docs/plugin-discovery-design.md @@ -1,6 +1,6 @@ # Plugin Discovery System Design -> **Note**: This is a design record. Earlier revisions also let a repository-level user configuration (`.baseline.toml`) reference adapters; darnit no longer reads that file. Adapters are referenced from framework TOML, and tool settings live in operator configuration outside the audited repository (`docs/architecture/framework-design.md` section 14). +> **Note**: This is a design record. The check and remediation adapter system it describes (`CheckAdapter`, `RemediationAdapter`, `AdapterRegistry`, the `darnit.check_adapters` and `darnit.remediation_adapters` entry point groups, and the framework TOML `[adapters]` table, `[defaults]` adapter keys, and control-level `check` key) was never dispatched to and was removed in 0.2.0 (#487); verification runs through sieve handlers (`docs/architecture/framework-design.md` sections 3 and 5). Only the `darnit.frameworks` discovery it introduced remains. The example `darnit-plugins` package it proposes (Phase 4) was never published and was also removed. Earlier revisions also let a repository-level user configuration (`.baseline.toml`) reference adapters; darnit no longer reads that file, and tool settings live in operator configuration outside the audited repository (`docs/architecture/framework-design.md` section 14). ## Overview diff --git a/docs/threatmodel/findings/python-eop-dynamic_import_attr.md b/docs/threatmodel/findings/python-eop-dynamic_import_attr.md index 4d170df4..669579e0 100644 --- a/docs/threatmodel/findings/python-eop-dynamic_import_attr.md +++ b/docs/threatmodel/findings/python-eop-dynamic_import_attr.md @@ -75,7 +75,7 @@
-packages/darnit/src/darnit/core/adapters.py:666 +packages/darnit/src/darnit/core/adapters.py:666 (removed in 0.2.0, #487) ``` 656 | diff --git a/docs/threatmodel/findings/python-sink-dangerous_attr.md b/docs/threatmodel/findings/python-sink-dangerous_attr.md index ae98e2a0..29fed237 100644 --- a/docs/threatmodel/findings/python-sink-dangerous_attr.md +++ b/docs/threatmodel/findings/python-sink-dangerous_attr.md @@ -12,6 +12,8 @@ **Examples:** `packages/darnit/src/darnit/core/adapters.py:231`, `packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253`, `packages/darnit/src/darnit/core/adapters.py:354` +> **Note (#487):** `packages/darnit-plugins/` and `packages/darnit/src/darnit/core/adapters.py` were removed in 0.2.0; the `kusari.py` and `adapters.py` instances below no longer exist. + ### Strategy 2 (1 instances) > Sieve exec handler — commands loaded from TOML control definitions (a trusted, admin-controlled source). Variable substitution uses an allowlisted set of placeholders ($OWNER, $REPO, $BRANCH, $PATH) with values from MCP CheckContext. List-form subprocess prevents shell metacharacter injection. @@ -81,7 +83,7 @@ ## Representative Examples
-packages/darnit/src/darnit/core/adapters.py:231 +packages/darnit/src/darnit/core/adapters.py:231 (removed in 0.2.0, #487) ``` 221 | # Add any extra config @@ -143,7 +145,7 @@
-packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253 +packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py:253 (removed in 0.2.0, #487) ``` 243 | diff --git a/docs/tutorials/create-new-implementation.md b/docs/tutorials/create-new-implementation.md index e070d973..a7891498 100644 --- a/docs/tutorials/create-new-implementation.md +++ b/docs/tutorials/create-new-implementation.md @@ -64,10 +64,6 @@ schema_version = "0.1.0-alpha" spec_version = "Hygiene v1.0" description = "Basic code hygiene checks for any project" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - # Templates for remediation [templates.readme] description = "Basic README template" diff --git a/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py b/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py index 6d330101..7d8952f0 100644 --- a/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py +++ b/packages/darnit-baseline/src/darnit_baseline/attestation/generator.py @@ -54,7 +54,6 @@ def generate_attestation_from_results( staging: bool = False, output_path: str | None = None, output_dir: str | None = None, - storage_config: dict | None = None, attestation_store: Any = None, ) -> str: """Generate attestation from audit results. @@ -174,23 +173,6 @@ def generate_attestation_from_results( "attestation": json.loads(output) }, indent=2) - # Store via pluggable storage backend if configured - if storage_config is not None: - # TODO: repo_url is hardcoded to GitHub — will break for GitLab/Gitea/etc. - # Open issue to track multi-forge support - repo_url = f"https://github.com/{audit_result.owner}/{audit_result.repo}" - try: - from darnit.storage.backends import get_backend - storage = get_backend(storage_config) - storage_ref = storage.store_attestation( - repo_url=repo_url, - commit=audit_result.commit, - attestation=unsigned, - ) - logger.info(f"Attestation stored via backend: {storage_ref}") - except Exception as e: - logger.warning(f"Storage backend failed (file save succeeded): {e}") - return f"✅ Attestation saved to: {output_path}\n\n{output}" diff --git a/packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml b/packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml index bfdd1af3..3339ec79 100644 --- a/packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml +++ b/packages/darnit-baseline/src/darnit_baseline/openssf-baseline.toml @@ -13,10 +13,6 @@ spec_version = "OSPS v2026.02.19" description = "OpenSSF Baseline security controls for open source projects" url = "https://baseline.openssf.org/" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - # ============================================================================= # Audit Profiles # ============================================================================= diff --git a/packages/darnit-baseline/src/darnit_baseline/remediation/routing.py b/packages/darnit-baseline/src/darnit_baseline/remediation/routing.py deleted file mode 100644 index fbb33aec..00000000 --- a/packages/darnit-baseline/src/darnit_baseline/remediation/routing.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Write-back routing classification for org-wide audits. - -Classifies remediation actions as targeting either the org `.project` repo -(shared metadata) or the individual repo's `.project/` folder (repo-specific). - -Example: - from darnit_baseline.remediation.routing import classify_writeback - - classification = classify_writeback("security.contact", org_config) - # Returns "org" or "repo" -""" - -from __future__ import annotations - -import logging -from typing import Any - -logger = logging.getLogger(__name__) - -# Fields that are org-level when present in the org .project config. -# These are shared metadata that typically belongs in the org's .project repo. -_ORG_LEVEL_FIELDS = { - "security.contact", - "security.policy", - "maintainers", - "governance.codeowners", - "governance.contributing", - "governance.governance_doc", -} - -# Fields that are always repo-level regardless of org config. -# These are repo-specific artifacts or overrides. -_ALWAYS_REPO_FIELDS = { - "SECURITY.md", - "CODEOWNERS", - ".github/CODEOWNERS", - "CONTRIBUTING.md", - "ci_provider", - "has_releases", - "is_library", - "has_compiled_assets", - "has_subprojects", -} - - -def classify_writeback( - field_or_artifact: str, - org_config: dict[str, Any] | None = None, -) -> str: - """Classify a remediation action as 'org' or 'repo'. - - Args: - field_or_artifact: The field path (e.g., "security.contact") or - artifact name (e.g., "SECURITY.md") being remediated. - org_config: The org-level .project config as a dict, or None if - no org config exists. - - Returns: - "org" if the field belongs in the org .project repo, - "repo" if it belongs in the individual repo's .project/ folder. - """ - # Always-repo fields don't need org config check - if field_or_artifact in _ALWAYS_REPO_FIELDS: - return "repo" - - # If no org config, everything goes to repo - if not org_config: - return "repo" - - # Check if the field exists at the org level - if field_or_artifact in _ORG_LEVEL_FIELDS: - # Check if the org config actually has this field populated - if _field_exists_in_config(field_or_artifact, org_config): - return "org" - - return "repo" - - -def classify_remediation_actions( - actions: list[dict[str, Any]], - org_config: dict[str, Any] | None = None, -) -> list[dict[str, Any]]: - """Classify a list of remediation actions with routing labels. - - Args: - actions: List of remediation action dicts, each with at least - a "field" or "artifact" key. - org_config: The org-level .project config. - - Returns: - The same actions list with an added "routing" key ("org" or "repo"). - """ - for action in actions: - field = action.get("field") or action.get("artifact", "") - action["routing"] = classify_writeback(field, org_config) - return actions - - -def format_routing_report( - actions: list[dict[str, Any]], - owner: str, -) -> str: - """Format write-back routing classification as markdown. - - Args: - actions: Classified remediation actions (with "routing" key). - owner: GitHub org/user for display. - - Returns: - Markdown section for the audit report. - """ - if not actions: - return "" - - lines = [ - "## Write-back Routing", - "", - "The following remediation actions are classified by target:", - "", - ] - - org_actions = [a for a in actions if a.get("routing") == "org"] - repo_actions = [a for a in actions if a.get("routing") == "repo"] - - if org_actions: - lines.append(f"### Org-level (`{owner}/.project`)") - lines.append("") - for action in org_actions: - desc = action.get("description", action.get("field", "")) - lines.append(f"- [org] {desc}") - lines.append("") - - if repo_actions: - lines.append("### Repo-level (per-repo `.project/`)") - lines.append("") - for action in repo_actions: - desc = action.get("description", action.get("field", "")) - lines.append(f"- [repo] {desc}") - lines.append("") - - return "\n".join(lines) - - -def _field_exists_in_config(field_path: str, config: dict[str, Any]) -> bool: - """Check if a dotted field path exists in a nested config dict. - - Args: - field_path: Dotted path like "security.contact" - config: Nested dict to check - - Returns: - True if the field exists and is not None/empty - """ - parts = field_path.split(".") - current = config - for part in parts: - if not isinstance(current, dict) or part not in current: - return False - current = current[part] - # Consider empty strings, None, and empty lists as "not existing" - return not (current is None or current == "" or current == []) diff --git a/packages/darnit-baseline/src/darnit_baseline/threat_model/discovery_models.py b/packages/darnit-baseline/src/darnit_baseline/threat_model/discovery_models.py index 5a98c09d..382cbd67 100644 --- a/packages/darnit-baseline/src/darnit_baseline/threat_model/discovery_models.py +++ b/packages/darnit-baseline/src/darnit_baseline/threat_model/discovery_models.py @@ -1,8 +1,7 @@ """Data model for the tree-sitter based discovery pipeline. -These types power the tree-sitter discovery pipeline. They live in a separate -module from the legacy ``models.py`` types, which are retained only for -backward compatibility of the ``StrideCategory`` enum. +These types power the tree-sitter discovery pipeline. ``models.py`` holds only +the ``StrideCategory`` enum they share. See `specs/010-threat-model-ast/data-model.md` for the authoritative definitions. """ @@ -109,9 +108,6 @@ def _asset_id(kind_prefix: str, language: str, location: Location) -> str: class DiscoveredEntryPoint: """An attack-surface entry point discovered by a tree-sitter query. - Named ``Discovered*`` to distinguish from the legacy - ``threat_model.models.EntryPoint`` type which has a different shape. - The ``id`` field is derived from ``kind``, ``language``, and ``location``; callers must not pass an override unless they know what they're doing. """ diff --git a/packages/darnit-baseline/src/darnit_baseline/threat_model/models.py b/packages/darnit-baseline/src/darnit_baseline/threat_model/models.py index 5b4ea946..b368cee3 100644 --- a/packages/darnit-baseline/src/darnit_baseline/threat_model/models.py +++ b/packages/darnit-baseline/src/darnit_baseline/threat_model/models.py @@ -1,12 +1,6 @@ -"""Data structures for threat modeling. +"""STRIDE category enum shared by the threat model pipeline.""" -This module contains all the data classes and enums used by the -STRIDE threat analysis engine. -""" - -from dataclasses import dataclass, field from enum import Enum -from typing import Any class StrideCategory(Enum): @@ -19,197 +13,6 @@ class StrideCategory(Enum): ELEVATION_OF_PRIVILEGE = "elevation_of_privilege" -class RiskLevel(Enum): - """Risk severity levels.""" - CRITICAL = "critical" - HIGH = "high" - MEDIUM = "medium" - LOW = "low" - INFORMATIONAL = "informational" - - -class Confidence(Enum): - """Detection confidence levels. - - HIGH: Confirmed via dependency manifest + code import. - MEDIUM: Code import found but not in dependency manifest (vendored, transitive). - LOW: Pattern found in string literal, regex definition, or comment — likely - a reference rather than actual usage. - """ - HIGH = "high" - MEDIUM = "medium" - LOW = "low" - - -@dataclass -class CodeLocation: - """Location of code relevant to a threat.""" - file: str - line_start: int - line_end: int - snippet: str = "" - annotation: str = "" - - -@dataclass -class EntryPoint: - """An API entry point discovered in the codebase.""" - id: str - entry_type: str # api_route, graphql, websocket, webhook, scheduled, server_action - path: str - method: str - file: str - line: int - authentication_required: bool - framework: str - parameters: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass -class DataStore: - """A data storage system detected in the codebase.""" - id: str - store_type: str # database, cache, file_system, external_storage - technology: str - file: str - line: int - contains_pii: bool = False - contains_financial: bool = False - encryption_at_rest: bool = False - confidence: Confidence = Confidence.MEDIUM - - -@dataclass -class SensitiveData: - """A field or variable that may contain sensitive data.""" - id: str - data_type: str # pii, financial, health, authentication, business - field_name: str - file: str - line: int - context: str = "" - - -@dataclass -class SecretReference: - """A potential secret or credential in the codebase.""" - id: str - secret_type: str # hardcoded, env_reference - name: str - file: str - line: int - severity: str = "high" - - -@dataclass -class AuthMechanism: - """An authentication mechanism detected in the codebase.""" - id: str - auth_type: str # nextauth, clerk, supabase, passport, jwt, custom - file: str - line: int - framework: str - assets: list[str] = field(default_factory=list) - confidence: Confidence = Confidence.MEDIUM - - -@dataclass -class RiskScore: - """Calculated risk score for a threat.""" - overall: float - level: RiskLevel - likelihood: float - impact: float - control_effectiveness: float - factors: dict[str, Any] = field(default_factory=dict) - - -class DetailLevel(Enum): - """Detail level for threat model output.""" - SUMMARY = "summary" - DETAILED = "detailed" - - -@dataclass -class RankedControl: - """A recommended control ranked by effectiveness.""" - control: str - effectiveness: str # "high", "medium", "low" - rationale: str - - -@dataclass -class Threat: - """A security threat identified through STRIDE analysis.""" - id: str - category: StrideCategory - title: str - description: str - affected_assets: list[str] - attack_vector: str - prerequisites: list[str] - risk: RiskScore - existing_controls: list[str] - recommended_controls: list[str] - code_locations: list[CodeLocation] - references: list[str] = field(default_factory=list) - exploitation_scenario: list[str] = field(default_factory=list) - data_flow_impact: str = "" - ranked_controls: list["RankedControl"] = field(default_factory=list) - attack_chain_ids: list[str] = field(default_factory=list) - - -@dataclass -class AssetInventory: - """Inventory of security-relevant assets discovered in the codebase.""" - entry_points: list[EntryPoint] - data_stores: list[DataStore] - sensitive_data: list[SensitiveData] - secrets: list[SecretReference] - authentication: list[AuthMechanism] - frameworks_detected: list[str] - external_services: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass -class AttackChain: - """A compound attack path combining multiple threats.""" - id: str - name: str - description: str - threat_ids: list[str] - categories: list[StrideCategory] - shared_assets: list[str] - composite_risk: RiskScore - - -@dataclass -class ThreatAnalysis: - """Complete threat analysis result.""" - methodology: str - threats: list[Threat] - control_gaps: list[dict[str, Any]] - summary: dict[str, Any] - attack_chains: list[AttackChain] = field(default_factory=list) - - __all__ = [ - # Enums "StrideCategory", - "RiskLevel", - "Confidence", - "DetailLevel", - # Data classes - "CodeLocation", - "EntryPoint", - "DataStore", - "SensitiveData", - "SecretReference", - "AuthMechanism", - "RiskScore", - "RankedControl", - "Threat", - "AttackChain", - "AssetInventory", - "ThreatAnalysis", ] diff --git a/packages/darnit-csl/src/darnit_csl/community-spec-optional.toml b/packages/darnit-csl/src/darnit_csl/community-spec-optional.toml index 8f7a8173..38aab715 100644 --- a/packages/darnit-csl/src/darnit_csl/community-spec-optional.toml +++ b/packages/darnit-csl/src/darnit_csl/community-spec-optional.toml @@ -29,10 +29,6 @@ spec_version = "CSL 1.0" description = "Facultative presence check for the optional Community Specification files (contributing, spec-template, code-of-conduct)" url = "https://github.com/CommunitySpecification/1.0" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - # ----------------------------------------------------------------------------- # CSL-OPT-01 -> 06-contributing.md # ----------------------------------------------------------------------------- diff --git a/packages/darnit-csl/src/darnit_csl/community-spec.toml b/packages/darnit-csl/src/darnit_csl/community-spec.toml index a5a195b7..9eb868d3 100644 --- a/packages/darnit-csl/src/darnit_csl/community-spec.toml +++ b/packages/darnit-csl/src/darnit_csl/community-spec.toml @@ -38,10 +38,6 @@ spec_version = "CSL 1.0" description = "Compliance for repositories that develop specifications under the Community Specification License 1.0" url = "https://github.com/CommunitySpecification/1.0" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - # ----------------------------------------------------------------------------- # MCP integration. Built-in audit + list tools are enough for a TOML-only # plugin; remediation is driven generically from the [remediation] blocks below. diff --git a/packages/darnit-example/example-hygiene.toml b/packages/darnit-example/example-hygiene.toml index 3dcd6bd1..10ad3b5c 100644 --- a/packages/darnit-example/example-hygiene.toml +++ b/packages/darnit-example/example-hygiene.toml @@ -12,10 +12,6 @@ schema_version = "0.1.0-alpha" spec_version = "PH v1.0" description = "Example project hygiene controls for open source projects" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - # ============================================================================= # Templates for Remediation # ============================================================================= diff --git a/packages/darnit-plugins/README.md b/packages/darnit-plugins/README.md deleted file mode 100644 index 2312a327..00000000 --- a/packages/darnit-plugins/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# darnit-plugins - -Reusable adapters for darnit compliance frameworks. - -## Overview - -This package provides check and remediation adapters that can be used by any darnit-compatible compliance framework. Adapters are discovered automatically via Python entry points. - -## Installation - -```bash -pip install darnit-plugins -``` - -## Available Adapters - -### Check Adapters - -| Adapter | Description | Entry Point | -|---------|-------------|-------------| -| `kusari` | Wrapper for Kusari SBOM/SCA CLI tool | `darnit.check_adapters` | -| `echo` | Simple echo adapter for testing | `darnit.check_adapters` | - -## Usage - -### In Framework TOML - -Reference adapters by name in your framework definition: - -```toml -# myframework.toml -[controls."CTRL-SCA-01"] -name = "DependencyScanning" -level = 2 -domain = "SCA" -description = "Scan dependencies for vulnerabilities" -check = { adapter = "kusari" } - -[controls."CTRL-TEST-01"] -name = "TestControl" -level = 1 -domain = "TEST" -description = "Test control using echo adapter" -check = { adapter = "echo", config = { status = "PASS" } } -``` - -### Programmatic Usage - -```python -from darnit.core import get_plugin_registry - -# Get registry and discover plugins -registry = get_plugin_registry() -registry.discover_all() - -# List available adapters -print(registry.list_check_adapters()) -# ['echo', 'kusari', ...] - -# Get adapter by name -adapter = registry.get_check_adapter("kusari") -result = adapter.check( - control_id="OSPS-VM-05.02", - owner="myorg", - repo="myrepo", - local_path="/path/to/repo", - config={"severity": "high"}, -) - -print(f"Status: {result.status}") -``` - -## Creating Custom Adapters - -### 1. Implement the Adapter Interface - -```python -# my_company/adapters/scanner.py -from darnit.core.adapters import CheckAdapter -from darnit.core.models import AdapterCapability, CheckResult, CheckStatus - -class MyScanner(CheckAdapter): - def name(self) -> str: - return "my-scanner" - - def capabilities(self) -> AdapterCapability: - return AdapterCapability( - control_ids={"MY-CTRL-01", "MY-CTRL-02"}, - supports_batch=True, - ) - - def check(self, control_id, owner, repo, local_path, config) -> CheckResult: - # Your check logic here - return CheckResult( - control_id=control_id, - status=CheckStatus.PASS, - message="Check passed", - level=1, - source="my-scanner", - ) -``` - -### 2. Register via Entry Points - -```toml -# pyproject.toml -[project.entry-points."darnit.check_adapters"] -my-scanner = "my_company.adapters.scanner:MyScanner" -``` - -### 3. Use in Framework Config - -```toml -# framework.toml -[controls."MY-CTRL-01"] -check = { adapter = "my-scanner" } -``` - -## Adapter Reference - -### KusariCheckAdapter - -Wrapper for the [Kusari](https://github.com/kusaridev/kusari) SBOM/SCA tool. - -**Supported Controls:** -- `OSPS-VM-05.02` - Pre-release SCA -- `OSPS-VM-05.03` - Known vulnerabilities -- `OSPS-BR-01.02` - SBOM generation -- `*-SCA-*` - Any SCA-related control -- `*-SBOM-*` - Any SBOM-related control - -**Configuration:** -```toml -[controls."OSPS-VM-05.02"] -check = { adapter = "kusari", config = { severity = "high" } } -``` - -**Requirements:** -```bash -pip install kusari # or brew install kusari -``` - -### EchoCheckAdapter - -Simple adapter that echoes back configuration as results. Useful for testing. - -**Configuration:** -```toml -[controls."TEST-001"] -check = { adapter = "echo", config = { status = "PASS", message = "Test passed" } } -``` - -**Options:** -- `status`: Return status (PASS, FAIL, ERROR, SKIP, MANUAL) -- `message`: Result message -- `delay`: Seconds to sleep (for timeout testing) -- `level`: Control level (1, 2, 3) - -## Entry Point Groups - -| Group | Purpose | -|-------|---------| -| `darnit.check_adapters` | Check adapter classes | -| `darnit.remediation_adapters` | Remediation adapter classes | -| `darnit.frameworks` | Framework TOML providers | - -## License - -Apache-2.0 diff --git a/packages/darnit-plugins/pyproject.toml b/packages/darnit-plugins/pyproject.toml deleted file mode 100644 index 20d3f1df..00000000 --- a/packages/darnit-plugins/pyproject.toml +++ /dev/null @@ -1,86 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "darnit-plugins" -version = "0.1.0" -description = "Plugin adapters for darnit compliance frameworks" -readme = "README.md" -license = "Apache-2.0" -requires-python = ">=3.10" -authors = [ - { name = "Kusari", email = "info@kusari.dev" }, -] -keywords = [ - "compliance", - "security", - "darnit-core", - "plugins", - "adapters", -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Security", - "Topic :: Software Development :: Quality Assurance", -] -dependencies = [ - "darnit-core>=0.1.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "pytest-cov>=4.0.0", -] - -[project.urls] -Homepage = "https://github.com/kusari-oss/darnit" -Repository = "https://github.com/kusari-oss/darnit" - -# ============================================================================= -# Entry Points for Plugin Discovery -# ============================================================================= -# These entry points allow darnit to discover adapters from this package. -# Any framework can reference these adapters by name in their TOML config: -# -# [controls."CTRL-001"] -# check = { adapter = "kusari" } -# -# ============================================================================= - -[project.entry-points."darnit.check_adapters"] -# CLI tool wrappers -kusari = "darnit_plugins.adapters.kusari:KusariCheckAdapter" -echo = "darnit_plugins.adapters.echo:EchoCheckAdapter" - -[project.entry-points."darnit.remediation_adapters"] -# Remediation adapters (none yet, but structure is ready) - -[tool.hatch.build.targets.wheel] -packages = ["src/darnit_plugins"] - -[tool.hatch.build.targets.sdist] -include = [ - "src/", - "README.md", -] - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_functions = ["test_*"] - -[tool.ruff] -line-length = 100 -target-version = "py310" - -[tool.ruff.lint] -select = ["E", "F", "I", "N", "W"] diff --git a/packages/darnit-plugins/src/darnit_plugins/__init__.py b/packages/darnit-plugins/src/darnit_plugins/__init__.py deleted file mode 100644 index 7e40c27a..00000000 --- a/packages/darnit-plugins/src/darnit_plugins/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Darnit Plugins - Reusable adapters for darnit compliance frameworks. - -This package provides check and remediation adapters that can be used -by any darnit-compatible compliance framework. - -Available Check Adapters: - - ``kusari``: Wrapper for the Kusari SBOM/SCA CLI tool - - ``echo``: Simple echo adapter for testing and examples - -Usage: - Adapters are automatically discovered via Python entry points. - Reference them by name in your framework TOML:: - - # In framework.toml - [controls."CTRL-001"] - check = { adapter = "kusari" } - - Or use them programmatically:: - - from darnit.core import get_plugin_registry - - registry = get_plugin_registry() - adapter = registry.get_check_adapter("kusari") - - result = adapter.check("CTRL-001", "owner", "repo", "/path", {}) - -Example: - Using the echo adapter for testing:: - - from darnit_plugins.adapters.echo import EchoCheckAdapter - - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", "/path", {"status": "PASS"}) - print(result.status) # PASS - -See Also: - - :mod:`darnit.core.registry` for plugin discovery - - :mod:`darnit.core.adapters` for adapter base classes -""" - -__version__ = "0.1.0" - -__all__ = [ - "__version__", -] diff --git a/packages/darnit-plugins/src/darnit_plugins/adapters/__init__.py b/packages/darnit-plugins/src/darnit_plugins/adapters/__init__.py deleted file mode 100644 index f43c7e69..00000000 --- a/packages/darnit-plugins/src/darnit_plugins/adapters/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Adapter implementations for darnit-plugins. - -This module provides reusable check adapters that can be used by -any darnit-compatible compliance framework. - -Available Adapters: - - :class:`KusariCheckAdapter`: Wrapper for Kusari SBOM/SCA tool - - :class:`EchoCheckAdapter`: Simple echo adapter for testing - -These adapters are registered via entry points and can be referenced -by name in framework TOML configurations:: - - [controls."CTRL-001"] - check = { adapter = "kusari" } - -See Also: - - :mod:`darnit.core.adapters` for adapter base classes -""" - -from .echo import EchoCheckAdapter -from .kusari import KusariCheckAdapter - -__all__ = [ - "KusariCheckAdapter", - "EchoCheckAdapter", -] diff --git a/packages/darnit-plugins/src/darnit_plugins/adapters/echo.py b/packages/darnit-plugins/src/darnit_plugins/adapters/echo.py deleted file mode 100644 index 25f82482..00000000 --- a/packages/darnit-plugins/src/darnit_plugins/adapters/echo.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Echo adapter for testing and examples. - -This module provides a simple check adapter that echoes back the -configuration as the result. Useful for testing, examples, and -debugging adapter integration. - -Usage: - In a framework config:: - - [controls."TEST-001"] - check = { adapter = "echo", config = { status = "PASS" } } - - Programmatically:: - - from darnit_plugins.adapters.echo import EchoCheckAdapter - - adapter = EchoCheckAdapter() - result = adapter.check( - "TEST-001", - "", - "", - "/path", - {"status": "PASS", "message": "All good!"}, - ) - print(result.status) # PASS - print(result.message) # All good! - -Configuration: - The adapter accepts the following configuration options: - - - ``status``: CheckStatus to return (PASS, FAIL, ERROR, SKIP, MANUAL) - - ``message``: Message to include in result - - ``delay``: Seconds to sleep before returning (for testing timeouts) - -Example: - Testing a framework's control routing:: - - # framework.toml - [controls."MY-CTRL-01"] - check = { adapter = "echo", config = { status = "PASS" } } - - [controls."MY-CTRL-02"] - check = { adapter = "echo", config = { status = "FAIL", message = "Test failure" } } - -See Also: - - :class:`darnit.core.adapters.CheckAdapter` for the adapter interface -""" - -import time -from typing import Any, Dict, List - -from darnit.core.adapters import CheckAdapter -from darnit.core.models import AdapterCapability, CheckResult, CheckStatus - - -class EchoCheckAdapter(CheckAdapter): - """Simple echo adapter for testing and examples. - - Returns configurable results based on the provided configuration. - Useful for testing framework integration, control routing, and - as an example for building custom adapters. - - Attributes: - default_status: Default status when not specified in config - - Example: - Basic usage:: - - adapter = EchoCheckAdapter() - - # Returns PASS - result = adapter.check("CTRL-001", "", "", "/path", { - "status": "PASS", - "message": "Everything is fine", - }) - - # Returns FAIL - result = adapter.check("CTRL-002", "", "", "/path", { - "status": "FAIL", - "message": "Something went wrong", - }) - - Testing timeout handling:: - - result = adapter.check("CTRL-003", "", "", "/path", { - "delay": 5, # Sleep for 5 seconds - "status": "PASS", - }) - - See Also: - - :class:`darnit.core.adapters.CheckAdapter` for the base interface - """ - - def __init__(self, default_status: str = "PASS"): - """Initialize the echo adapter. - - Args: - default_status: Default status when not specified in config - """ - self.default_status = default_status - - def name(self) -> str: - """Return adapter name. - - Returns: - The string "echo" - """ - return "echo" - - def capabilities(self) -> AdapterCapability: - """Return adapter capabilities. - - The echo adapter can handle any control (wildcard). - - Returns: - AdapterCapability with wildcard control support - """ - return AdapterCapability( - control_ids={"*"}, # Handles any control - supports_batch=True, - ) - - def check( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - ) -> CheckResult: - """Return a check result based on configuration. - - Args: - control_id: The control identifier - owner: Repository owner (ignored) - repo: Repository name (ignored) - local_path: Path to repository (ignored) - config: Configuration dict with optional keys: - - status: CheckStatus string (PASS, FAIL, ERROR, SKIP, MANUAL) - - message: Result message - - delay: Seconds to sleep before returning - - level: Control level (1, 2, 3) - - Returns: - CheckResult with configured status and message - - Example: - >>> adapter = EchoCheckAdapter() - >>> result = adapter.check("TEST-001", "", "", "/path", { - ... "status": "PASS", - ... "message": "Test passed!", - ... }) - >>> result.status - - """ - # Optional delay for testing timeouts - delay = config.get("delay", 0) - if delay: - time.sleep(delay) - - # Get status from config or default - status_str = config.get("status", self.default_status) - try: - # Handle case-insensitive status values - status = CheckStatus(status_str.lower()) - except (ValueError, AttributeError): - status = CheckStatus.ERROR - status_str = f"Invalid status '{status_str}', using ERROR" - - # Get message from config or generate one - message = config.get( - "message", - f"Echo adapter: {control_id} returned {status_str}", - ) - - # Get level from config or default to 1 - level = config.get("level", 1) - - return CheckResult( - control_id=control_id, - status=status, - message=message, - level=level, - source="echo", - ) - - def check_batch( - self, - control_ids: List[str], - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - ) -> List[CheckResult]: - """Return check results for multiple controls. - - Args: - control_ids: List of control identifiers - owner: Repository owner - repo: Repository name - local_path: Path to repository - config: Configuration dict (applied to all controls) - - Returns: - List of CheckResult objects - - Example: - >>> results = adapter.check_batch( - ... ["TEST-001", "TEST-002"], - ... "", "", "/path", - ... {"status": "PASS"}, - ... ) - >>> len(results) - 2 - """ - return [ - self.check(control_id, owner, repo, local_path, config) - for control_id in control_ids - ] diff --git a/packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py b/packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py deleted file mode 100644 index bdb7d2ad..00000000 --- a/packages/darnit-plugins/src/darnit_plugins/adapters/kusari.py +++ /dev/null @@ -1,538 +0,0 @@ -"""Kusari adapter for darnit. - -This module provides a check adapter that wraps the Kusari CLI tool -for analyzing code changes, dependencies, and security posture. - -Kusari Inspector analyzes the differences between a git repository's working -tree and a specified revision. It performs: - -- **Dependency Analysis**: Identifies added/removed/modified dependencies -- **SBOM Generation**: Creates Software Bill of Materials when needed -- **Vulnerability Scanning**: Checks dependencies against known vulnerabilities -- **Code Change Analysis**: Reviews code modifications for security implications - -The analysis results are uploaded to Kusari Console and returned in the -specified output format (markdown or SARIF). - -Usage: - The adapter can be referenced by name in framework configs:: - - # In framework.toml - [controls."OSPS-VM-05.02"] - check = { adapter = "kusari" } - - Or instantiated directly:: - - from darnit_plugins.adapters.kusari import KusariCheckAdapter - - adapter = KusariCheckAdapter() - result = adapter.check( - "OSPS-VM-05.02", - "owner", - "repo", - "/path/to/repo", - {"git_rev": "HEAD", "output_format": "sarif"}, - ) - -Configuration Options: - The adapter accepts the following configuration options: - - - ``git_rev``: Git revision to compare against (default: "HEAD") - Examples: "HEAD", "HEAD^", "origin/main", commit SHA - - ``output_format``: Output format - "markdown" or "sarif" (default: "markdown") - - ``wait``: Wait for results (default: True) - - ``console_url``: Kusari Console URL (optional) - - ``platform_url``: Kusari Platform URL (optional) - - ``verbose``: Enable verbose output (default: False) - -Requirements: - Kusari CLI must be installed and authenticated:: - - # Install - brew install kusari - # or download from https://github.com/kusaridev/kusari-cli - - # Authenticate - kusari auth login - -See Also: - - https://github.com/kusaridev/kusari-cli for CLI documentation - - https://console.us.kusari.cloud/ for Kusari Console -""" - -import json -import logging -import subprocess -from typing import Any, Dict, List - -from darnit.core.adapters import CheckAdapter -from darnit.core.models import AdapterCapability, CheckResult, CheckStatus - -logger = logging.getLogger(__name__) - - -class KusariCheckAdapter(CheckAdapter): - """Check adapter that wraps the Kusari CLI tool. - - Kusari analyzes code changes between the working tree and a git revision, - performing dependency analysis, SBOM generation, and vulnerability scanning. - Results are uploaded to Kusari Console and returned for compliance checking. - - Attributes: - _kusari_path: Path to the kusari binary - _timeout: Command timeout in seconds - _default_git_rev: Default git revision for comparisons - - Example: - Basic usage with default settings (compare against HEAD):: - - adapter = KusariCheckAdapter() - result = adapter.check( - control_id="OSPS-VM-05.03", - owner="kusari-oss", - repo="darnit", - local_path="/path/to/repo", - config={}, # Uses HEAD by default - ) - - Compare against a specific revision:: - - result = adapter.check( - control_id="OSPS-VM-05.02", - owner="", - repo="myproject", - local_path="/path/to/repo", - config={ - "git_rev": "origin/main", - "output_format": "sarif", - }, - ) - - Check pre-release changes (compare working tree to last commit):: - - result = adapter.check( - control_id="OSPS-VM-05.02", - owner="", - repo="myproject", - local_path="/path/to/repo", - config={"git_rev": "HEAD"}, - ) - - See Also: - - :class:`darnit.core.adapters.CheckAdapter` for the base interface - """ - - # Controls this adapter can handle - # These map to OpenSSF Baseline controls related to dependency/vulnerability management - SUPPORTED_CONTROLS = { - # Vulnerability Management controls - "OSPS-VM-05.01", # Automated dependency vulnerability scanning - "OSPS-VM-05.02", # Pre-release SCA (software composition analysis) - "OSPS-VM-05.03", # Known vulnerability remediation - # Build & Release controls - "OSPS-BR-01.02", # SBOM generation - "OSPS-BR-01.03", # Dependency inventory - } - - def __init__( - self, - kusari_path: str = "kusari", - timeout: int = 300, - default_git_rev: str = "HEAD", - ): - """Initialize the Kusari adapter. - - Args: - kusari_path: Path to the kusari binary (default: "kusari") - timeout: Command timeout in seconds (default: 300) - default_git_rev: Default git revision for comparisons (default: "HEAD") - """ - self._kusari_path = kusari_path - self._timeout = timeout - self._default_git_rev = default_git_rev - - def name(self) -> str: - """Return adapter name. - - Returns: - The string "kusari" - """ - return "kusari" - - def capabilities(self) -> AdapterCapability: - """Return adapter capabilities. - - Kusari supports batch operations since a single scan can provide - data for multiple controls (dependencies, vulnerabilities, SBOM). - - Returns: - AdapterCapability indicating supported controls and features - - Example: - >>> adapter = KusariCheckAdapter() - >>> caps = adapter.capabilities() - >>> "OSPS-VM-05.02" in caps.control_ids - True - >>> caps.supports_batch - True - """ - return AdapterCapability( - control_ids=self.SUPPORTED_CONTROLS, - supports_batch=True, - ) - - def check( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - ) -> CheckResult: - """Run a compliance check using Kusari repo scan. - - Executes ``kusari repo scan `` to analyze - code changes and dependencies. The scan results are used to - determine compliance with the specified control. - - Args: - control_id: The control identifier to check - owner: Repository owner (used for context, not in command) - repo: Repository name (used for context, not in command) - local_path: Path to the local git repository - config: Configuration options: - - git_rev: Git revision to compare against (default: "HEAD") - - output_format: "markdown" or "sarif" (default: "markdown") - - wait: Wait for results (default: True) - - verbose: Enable verbose output (default: False) - - Returns: - CheckResult with the scan outcome and any findings - - Example: - >>> result = adapter.check( - ... "OSPS-VM-05.03", - ... "kusari-oss", - ... "darnit", - ... "/path/to/repo", - ... {"git_rev": "HEAD", "output_format": "sarif"}, - ... ) - >>> print(result.status) - """ - git_rev = config.get("git_rev", self._default_git_rev) - output_format = config.get("output_format", "markdown") - wait = config.get("wait", True) - verbose = config.get("verbose", False) - - # Build the kusari repo scan command - cmd = [ - self._kusari_path, - "repo", - "scan", - local_path, - git_rev, - "--output-format", - output_format, - ] - - if wait: - cmd.append("--wait") - - if verbose: - cmd.append("--verbose") - - # Add optional URL overrides - if config.get("console_url"): - cmd.extend(["--console-url", config["console_url"]]) - if config.get("platform_url"): - cmd.extend(["--platform-url", config["platform_url"]]) - - logger.debug(f"Running Kusari command: {' '.join(cmd)}") - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=self._timeout, - ) - - # Parse the output based on format and control type - return self._parse_result( - control_id=control_id, - returncode=result.returncode, - stdout=result.stdout, - stderr=result.stderr, - output_format=output_format, - ) - - except subprocess.TimeoutExpired: - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message=f"Kusari scan timed out after {self._timeout}s", - level=self._get_control_level(control_id), - source="kusari", - ) - except FileNotFoundError: - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message=( - f"Kusari CLI not found at '{self._kusari_path}'. " - "Install with: brew install kusari" - ), - level=self._get_control_level(control_id), - source="kusari", - ) - except Exception as e: - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message=f"Kusari scan failed: {e}", - level=self._get_control_level(control_id), - source="kusari", - ) - - def _parse_result( - self, - control_id: str, - returncode: int, - stdout: str, - stderr: str, - output_format: str, - ) -> CheckResult: - """Parse Kusari output into a CheckResult. - - Args: - control_id: The control being checked - returncode: Command exit code - stdout: Command stdout - stderr: Command stderr - output_format: Output format used ("markdown" or "sarif") - - Returns: - CheckResult based on scan findings - """ - level = self._get_control_level(control_id) - - # Check for authentication errors - if "auth error" in stderr.lower() or "token is expired" in stderr.lower(): - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message="Kusari authentication required. Run: kusari auth login", - level=level, - source="kusari", - ) - - # SARIF format parsing - if output_format == "sarif": - return self._parse_sarif_result(control_id, stdout, stderr, level) - - # Markdown format - check for flagged issues - if "No Flagged Issues Detected" in stdout: - return CheckResult( - control_id=control_id, - status=CheckStatus.PASS, - message="Kusari scan completed with no flagged issues", - level=level, - source="kusari", - evidence=stdout[:500] if stdout else None, - ) - elif "Flagged Issues Detected" in stdout: - return CheckResult( - control_id=control_id, - status=CheckStatus.FAIL, - message="Kusari scan detected flagged issues", - level=level, - source="kusari", - details={"raw_output": stdout}, - evidence=stdout[:1000] if stdout else None, - ) - elif returncode == 0: - return CheckResult( - control_id=control_id, - status=CheckStatus.PASS, - message="Kusari scan completed successfully", - level=level, - source="kusari", - evidence=stdout[:500] if stdout else None, - ) - else: - return CheckResult( - control_id=control_id, - status=CheckStatus.FAIL, - message=f"Kusari scan failed: {stderr or stdout}", - level=level, - source="kusari", - ) - - def _parse_sarif_result( - self, - control_id: str, - stdout: str, - stderr: str, - level: int, - ) -> CheckResult: - """Parse SARIF format output. - - Args: - control_id: The control being checked - stdout: SARIF JSON output - stderr: Command stderr - level: Control level - - Returns: - CheckResult based on SARIF findings - """ - try: - sarif = json.loads(stdout) - runs = sarif.get("runs", []) - - total_results = 0 - errors = 0 - warnings = 0 - - for run in runs: - for result in run.get("results", []): - total_results += 1 - result_level = result.get("level", "note") - if result_level == "error": - errors += 1 - elif result_level == "warning": - warnings += 1 - - if errors > 0: - return CheckResult( - control_id=control_id, - status=CheckStatus.FAIL, - message=f"Kusari found {errors} error(s) and {warnings} warning(s)", - level=level, - source="kusari", - details={"sarif": sarif, "errors": errors, "warnings": warnings}, - ) - elif warnings > 0: - return CheckResult( - control_id=control_id, - status=CheckStatus.WARN, - message=f"Kusari found {warnings} warning(s)", - level=level, - source="kusari", - details={"sarif": sarif, "warnings": warnings}, - ) - else: - return CheckResult( - control_id=control_id, - status=CheckStatus.PASS, - message="Kusari scan completed with no issues", - level=level, - source="kusari", - ) - - except json.JSONDecodeError: - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message=f"Failed to parse Kusari SARIF output: {stderr or stdout[:200]}", - level=level, - source="kusari", - ) - - def _get_control_level(self, control_id: str) -> int: - """Get the maturity level for a control. - - Args: - control_id: The control identifier - - Returns: - Maturity level (1, 2, or 3) - """ - # Level mappings based on OpenSSF Baseline - level_map = { - "OSPS-VM-05.01": 2, # Automated scanning - "OSPS-VM-05.02": 3, # Pre-release SCA - "OSPS-VM-05.03": 3, # Known vulnerability remediation - "OSPS-BR-01.02": 2, # SBOM generation - "OSPS-BR-01.03": 2, # Dependency inventory - } - return level_map.get(control_id, 1) - - def check_batch( - self, - control_ids: List[str], - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - ) -> List[CheckResult]: - """Run checks for multiple controls using a single Kusari scan. - - Since Kusari performs comprehensive analysis in a single scan, - this method runs the scan once and maps the results to multiple - controls based on the findings. - - Args: - control_ids: List of control identifiers to check - owner: Repository owner - repo: Repository name - local_path: Path to the local repository - config: Configuration options (see check() for details) - - Returns: - List of CheckResult objects, one per control - - Example: - >>> results = adapter.check_batch( - ... ["OSPS-VM-05.02", "OSPS-VM-05.03", "OSPS-BR-01.02"], - ... "kusari-oss", - ... "darnit", - ... "/path/to/repo", - ... {"git_rev": "HEAD"}, - ... ) - >>> for r in results: - ... print(f"{r.control_id}: {r.status.value}") - """ - # Run a single scan with SARIF output for detailed parsing - sarif_config = dict(config) - sarif_config["output_format"] = "sarif" - - # Use the first control for the initial scan - primary_result = self.check( - control_ids[0] if control_ids else "OSPS-VM-05.01", - owner, - repo, - local_path, - sarif_config, - ) - - # If scan failed, return error for all controls - if primary_result.status == CheckStatus.ERROR: - return [ - CheckResult( - control_id=cid, - status=CheckStatus.ERROR, - message=primary_result.message, - level=self._get_control_level(cid), - source="kusari", - ) - for cid in control_ids - ] - - # Map scan results to individual controls - # In a full implementation, this would parse SARIF findings - # and map specific issues to specific controls - results = [] - for control_id in control_ids: - results.append( - CheckResult( - control_id=control_id, - status=primary_result.status, - message=primary_result.message, - level=self._get_control_level(control_id), - source="kusari", - details=primary_result.details, - ) - ) - - return results diff --git a/packages/darnit-plugins/tests/conftest.py b/packages/darnit-plugins/tests/conftest.py deleted file mode 100644 index c4cef641..00000000 --- a/packages/darnit-plugins/tests/conftest.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Pytest fixtures for darnit-plugins tests.""" - -import sys -from pathlib import Path - -import pytest - -# Add package paths for testing without installation -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "darnit" / "src")) -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - - -@pytest.fixture -def temp_repo(tmp_path: Path) -> Path: - """Create a minimal temporary repository.""" - (tmp_path / ".git").mkdir() - return tmp_path - - -@pytest.fixture -def repo_with_deps(tmp_path: Path) -> Path: - """Create a repository with dependency files.""" - (tmp_path / ".git").mkdir() - (tmp_path / "package.json").write_text('{"dependencies": {"lodash": "^4.0.0"}}') - (tmp_path / "requirements.txt").write_text("requests>=2.28.0\n") - return tmp_path diff --git a/packages/darnit-plugins/tests/test_echo_adapter.py b/packages/darnit-plugins/tests/test_echo_adapter.py deleted file mode 100644 index 72fbc4e5..00000000 --- a/packages/darnit-plugins/tests/test_echo_adapter.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for the EchoCheckAdapter.""" - -from pathlib import Path - -from darnit.core.models import CheckStatus - -from darnit_plugins.adapters.echo import EchoCheckAdapter - - -class TestEchoAdapterBasics: - """Basic adapter functionality tests.""" - - def test_adapter_name(self): - """Should return correct name.""" - adapter = EchoCheckAdapter() - assert adapter.name() == "echo" - - def test_adapter_capabilities(self): - """Should support any control (wildcard).""" - adapter = EchoCheckAdapter() - caps = adapter.capabilities() - - assert "*" in caps.control_ids - assert caps.supports_batch is True - - def test_supports_any_control(self): - """Should handle any control ID.""" - adapter = EchoCheckAdapter() - - assert adapter.supports_control("ANY-CTRL-01") - assert adapter.supports_control("OSPS-VM-05.02") - assert adapter.supports_control("CUSTOM-TEST-123") - - -class TestEchoAdapterCheck: - """Tests for check() method.""" - - def test_default_pass(self, temp_repo: Path): - """Should return PASS by default.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), {}) - - assert result.control_id == "TEST-001" - assert result.status == CheckStatus.PASS - assert result.source == "echo" - - def test_configured_pass(self, temp_repo: Path): - """Should return PASS when configured.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "status": "PASS", - "message": "All good!", - }) - - assert result.status == CheckStatus.PASS - assert result.message == "All good!" - - def test_configured_fail(self, temp_repo: Path): - """Should return FAIL when configured.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "status": "FAIL", - "message": "Something wrong", - }) - - assert result.status == CheckStatus.FAIL - assert result.message == "Something wrong" - - def test_configured_error(self, temp_repo: Path): - """Should return ERROR when configured.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "status": "ERROR", - }) - - assert result.status == CheckStatus.ERROR - - def test_configured_warn(self, temp_repo: Path): - """Should return WARN when configured.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "status": "WARN", - }) - - assert result.status == CheckStatus.WARN - - def test_configured_na(self, temp_repo: Path): - """Should return NA when configured.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "status": "NA", - }) - - assert result.status == CheckStatus.NA - - def test_invalid_status(self, temp_repo: Path): - """Should return ERROR for invalid status.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "status": "INVALID", - }) - - assert result.status == CheckStatus.ERROR - - def test_custom_level(self, temp_repo: Path): - """Should use configured level.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), { - "level": 3, - }) - - assert result.level == 3 - - def test_default_level(self, temp_repo: Path): - """Should default to level 1.""" - adapter = EchoCheckAdapter() - result = adapter.check("TEST-001", "", "", str(temp_repo), {}) - - assert result.level == 1 - - def test_custom_default_status(self, temp_repo: Path): - """Should use custom default status.""" - adapter = EchoCheckAdapter(default_status="FAIL") - result = adapter.check("TEST-001", "", "", str(temp_repo), {}) - - assert result.status == CheckStatus.FAIL - - -class TestEchoAdapterBatch: - """Tests for check_batch() method.""" - - def test_batch_check(self, temp_repo: Path): - """Should check multiple controls.""" - adapter = EchoCheckAdapter() - results = adapter.check_batch( - ["TEST-001", "TEST-002", "TEST-003"], - "", - "", - str(temp_repo), - {"status": "PASS"}, - ) - - assert len(results) == 3 - assert all(r.status == CheckStatus.PASS for r in results) - assert [r.control_id for r in results] == ["TEST-001", "TEST-002", "TEST-003"] - - def test_batch_empty(self, temp_repo: Path): - """Should handle empty control list.""" - adapter = EchoCheckAdapter() - results = adapter.check_batch([], "", "", str(temp_repo), {}) - - assert len(results) == 0 diff --git a/packages/darnit-plugins/tests/test_plugin_registry.py b/packages/darnit-plugins/tests/test_plugin_registry.py deleted file mode 100644 index 6188dd2f..00000000 --- a/packages/darnit-plugins/tests/test_plugin_registry.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Tests for the PluginRegistry.""" - -from pathlib import Path - -from darnit.core.adapters import CheckAdapter -from darnit.core.models import AdapterCapability, CheckResult, CheckStatus -from darnit.core.registry import ( - AdapterInfo, - FrameworkInfo, - PluginRegistry, - get_plugin_registry, - reset_plugin_registry, -) - - -class MockCheckAdapter(CheckAdapter): - """Mock adapter for testing.""" - - def name(self) -> str: - return "mock" - - def capabilities(self) -> AdapterCapability: - return AdapterCapability( - control_ids={"MOCK-01", "MOCK-02"}, - supports_batch=True, - ) - - def check(self, control_id, owner, repo, local_path, config) -> CheckResult: - return CheckResult( - control_id=control_id, - status=CheckStatus.PASS, - message="Mock check passed", - level=1, - source="mock", - ) - - -class TestPluginRegistryBasics: - """Basic registry functionality tests.""" - - def setup_method(self): - """Reset registry before each test.""" - reset_plugin_registry() - - def test_global_registry_singleton(self): - """Should return the same instance.""" - reg1 = get_plugin_registry() - reg2 = get_plugin_registry() - assert reg1 is reg2 - - def test_reset_registry(self): - """Should create new instance after reset.""" - reg1 = get_plugin_registry() - reset_plugin_registry() - reg2 = get_plugin_registry() - assert reg1 is not reg2 - - def test_clear_cache(self): - """Should clear all caches.""" - registry = PluginRegistry() - registry.register_check_adapter("test", MockCheckAdapter) - - assert "test" in registry.list_check_adapters() - - registry.clear_cache() - - # After clear, manually registered adapters are gone - assert "test" not in registry._check_adapters - - -class TestFrameworkRegistration: - """Tests for framework registration and discovery.""" - - def setup_method(self): - reset_plugin_registry() - - def test_register_framework(self): - """Should register a framework.""" - registry = PluginRegistry() - registry.register_framework( - "test-framework", - lambda: Path("/path/to/test.toml"), - ) - - assert "test-framework" in registry.list_frameworks() - - def test_get_framework_path(self): - """Should return framework path.""" - registry = PluginRegistry() - registry.register_framework( - "test-framework", - lambda: Path("/path/to/test.toml"), - ) - - path = registry.get_framework_path("test-framework") - assert path == Path("/path/to/test.toml") - - def test_get_framework_path_not_found(self): - """Should return None for unknown framework.""" - registry = PluginRegistry() - path = registry.get_framework_path("nonexistent") - assert path is None - - def test_has_framework(self): - """Should check framework existence.""" - registry = PluginRegistry() - registry.register_framework("exists", lambda: Path("/test.toml")) - - assert registry.has_framework("exists") - assert not registry.has_framework("missing") - - def test_get_framework_info(self): - """Should return FrameworkInfo.""" - registry = PluginRegistry() - registry.register_framework( - "test", - lambda: Path("/test.toml"), - package="test-package", - ) - - info = registry.get_framework_info("test") - assert info is not None - assert info.name == "test" - assert info.package == "test-package" - - -class TestCheckAdapterRegistration: - """Tests for check adapter registration and discovery.""" - - def setup_method(self): - reset_plugin_registry() - - def test_register_adapter_class(self): - """Should register an adapter class.""" - registry = PluginRegistry() - registry.register_check_adapter("mock", MockCheckAdapter) - - assert "mock" in registry.list_check_adapters() - - def test_register_adapter_instance(self): - """Should register an adapter instance.""" - registry = PluginRegistry() - instance = MockCheckAdapter() - registry.register_check_adapter("mock", instance) - - adapter = registry.get_check_adapter("mock") - assert adapter is instance - - def test_get_check_adapter(self): - """Should return adapter instance.""" - registry = PluginRegistry() - registry.register_check_adapter("mock", MockCheckAdapter) - - adapter = registry.get_check_adapter("mock") - assert adapter is not None - assert adapter.name() == "mock" - - def test_get_check_adapter_cached(self): - """Should cache adapter instances.""" - registry = PluginRegistry() - registry.register_check_adapter("mock", MockCheckAdapter) - - adapter1 = registry.get_check_adapter("mock") - adapter2 = registry.get_check_adapter("mock") - assert adapter1 is adapter2 - - def test_get_check_adapter_not_found(self): - """Should return None for unknown adapter.""" - registry = PluginRegistry() - adapter = registry.get_check_adapter("nonexistent") - assert adapter is None - - def test_has_check_adapter(self): - """Should check adapter existence.""" - registry = PluginRegistry() - registry.register_check_adapter("mock", MockCheckAdapter) - - assert registry.has_check_adapter("mock") - assert not registry.has_check_adapter("missing") - - def test_get_check_adapter_info(self): - """Should return AdapterInfo.""" - registry = PluginRegistry() - registry.register_check_adapter("mock", MockCheckAdapter, package="test-pkg") - - info = registry.get_check_adapter_info("mock") - assert info is not None - assert info.name == "mock" - assert info.package == "test-pkg" - assert info.adapter_type == "check" - - -class TestConfigBasedRegistration: - """Tests for config-based adapter registration.""" - - def setup_method(self): - reset_plugin_registry() - - def test_register_command_adapter(self): - """Should register a command adapter from config.""" - registry = PluginRegistry() - adapter = registry.register_from_adapter_config("test-cmd", { - "type": "command", - "command": "echo", - "output_format": "json", - }) - - assert adapter is not None - assert adapter.name() == "test-cmd" - - def test_register_script_adapter(self): - """Should register a script adapter from config.""" - registry = PluginRegistry() - adapter = registry.register_from_adapter_config("test-script", { - "type": "script", - "command": "/bin/true", - }) - - assert adapter is not None - assert adapter.name() == "test-script" - - def test_register_unknown_type(self): - """Should return None for unknown adapter type.""" - registry = PluginRegistry() - adapter = registry.register_from_adapter_config("test", { - "type": "unknown", - }) - - assert adapter is None - - -class TestPluginSummary: - """Tests for plugin summary.""" - - def setup_method(self): - reset_plugin_registry() - - def test_get_plugin_summary(self): - """Should return plugin summary.""" - registry = PluginRegistry() - registry.register_framework("fw1", lambda: Path("/fw1.toml")) - registry.register_check_adapter("adapter1", MockCheckAdapter) - - summary = registry.get_plugin_summary() - - assert "frameworks" in summary - assert "check_adapters" in summary - assert "remediation_adapters" in summary - assert "counts" in summary - - assert "fw1" in summary["frameworks"] - assert "adapter1" in summary["check_adapters"] - - -class TestAdapterInfo: - """Tests for AdapterInfo class.""" - - def test_get_instance_lazy(self): - """Should lazily instantiate adapter.""" - info = AdapterInfo( - name="test", - package="test-pkg", - entry_point_name="test", - adapter_class=MockCheckAdapter, - adapter_type="check", - ) - - # Not instantiated yet - assert info._instance is None - - # Get instance - instance = info.get_instance() - assert instance is not None - assert isinstance(instance, MockCheckAdapter) - - # Cached - assert info._instance is instance - assert info.get_instance() is instance - - def test_capabilities_property(self): - """Should get capabilities via property.""" - info = AdapterInfo( - name="test", - package="test-pkg", - entry_point_name="test", - adapter_class=MockCheckAdapter, - adapter_type="check", - ) - - caps = info.capabilities - assert caps is not None - assert "MOCK-01" in caps.control_ids - - -class TestFrameworkInfo: - """Tests for FrameworkInfo class.""" - - def test_path_lazy(self): - """Should lazily resolve path.""" - call_count = [0] - - def get_path(): - call_count[0] += 1 - return Path("/test.toml") - - info = FrameworkInfo( - name="test", - package="test-pkg", - entry_point_name="test", - path_func=get_path, - ) - - # Not resolved yet - assert info._path is None - assert call_count[0] == 0 - - # Get path - path = info.path - assert path == Path("/test.toml") - assert call_count[0] == 1 - - # Cached - assert info.path == Path("/test.toml") - assert call_count[0] == 1 # Not called again diff --git a/packages/darnit-testchecks/README.md b/packages/darnit-testchecks/README.md index 73112203..52dc604c 100644 --- a/packages/darnit-testchecks/README.md +++ b/packages/darnit-testchecks/README.md @@ -8,7 +8,6 @@ This package demonstrates how to create a custom compliance framework using the - **12 trivial controls** across 3 maturity levels - **Declarative framework definition** in `testchecks.toml` -- **Python check implementations** in the builtin adapter - **Simple remediations** for basic controls ## Installation @@ -54,27 +53,14 @@ pip install -e packages/darnit-testchecks ## Usage -### Running Checks +### Loading the Framework ```python from darnit_testchecks import get_framework_path -from darnit_testchecks.adapters import get_test_check_adapter from darnit.config.merger import load_framework_config -# Load framework framework = load_framework_config(get_framework_path()) print(f"Loaded {len(framework.controls)} controls") - -# Run checks -adapter = get_test_check_adapter() -result = adapter.check( - control_id="TEST-DOC-01", - owner="", - repo="", - local_path="/path/to/repo", - config={}, -) -print(f"{result.control_id}: {result.status.value} - {result.message}") ``` ### Selecting the Framework and Claiming Controls Not Applicable @@ -102,7 +88,7 @@ Use this package as a template: 1. Copy the package structure 2. Edit `testchecks.toml` with your controls -3. Implement check functions in `adapters/builtin.py` +3. Declare each control's `passes` in the TOML 4. Update `pyproject.toml` entry points ## License diff --git a/packages/darnit-testchecks/pyproject.toml b/packages/darnit-testchecks/pyproject.toml index 0a0a11fd..f70ab367 100644 --- a/packages/darnit-testchecks/pyproject.toml +++ b/packages/darnit-testchecks/pyproject.toml @@ -47,16 +47,6 @@ Repository = "https://github.com/kusari-oss/darnit" [project.entry-points."darnit.frameworks"] testchecks = "darnit_testchecks:get_framework_path" -[project.entry-points."darnit.check_adapters"] -testchecks = "darnit_testchecks.adapters.builtin:TrivialCheckAdapter" - -[project.entry-points."darnit.remediation_adapters"] -testchecks = "darnit_testchecks.adapters.builtin:TrivialRemediationAdapter" - -# Legacy entry point (deprecated, kept for backwards compatibility) -[project.entry-points."darnit.adapters"] -testchecks = "darnit_testchecks.adapters.builtin:get_test_check_adapter" - # In-memory reference store backends (feature 033 T020) [project.entry-points."darnit.stores.project"] in-memory = "darnit_testchecks.stores:InMemoryProjectStateStore" diff --git a/packages/darnit-testchecks/src/darnit_testchecks/adapters/__init__.py b/packages/darnit-testchecks/src/darnit_testchecks/adapters/__init__.py deleted file mode 100644 index 615badaa..00000000 --- a/packages/darnit-testchecks/src/darnit_testchecks/adapters/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Adapters for Test Checks Framework.""" - -from .builtin import ( - TrivialCheckAdapter, - TrivialRemediationAdapter, - get_test_check_adapter, - get_test_remediation_adapter, -) - -__all__ = [ - "TrivialCheckAdapter", - "TrivialRemediationAdapter", - "get_test_check_adapter", - "get_test_remediation_adapter", -] diff --git a/packages/darnit-testchecks/src/darnit_testchecks/adapters/builtin.py b/packages/darnit-testchecks/src/darnit_testchecks/adapters/builtin.py deleted file mode 100644 index 6d48f36c..00000000 --- a/packages/darnit-testchecks/src/darnit_testchecks/adapters/builtin.py +++ /dev/null @@ -1,675 +0,0 @@ -"""Builtin adapter for Test Checks Framework. - -This adapter implements the trivial checks defined in testchecks.toml. -It demonstrates how to create custom check implementations for a -darnit framework. -""" - -import logging -import re -from pathlib import Path -from typing import Any, Dict, List, Optional - -from darnit.core.adapters import CheckAdapter, RemediationAdapter -from darnit.core.models import ( - AdapterCapability, - CheckResult, - CheckStatus, - RemediationResult, -) - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# Control Definitions -# ============================================================================= - -# Control metadata for quick lookup -CONTROLS = { - # Level 1 - Documentation - "TEST-DOC-01": {"name": "HasReadme", "level": 1, "domain": "DOC"}, - "TEST-DOC-02": {"name": "HasChangelog", "level": 1, "domain": "DOC"}, - "TEST-LIC-01": {"name": "HasLicense", "level": 1, "domain": "LIC"}, - "TEST-IGN-01": {"name": "HasGitignore", "level": 1, "domain": "CFG"}, - # Level 2 - Quality - "TEST-QA-01": {"name": "NoTodoComments", "level": 2, "domain": "QA"}, - "TEST-QA-02": {"name": "NoPrintStatements", "level": 2, "domain": "QA"}, - "TEST-CFG-01": {"name": "HasEditorConfig", "level": 2, "domain": "CFG"}, - "TEST-CFG-02": {"name": "HasPreCommitConfig", "level": 2, "domain": "CFG"}, - # Level 3 - Security & CI - "TEST-SEC-01": {"name": "NoHardcodedPasswords", "level": 3, "domain": "SEC"}, - "TEST-SEC-02": {"name": "GitignoreSecrets", "level": 3, "domain": "SEC"}, - "TEST-CI-01": {"name": "HasCIConfig", "level": 3, "domain": "CI"}, - "TEST-CI-02": {"name": "CIRunsTests", "level": 3, "domain": "CI"}, -} - - -# ============================================================================= -# Check Implementations -# ============================================================================= - - -def check_file_exists(local_path: str, file_patterns: List[str]) -> tuple: - """Check if any of the specified files exist. - - Args: - local_path: Path to repository root - file_patterns: List of file patterns to check - - Returns: - Tuple of (exists: bool, found_file: Optional[str]) - """ - repo = Path(local_path) - - for pattern in file_patterns: - if "*" in pattern: - # Glob pattern - matches = list(repo.glob(pattern)) - if matches: - return True, str(matches[0].relative_to(repo)) - else: - # Exact file - if (repo / pattern).exists(): - return True, pattern - - return False, None - - -def check_pattern_not_found( - local_path: str, - file_patterns: List[str], - regex_patterns: Dict[str, str], -) -> tuple: - """Check that patterns are NOT found in files (for detecting bad practices). - - Args: - local_path: Path to repository root - file_patterns: Glob patterns for files to check - regex_patterns: Dict of name -> regex pattern to search for - - Returns: - Tuple of (passed: bool, violations: List[str]) - """ - repo = Path(local_path) - violations = [] - - for file_pattern in file_patterns: - for file_path in repo.glob(file_pattern): - if file_path.is_file(): - try: - content = file_path.read_text(errors="ignore", encoding="utf-8") - for pattern_name, regex in regex_patterns.items(): - matches = re.findall(regex, content, re.MULTILINE) - if matches: - rel_path = file_path.relative_to(repo) - violations.append( - f"{rel_path}: found {pattern_name} ({len(matches)} occurrences)" - ) - except Exception as e: - logger.debug(f"Could not read {file_path}: {e}") - - return len(violations) == 0, violations - - -def check_pattern_found( - local_path: str, - file_patterns: List[str], - regex_patterns: Dict[str, str], -) -> tuple: - """Check that patterns ARE found in files (for detecting required content). - - Args: - local_path: Path to repository root - file_patterns: Glob patterns for files to check - regex_patterns: Dict of name -> regex pattern to search for - - Returns: - Tuple of (passed: bool, found: Dict[str, bool]) - """ - repo = Path(local_path) - found = {name: False for name in regex_patterns} - - for file_pattern in file_patterns: - for file_path in repo.glob(file_pattern): - if file_path.is_file(): - try: - content = file_path.read_text(errors="ignore", encoding="utf-8") - for pattern_name, regex in regex_patterns.items(): - if re.search(regex, content, re.MULTILINE): - found[pattern_name] = True - except Exception as e: - logger.debug(f"Could not read {file_path}: {e}") - - all_found = all(found.values()) - return all_found, found - - -# ============================================================================= -# Control Check Functions -# ============================================================================= - - -def check_test_doc_01(local_path: str) -> CheckResult: - """TEST-DOC-01: HasReadme""" - exists, found = check_file_exists( - local_path, - ["README.md", "README.rst", "README.txt", "README"], - ) - return CheckResult( - control_id="TEST-DOC-01", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found {found}" if exists else "No README file found", - level=1, - source="testchecks", - ) - - -def check_test_doc_02(local_path: str) -> CheckResult: - """TEST-DOC-02: HasChangelog""" - exists, found = check_file_exists( - local_path, - ["CHANGELOG.md", "CHANGELOG.txt", "CHANGELOG", "HISTORY.md", "CHANGES.md"], - ) - return CheckResult( - control_id="TEST-DOC-02", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found {found}" if exists else "No CHANGELOG file found", - level=1, - source="testchecks", - ) - - -def check_test_lic_01(local_path: str) -> CheckResult: - """TEST-LIC-01: HasLicense""" - exists, found = check_file_exists( - local_path, - ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"], - ) - return CheckResult( - control_id="TEST-LIC-01", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found {found}" if exists else "No LICENSE file found", - level=1, - source="testchecks", - ) - - -def check_test_ign_01(local_path: str) -> CheckResult: - """TEST-IGN-01: HasGitignore""" - exists, found = check_file_exists(local_path, [".gitignore"]) - return CheckResult( - control_id="TEST-IGN-01", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found {found}" if exists else "No .gitignore file found", - level=1, - source="testchecks", - ) - - -def check_test_qa_01(local_path: str) -> CheckResult: - """TEST-QA-01: NoTodoComments""" - passed, violations = check_pattern_not_found( - local_path, - ["**/*.py", "**/*.js", "**/*.ts"], - {"TODO comment": r"#\s*TODO|//\s*TODO|/\*\s*TODO"}, - ) - if passed: - message = "No TODO comments found" - else: - message = f"Found TODO comments: {', '.join(violations[:5])}" - if len(violations) > 5: - message += f" (and {len(violations) - 5} more)" - return CheckResult( - control_id="TEST-QA-01", - status=CheckStatus.PASS if passed else CheckStatus.FAIL, - message=message, - level=2, - source="testchecks", - ) - - -def check_test_qa_02(local_path: str) -> CheckResult: - """TEST-QA-02: NoPrintStatements""" - passed, violations = check_pattern_not_found( - local_path, - ["**/*.py"], - {"print statement": r"^\s*print\s*\("}, - ) - if passed: - message = "No print() statements found" - else: - message = f"Found print statements: {', '.join(violations[:5])}" - if len(violations) > 5: - message += f" (and {len(violations) - 5} more)" - return CheckResult( - control_id="TEST-QA-02", - status=CheckStatus.PASS if passed else CheckStatus.FAIL, - message=message, - level=2, - source="testchecks", - ) - - -def check_test_cfg_01(local_path: str) -> CheckResult: - """TEST-CFG-01: HasEditorConfig""" - exists, found = check_file_exists(local_path, [".editorconfig"]) - return CheckResult( - control_id="TEST-CFG-01", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found {found}" if exists else "No .editorconfig file found", - level=2, - source="testchecks", - ) - - -def check_test_cfg_02(local_path: str) -> CheckResult: - """TEST-CFG-02: HasPreCommitConfig""" - exists, found = check_file_exists( - local_path, - [".pre-commit-config.yaml", ".pre-commit-config.yml"], - ) - return CheckResult( - control_id="TEST-CFG-02", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found {found}" if exists else "No pre-commit config found", - level=2, - source="testchecks", - ) - - -def check_test_sec_01(local_path: str) -> CheckResult: - """TEST-SEC-01: NoHardcodedPasswords""" - passed, violations = check_pattern_not_found( - local_path, - ["**/*.py", "**/*.js", "**/*.ts", "**/*.yaml", "**/*.yml", "**/*.json"], - { - "password assignment": r'password\s*=\s*["\'][^"\']+["\']', - "secret assignment": r'secret\s*=\s*["\'][^"\']+["\']', - "api_key assignment": r'api_key\s*=\s*["\'][^"\']+["\']', - }, - ) - if passed: - message = "No hardcoded secrets found" - else: - message = f"Potential hardcoded secrets: {', '.join(violations[:3])}" - if len(violations) > 3: - message += f" (and {len(violations) - 3} more)" - return CheckResult( - control_id="TEST-SEC-01", - status=CheckStatus.PASS if passed else CheckStatus.FAIL, - message=message, - level=3, - source="testchecks", - ) - - -def check_test_sec_02(local_path: str) -> CheckResult: - """TEST-SEC-02: GitignoreSecrets""" - gitignore_path = Path(local_path) / ".gitignore" - if not gitignore_path.exists(): - return CheckResult( - control_id="TEST-SEC-02", - status=CheckStatus.FAIL, - message="No .gitignore file found", - level=3, - source="testchecks", - ) - - passed, found = check_pattern_found( - local_path, - [".gitignore"], - { - "env files": r"\.env", - "key files": r"\*\.key|\*\.pem", - }, - ) - missing = [name for name, was_found in found.items() if not was_found] - if passed: - message = ".gitignore includes common secret patterns" - else: - message = f".gitignore missing patterns for: {', '.join(missing)}" - return CheckResult( - control_id="TEST-SEC-02", - status=CheckStatus.PASS if passed else CheckStatus.FAIL, - message=message, - level=3, - source="testchecks", - ) - - -def check_test_ci_01(local_path: str) -> CheckResult: - """TEST-CI-01: HasCIConfig""" - exists, found = check_file_exists( - local_path, - [ - ".github/workflows/*.yml", - ".github/workflows/*.yaml", - ".gitlab-ci.yml", - ".circleci/config.yml", - "Jenkinsfile", - ".travis.yml", - "azure-pipelines.yml", - ], - ) - return CheckResult( - control_id="TEST-CI-01", - status=CheckStatus.PASS if exists else CheckStatus.FAIL, - message=f"Found CI config: {found}" if exists else "No CI configuration found", - level=3, - source="testchecks", - ) - - -def check_test_ci_02(local_path: str) -> CheckResult: - """TEST-CI-02: CIRunsTests""" - passed, found = check_pattern_found( - local_path, - [".github/workflows/*.yml", ".github/workflows/*.yaml", ".gitlab-ci.yml"], - { - "test command": r"(npm test|pytest|go test|cargo test|mvn test|make test)", - }, - ) - if passed: - message = "CI configuration runs tests" - else: - message = "No test commands found in CI configuration" - return CheckResult( - control_id="TEST-CI-02", - status=CheckStatus.PASS if passed else CheckStatus.FAIL, - message=message, - level=3, - source="testchecks", - ) - - -# Mapping of control ID to check function -CHECK_FUNCTIONS = { - "TEST-DOC-01": check_test_doc_01, - "TEST-DOC-02": check_test_doc_02, - "TEST-LIC-01": check_test_lic_01, - "TEST-IGN-01": check_test_ign_01, - "TEST-QA-01": check_test_qa_01, - "TEST-QA-02": check_test_qa_02, - "TEST-CFG-01": check_test_cfg_01, - "TEST-CFG-02": check_test_cfg_02, - "TEST-SEC-01": check_test_sec_01, - "TEST-SEC-02": check_test_sec_02, - "TEST-CI-01": check_test_ci_01, - "TEST-CI-02": check_test_ci_02, -} - - -# ============================================================================= -# Test Check Adapter -# ============================================================================= - - -class TrivialCheckAdapter(CheckAdapter): - """Adapter for running Test Checks Framework controls. - - This adapter demonstrates how to implement a custom check adapter - for a darnit compliance framework. - """ - - def __init__(self): - """Initialize the test check adapter.""" - pass - - def name(self) -> str: - """Return adapter name.""" - return "testchecks" - - def capabilities(self) -> AdapterCapability: - """Return what controls this adapter can check.""" - return AdapterCapability( - control_ids=set(CHECK_FUNCTIONS.keys()), - supports_batch=True, - ) - - def check( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - ) -> CheckResult: - """Run check for a specific control. - - Args: - control_id: Control identifier (e.g., "TEST-DOC-01") - owner: GitHub owner/org (unused for local checks) - repo: Repository name (unused for local checks) - local_path: Path to local repository clone - config: Additional configuration - - Returns: - CheckResult for the specified control - """ - check_func = CHECK_FUNCTIONS.get(control_id) - if not check_func: - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message=f"Unknown control: {control_id}", - level=CONTROLS.get(control_id, {}).get("level", 1), - source="testchecks", - ) - - try: - return check_func(local_path) - except Exception as e: - logger.error(f"Error checking {control_id}: {e}") - return CheckResult( - control_id=control_id, - status=CheckStatus.ERROR, - message=f"Check failed: {e}", - level=CONTROLS.get(control_id, {}).get("level", 1), - source="testchecks", - ) - - def check_batch( - self, - control_ids: List[str], - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - ) -> List[CheckResult]: - """Run checks for multiple controls. - - Args: - control_ids: List of control identifiers - owner: GitHub owner/org - repo: Repository name - local_path: Path to local repository clone - config: Additional configuration - - Returns: - List of CheckResult for all requested controls - """ - return [ - self.check(control_id, owner, repo, local_path, config) - for control_id in control_ids - ] - - -# ============================================================================= -# Test Remediation Adapter -# ============================================================================= - - -class TrivialRemediationAdapter(RemediationAdapter): - """Adapter for running Test Checks Framework remediations. - - Provides simple file-creation remediations for basic controls. - """ - - def __init__(self): - """Initialize the test remediation adapter.""" - pass - - def name(self) -> str: - """Return adapter name.""" - return "testchecks" - - def capabilities(self) -> AdapterCapability: - """Return what controls this adapter can remediate.""" - return AdapterCapability( - control_ids={"TEST-DOC-01", "TEST-IGN-01"}, - supports_batch=False, - ) - - def remediate( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: Dict[str, Any], - dry_run: bool = True, - ) -> RemediationResult: - """Apply remediation for a specific control. - - Args: - control_id: Control identifier - owner: GitHub owner/org - repo: Repository name - local_path: Path to local repository clone - config: Additional configuration - dry_run: If True, show what would be done without making changes - - Returns: - RemediationResult describing the outcome - """ - if control_id == "TEST-DOC-01": - return self._create_readme(local_path, repo, dry_run) - elif control_id == "TEST-IGN-01": - return self._create_gitignore(local_path, dry_run) - else: - return RemediationResult( - control_id=control_id, - success=False, - message=f"No remediation available for {control_id}", - source="testchecks", - ) - - def _create_readme( - self, local_path: str, repo: str, dry_run: bool - ) -> RemediationResult: - """Create a basic README.md file.""" - readme_path = Path(local_path) / "README.md" - content = f"""# {repo} - -A brief description of this project. - -## Installation - -```bash -# Installation instructions -``` - -## Usage - -```bash -# Usage examples -``` - -## License - -See [LICENSE](LICENSE) for details. -""" - if dry_run: - return RemediationResult( - control_id="TEST-DOC-01", - success=True, - message=f"Would create {readme_path}", - changes_made=[], - source="testchecks", - ) - - readme_path.write_text(content, encoding="utf-8") - return RemediationResult( - control_id="TEST-DOC-01", - success=True, - message=f"Created {readme_path}", - changes_made=[str(readme_path)], - source="testchecks", - ) - - def _create_gitignore(self, local_path: str, dry_run: bool) -> RemediationResult: - """Create a basic .gitignore file.""" - gitignore_path = Path(local_path) / ".gitignore" - content = """# Environment -.env -.env.local -.env.*.local - -# Secrets -*.key -*.pem -credentials.json -secrets.yaml - -# Python -__pycache__/ -*.py[cod] -.venv/ -venv/ -.pytest_cache/ - -# Node -node_modules/ -.npm/ - -# IDE -.idea/ -.vscode/ -*.swp - -# OS -.DS_Store -Thumbs.db -""" - if dry_run: - return RemediationResult( - control_id="TEST-IGN-01", - success=True, - message=f"Would create {gitignore_path}", - changes_made=[], - source="testchecks", - ) - - gitignore_path.write_text(content, encoding="utf-8") - return RemediationResult( - control_id="TEST-IGN-01", - success=True, - message=f"Created {gitignore_path}", - changes_made=[str(gitignore_path)], - source="testchecks", - ) - - -# ============================================================================= -# Factory Functions -# ============================================================================= - -_test_check_adapter: Optional[TrivialCheckAdapter] = None -_test_remediation_adapter: Optional[TrivialRemediationAdapter] = None - - -def get_test_check_adapter() -> TrivialCheckAdapter: - """Get the singleton test check adapter instance.""" - global _test_check_adapter - if _test_check_adapter is None: - _test_check_adapter = TrivialCheckAdapter() - return _test_check_adapter - - -def get_test_remediation_adapter() -> TrivialRemediationAdapter: - """Get the singleton test remediation adapter instance.""" - global _test_remediation_adapter - if _test_remediation_adapter is None: - _test_remediation_adapter = TrivialRemediationAdapter() - return _test_remediation_adapter diff --git a/packages/darnit-testchecks/testchecks.toml b/packages/darnit-testchecks/testchecks.toml index 8d4b1606..70e20502 100644 --- a/packages/darnit-testchecks/testchecks.toml +++ b/packages/darnit-testchecks/testchecks.toml @@ -13,19 +13,6 @@ spec_version = "test-v1" description = "Trivial checks for testing the darnit declarative configuration system" url = "https://github.com/kusaridev/baseline-mcp" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - -# ============================================================================= -# Adapter Definitions -# ============================================================================= - -[adapters.builtin] -type = "python" -module = "darnit_testchecks.adapters.builtin" -class = "TestCheckAdapter" - # ============================================================================= # Level 1 Controls - Basic Project Setup # ============================================================================= diff --git a/packages/darnit-testchecks/tests/test_adapter.py b/packages/darnit-testchecks/tests/test_adapter.py deleted file mode 100644 index 364dff8b..00000000 --- a/packages/darnit-testchecks/tests/test_adapter.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Tests for the TrivialCheckAdapter and the testchecks effective configuration.""" - -import sys -from pathlib import Path - -# Add package paths for testing without installation -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "darnit" / "src")) -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from darnit.config.merger import load_effective_config -from darnit.core.models import CheckStatus - -from darnit_testchecks import get_framework_path -from darnit_testchecks.adapters import ( - TrivialCheckAdapter, - TrivialRemediationAdapter, - get_test_check_adapter, - get_test_remediation_adapter, -) - - -class TestAdapterBasics: - """Tests for adapter basic functionality.""" - - def test_adapter_singleton(self): - """Adapter should be a singleton.""" - adapter1 = get_test_check_adapter() - adapter2 = get_test_check_adapter() - assert adapter1 is adapter2 - - def test_adapter_name(self): - """Adapter should have correct name.""" - adapter = get_test_check_adapter() - assert adapter.name() == "testchecks" - - def test_adapter_capabilities(self): - """Adapter should report capabilities.""" - adapter = get_test_check_adapter() - caps = adapter.capabilities() - - assert caps.supports_batch is True - assert len(caps.control_ids) == 12 - assert "TEST-DOC-01" in caps.control_ids - assert "TEST-SEC-01" in caps.control_ids - - def test_remediation_adapter_singleton(self): - """Remediation adapter should be a singleton.""" - adapter1 = get_test_remediation_adapter() - adapter2 = get_test_remediation_adapter() - assert adapter1 is adapter2 - - def test_remediation_capabilities(self): - """Remediation adapter should report limited capabilities.""" - adapter = get_test_remediation_adapter() - caps = adapter.capabilities() - - assert caps.supports_batch is False - assert "TEST-DOC-01" in caps.control_ids - assert "TEST-IGN-01" in caps.control_ids - - -class TestAdapterCheck: - """Tests for adapter check() method.""" - - def test_check_single_control(self, minimal_repo: Path): - """Should check a single control.""" - adapter = TrivialCheckAdapter() - result = adapter.check( - control_id="TEST-DOC-01", - owner="", - repo="test", - local_path=str(minimal_repo), - config={}, - ) - - assert result.control_id == "TEST-DOC-01" - assert result.status == CheckStatus.PASS - assert result.source == "testchecks" - - def test_check_unknown_control(self, temp_repo: Path): - """Should return error for unknown control.""" - adapter = TrivialCheckAdapter() - result = adapter.check( - control_id="UNKNOWN-01", - owner="", - repo="test", - local_path=str(temp_repo), - config={}, - ) - - assert result.control_id == "UNKNOWN-01" - assert result.status == CheckStatus.ERROR - assert "Unknown control" in result.message - - -class TestAdapterBatch: - """Tests for adapter check_batch() method.""" - - def test_batch_check_all_controls(self, complete_repo: Path): - """Should check all controls in batch.""" - adapter = TrivialCheckAdapter() - control_ids = list(adapter.capabilities().control_ids) - - results = adapter.check_batch( - control_ids=control_ids, - owner="", - repo="test", - local_path=str(complete_repo), - config={}, - ) - - assert len(results) == 12 - # All should pass for complete_repo - for result in results: - assert result.status == CheckStatus.PASS, f"{result.control_id}: {result.message}" - - def test_batch_check_subset(self, minimal_repo: Path): - """Should check subset of controls.""" - adapter = TrivialCheckAdapter() - - results = adapter.check_batch( - control_ids=["TEST-DOC-01", "TEST-LIC-01"], - owner="", - repo="test", - local_path=str(minimal_repo), - config={}, - ) - - assert len(results) == 2 - assert all(r.status == CheckStatus.PASS for r in results) - - def test_batch_mixed_results(self, minimal_repo: Path): - """Should return mixed results for partial compliance.""" - adapter = TrivialCheckAdapter() - - results = adapter.check_batch( - control_ids=["TEST-DOC-01", "TEST-DOC-02"], # README exists, CHANGELOG doesn't - owner="", - repo="test", - local_path=str(minimal_repo), - config={}, - ) - - results_dict = {r.control_id: r for r in results} - assert results_dict["TEST-DOC-01"].status == CheckStatus.PASS - assert results_dict["TEST-DOC-02"].status == CheckStatus.FAIL - - -class TestRemediation: - """Tests for remediation adapter.""" - - def test_remediate_readme_dry_run(self, temp_repo: Path): - """Should show what would be created in dry run.""" - adapter = TrivialRemediationAdapter() - result = adapter.remediate( - control_id="TEST-DOC-01", - owner="", - repo="test-repo", - local_path=str(temp_repo), - config={}, - dry_run=True, - ) - - assert result.success is True - assert "Would create" in result.message - assert not (temp_repo / "README.md").exists() - - def test_remediate_readme_actual(self, temp_repo: Path): - """Should create README when not dry run.""" - adapter = TrivialRemediationAdapter() - result = adapter.remediate( - control_id="TEST-DOC-01", - owner="", - repo="test-repo", - local_path=str(temp_repo), - config={}, - dry_run=False, - ) - - assert result.success is True - assert "Created" in result.message - assert (temp_repo / "README.md").exists() - assert "test-repo" in (temp_repo / "README.md").read_text() - - def test_remediate_gitignore(self, temp_repo: Path): - """Should create .gitignore with security patterns.""" - adapter = TrivialRemediationAdapter() - result = adapter.remediate( - control_id="TEST-IGN-01", - owner="", - repo="test", - local_path=str(temp_repo), - config={}, - dry_run=False, - ) - - assert result.success is True - gitignore = (temp_repo / ".gitignore").read_text() - assert ".env" in gitignore - assert "*.key" in gitignore - - def test_remediate_unknown_control(self, temp_repo: Path): - """Should fail for unsupported control.""" - adapter = TrivialRemediationAdapter() - result = adapter.remediate( - control_id="TEST-SEC-01", # Not remediable - owner="", - repo="test", - local_path=str(temp_repo), - config={}, - dry_run=True, - ) - - assert result.success is False - assert "No remediation available" in result.message - - -class TestEffectiveConfig: - """Tests for the framework's effective configuration.""" - - def test_all_controls_load(self): - """Every control in the framework TOML is in the effective config.""" - effective = load_effective_config(get_framework_path()) - - assert len(effective.controls) == 12 - - -class TestEndToEnd: - """End-to-end tests combining multiple components.""" - - def test_full_audit_flow(self, complete_repo: Path): - """Every control passes on a complete repository.""" - effective = load_effective_config(get_framework_path()) - - adapter = TrivialCheckAdapter() - results = adapter.check_batch( - control_ids=list(effective.controls), - owner="", - repo="test", - local_path=str(complete_repo), - config={}, - ) - - for result in results: - assert result.status == CheckStatus.PASS, f"{result.control_id}: {result.message}" - - def test_audit_violations(self, repo_with_violations: Path): - """A hard-coded secret fails TEST-SEC-01.""" - adapter = TrivialCheckAdapter() - result = adapter.check( - control_id="TEST-SEC-01", - owner="", - repo="test", - local_path=str(repo_with_violations), - config={}, - ) - assert result.status == CheckStatus.FAIL diff --git a/packages/darnit-testchecks/tests/test_checks.py b/packages/darnit-testchecks/tests/test_checks.py deleted file mode 100644 index 58e920a3..00000000 --- a/packages/darnit-testchecks/tests/test_checks.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Tests for individual check functions.""" - -import sys -from pathlib import Path - -# Add package paths for testing without installation -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "darnit" / "src")) -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from darnit.core.models import CheckStatus - -from darnit_testchecks.adapters.builtin import ( - check_test_cfg_01, - check_test_cfg_02, - check_test_ci_01, - check_test_ci_02, - check_test_doc_01, - check_test_doc_02, - check_test_ign_01, - check_test_lic_01, - check_test_qa_01, - check_test_qa_02, - check_test_sec_01, - check_test_sec_02, -) - - -class TestLevel1Checks: - """Tests for Level 1 checks (basic project setup).""" - - def test_doc_01_passes_with_readme(self, minimal_repo: Path): - """TEST-DOC-01 should pass when README.md exists.""" - result = check_test_doc_01(str(minimal_repo)) - assert result.status == CheckStatus.PASS - assert "README.md" in result.message - - def test_doc_01_fails_without_readme(self, temp_repo: Path): - """TEST-DOC-01 should fail when no README exists.""" - result = check_test_doc_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - assert "No README" in result.message - - def test_doc_01_accepts_readme_variants(self, temp_repo: Path): - """TEST-DOC-01 should accept README.rst, README.txt, etc.""" - (temp_repo / "README.rst").write_text("Test\n====\n") - result = check_test_doc_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_doc_02_passes_with_changelog(self, temp_repo: Path): - """TEST-DOC-02 should pass when CHANGELOG.md exists.""" - (temp_repo / "CHANGELOG.md").write_text("# Changelog\n") - result = check_test_doc_02(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_doc_02_fails_without_changelog(self, temp_repo: Path): - """TEST-DOC-02 should fail when no CHANGELOG exists.""" - result = check_test_doc_02(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_lic_01_passes_with_license(self, minimal_repo: Path): - """TEST-LIC-01 should pass when LICENSE exists.""" - result = check_test_lic_01(str(minimal_repo)) - assert result.status == CheckStatus.PASS - - def test_lic_01_fails_without_license(self, temp_repo: Path): - """TEST-LIC-01 should fail when no LICENSE exists.""" - result = check_test_lic_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_ign_01_passes_with_gitignore(self, temp_repo: Path): - """TEST-IGN-01 should pass when .gitignore exists.""" - (temp_repo / ".gitignore").write_text("*.pyc\n") - result = check_test_ign_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_ign_01_fails_without_gitignore(self, temp_repo: Path): - """TEST-IGN-01 should fail when no .gitignore exists.""" - result = check_test_ign_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - -class TestLevel2Checks: - """Tests for Level 2 checks (code quality).""" - - def test_qa_01_passes_without_todos(self, temp_repo: Path): - """TEST-QA-01 should pass when no TODO comments exist.""" - (temp_repo / "app.py").write_text('def hello():\n return "world"\n') - result = check_test_qa_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_qa_01_fails_with_todos(self, temp_repo: Path): - """TEST-QA-01 should fail when TODO comments exist.""" - (temp_repo / "app.py").write_text("# TODO: implement this\ndef hello(): pass\n") - result = check_test_qa_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - assert "TODO" in result.message - - def test_qa_01_detects_js_todos(self, temp_repo: Path): - """TEST-QA-01 should detect TODO in JavaScript files.""" - (temp_repo / "app.js").write_text("// TODO: fix this\nfunction hello() {}\n") - result = check_test_qa_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_qa_02_passes_without_prints(self, temp_repo: Path): - """TEST-QA-02 should pass when no print statements exist.""" - (temp_repo / "app.py").write_text( - 'import logging\nlogger = logging.getLogger(__name__)\n' - 'logger.info("hello")\n' - ) - result = check_test_qa_02(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_qa_02_fails_with_prints(self, temp_repo: Path): - """TEST-QA-02 should fail when print statements exist.""" - (temp_repo / "app.py").write_text('print("hello")\n') - result = check_test_qa_02(str(temp_repo)) - assert result.status == CheckStatus.FAIL - assert "print" in result.message - - def test_cfg_01_passes_with_editorconfig(self, temp_repo: Path): - """TEST-CFG-01 should pass when .editorconfig exists.""" - (temp_repo / ".editorconfig").write_text("[*]\nindent_style = space\n") - result = check_test_cfg_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_cfg_01_fails_without_editorconfig(self, temp_repo: Path): - """TEST-CFG-01 should fail when no .editorconfig exists.""" - result = check_test_cfg_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_cfg_02_passes_with_precommit(self, temp_repo: Path): - """TEST-CFG-02 should pass when pre-commit config exists.""" - (temp_repo / ".pre-commit-config.yaml").write_text("repos: []\n") - result = check_test_cfg_02(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_cfg_02_fails_without_precommit(self, temp_repo: Path): - """TEST-CFG-02 should fail when no pre-commit config exists.""" - result = check_test_cfg_02(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - -class TestLevel3Checks: - """Tests for Level 3 checks (security & CI).""" - - def test_sec_01_passes_without_secrets(self, temp_repo: Path): - """TEST-SEC-01 should pass when no hardcoded secrets exist.""" - (temp_repo / "config.py").write_text( - 'import os\npassword = os.environ.get("PASSWORD")\n' - ) - result = check_test_sec_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_sec_01_fails_with_password(self, temp_repo: Path): - """TEST-SEC-01 should fail when hardcoded password exists.""" - (temp_repo / "config.py").write_text('password = "secret123"\n') - result = check_test_sec_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - assert "secret" in result.message.lower() - - def test_sec_01_fails_with_api_key(self, temp_repo: Path): - """TEST-SEC-01 should fail when hardcoded api_key exists.""" - (temp_repo / "config.py").write_text('api_key = "abc123"\n') - result = check_test_sec_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_sec_02_passes_with_patterns(self, temp_repo: Path): - """TEST-SEC-02 should pass when .gitignore has secret patterns.""" - (temp_repo / ".gitignore").write_text(".env\n*.key\n*.pem\n") - result = check_test_sec_02(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_sec_02_fails_without_patterns(self, temp_repo: Path): - """TEST-SEC-02 should fail when .gitignore missing patterns.""" - (temp_repo / ".gitignore").write_text("*.pyc\n__pycache__/\n") - result = check_test_sec_02(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_sec_02_fails_without_gitignore(self, temp_repo: Path): - """TEST-SEC-02 should fail when no .gitignore exists.""" - result = check_test_sec_02(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_ci_01_passes_with_github_actions(self, temp_repo: Path): - """TEST-CI-01 should pass when GitHub Actions workflow exists.""" - workflows = temp_repo / ".github" / "workflows" - workflows.mkdir(parents=True) - (workflows / "ci.yml").write_text("name: CI\non: push\n") - result = check_test_ci_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_ci_01_passes_with_gitlab_ci(self, temp_repo: Path): - """TEST-CI-01 should pass when .gitlab-ci.yml exists.""" - (temp_repo / ".gitlab-ci.yml").write_text("stages:\n - test\n") - result = check_test_ci_01(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_ci_01_fails_without_ci(self, temp_repo: Path): - """TEST-CI-01 should fail when no CI config exists.""" - result = check_test_ci_01(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - def test_ci_02_passes_with_test_command(self, temp_repo: Path): - """TEST-CI-02 should pass when CI runs tests.""" - workflows = temp_repo / ".github" / "workflows" - workflows.mkdir(parents=True) - (workflows / "ci.yml").write_text( - "name: CI\non: push\njobs:\n test:\n steps:\n - run: pytest\n" - ) - result = check_test_ci_02(str(temp_repo)) - assert result.status == CheckStatus.PASS - - def test_ci_02_fails_without_tests(self, temp_repo: Path): - """TEST-CI-02 should fail when CI doesn't run tests.""" - workflows = temp_repo / ".github" / "workflows" - workflows.mkdir(parents=True) - (workflows / "ci.yml").write_text( - "name: CI\non: push\njobs:\n build:\n steps:\n - run: echo hi\n" - ) - result = check_test_ci_02(str(temp_repo)) - assert result.status == CheckStatus.FAIL - - -class TestCompleteRepo: - """Tests using the complete_repo fixture.""" - - def test_complete_repo_passes_level1(self, complete_repo: Path): - """Complete repo should pass all Level 1 checks.""" - results = [ - check_test_doc_01(str(complete_repo)), - check_test_doc_02(str(complete_repo)), - check_test_lic_01(str(complete_repo)), - check_test_ign_01(str(complete_repo)), - ] - for result in results: - assert result.status == CheckStatus.PASS, f"{result.control_id} failed: {result.message}" - - def test_complete_repo_passes_level2(self, complete_repo: Path): - """Complete repo should pass all Level 2 checks.""" - results = [ - check_test_qa_01(str(complete_repo)), - check_test_qa_02(str(complete_repo)), - check_test_cfg_01(str(complete_repo)), - check_test_cfg_02(str(complete_repo)), - ] - for result in results: - assert result.status == CheckStatus.PASS, f"{result.control_id} failed: {result.message}" - - def test_complete_repo_passes_level3(self, complete_repo: Path): - """Complete repo should pass all Level 3 checks.""" - results = [ - check_test_sec_01(str(complete_repo)), - check_test_sec_02(str(complete_repo)), - check_test_ci_01(str(complete_repo)), - check_test_ci_02(str(complete_repo)), - ] - for result in results: - assert result.status == CheckStatus.PASS, f"{result.control_id} failed: {result.message}" - - -class TestRepoWithViolations: - """Tests using the repo_with_violations fixture.""" - - def test_violations_detected(self, repo_with_violations: Path): - """Repo with violations should fail quality checks.""" - # Should fail TODO check - result = check_test_qa_01(str(repo_with_violations)) - assert result.status == CheckStatus.FAIL - - # Should fail print check - result = check_test_qa_02(str(repo_with_violations)) - assert result.status == CheckStatus.FAIL - - # Should fail secrets check - result = check_test_sec_01(str(repo_with_violations)) - assert result.status == CheckStatus.FAIL - - # Should fail gitignore patterns check - result = check_test_sec_02(str(repo_with_violations)) - assert result.status == CheckStatus.FAIL diff --git a/packages/darnit-testchecks/tests/test_framework.py b/packages/darnit-testchecks/tests/test_framework.py index 201c8458..0eb574bc 100644 --- a/packages/darnit-testchecks/tests/test_framework.py +++ b/packages/darnit-testchecks/tests/test_framework.py @@ -67,25 +67,6 @@ def test_framework_control_domains(self): assert framework.controls["TEST-SEC-01"].domain == "SEC" assert framework.controls["TEST-CI-01"].domain == "CI" - def test_framework_has_adapters(self): - """Framework should define adapters.""" - framework = load_framework_config(get_framework_path()) - - assert "builtin" in framework.adapters - adapter = framework.adapters["builtin"] - # Adapter might be dict or AdapterConfig depending on parsing - if hasattr(adapter, "type"): - assert adapter.type == "python" - else: - assert adapter.get("type") == "python" - - def test_framework_has_defaults(self): - """Framework should have defaults.""" - framework = load_framework_config(get_framework_path()) - - assert framework.defaults.check_adapter == "builtin" - assert framework.defaults.remediation_adapter == "builtin" - def test_package_version(self): """Package version should match framework version.""" framework = load_framework_config(get_framework_path()) diff --git a/packages/darnit/src/darnit/cli.py b/packages/darnit/src/darnit/cli.py index b3756feb..20fd9668 100644 --- a/packages/darnit/src/darnit/cli.py +++ b/packages/darnit/src/darnit/cli.py @@ -445,7 +445,7 @@ def cmd_plan(args: argparse.Namespace) -> int: logger.info(f"Level {level} ({len(shown_controls)} controls):") for cid, ctrl in shown_controls: - logger.info(f" • {cid}: {ctrl.name} [adapter: {ctrl.check_adapter}]") + logger.info(f" • {cid}: {ctrl.name}") total_shown += len(shown_controls) if total_filtered > 0: @@ -479,7 +479,6 @@ def cmd_validate(args: argparse.Namespace) -> int: else: logger.info(f"Framework '{config.metadata.name}' is valid") logger.info(f" Controls: {len(config.controls)}") - logger.info(f" Adapters: {len(config.adapters)}") # Show level breakdown by_level = {} diff --git a/packages/darnit/src/darnit/config/__init__.py b/packages/darnit/src/darnit/config/__init__.py index e8bbdf9d..ddcb3237 100644 --- a/packages/darnit/src/darnit/config/__init__.py +++ b/packages/darnit/src/darnit/config/__init__.py @@ -49,20 +49,16 @@ # Framework configuration schema from .framework_schema import ( - AdapterType, - CheckConfig, # Context definitions (interactive context collection) ContextDefinitionConfig, ControlConfig, FrameworkConfig, FrameworkContextConfig, - FrameworkDefaults, FrameworkMetadata, HandlerInvocation, # Locator configuration (evidence location) LocatorConfig, LocatorLLMHints, - OutputMapping, ProjectUpdateRemediationConfig, RemediationConfig, ) @@ -225,17 +221,13 @@ # Framework configuration schema "FrameworkConfig", "FrameworkMetadata", - "FrameworkDefaults", "ControlConfig", - "CheckConfig", "RemediationConfig", "HandlerInvocation", "ProjectUpdateRemediationConfig", - "AdapterType", # Locator configuration (evidence location) "LocatorConfig", "LocatorLLMHints", - "OutputMapping", # Context definitions (interactive context collection) "ContextDefinitionConfig", "FrameworkContextConfig", diff --git a/packages/darnit/src/darnit/config/control_loader.py b/packages/darnit/src/darnit/config/control_loader.py index d4f90857..e0e3e4a6 100644 --- a/packages/darnit/src/darnit/config/control_loader.py +++ b/packages/darnit/src/darnit/config/control_loader.py @@ -259,8 +259,6 @@ def control_from_effective( metadata: dict = { "security_severity": security_severity, "docs_url": effective.docs_url, - "check_adapter": effective.check_adapter, - "remediation_adapter": effective.remediation_adapter, } if effective.when: diff --git a/packages/darnit/src/darnit/config/framework_schema.py b/packages/darnit/src/darnit/config/framework_schema.py index 5539926e..be6d8eb1 100644 --- a/packages/darnit/src/darnit/config/framework_schema.py +++ b/packages/darnit/src/darnit/config/framework_schema.py @@ -5,8 +5,6 @@ Schema Structure: - metadata: Framework identification (name, version, spec_version) - - defaults: Default adapter settings - - adapters: Adapter definitions (python, command, script, http) - controls: Control definitions with passes and remediation Example: @@ -83,7 +81,6 @@ # - Single tool run (e.g., Scorecard) serves multiple controls # - Cache results with `cache_key` in adapter config # - Extract per-control results with JSONPath -# - Already has TODOs in CheckConfig and CommandAdapterConfig # # ----------------------------------------------------------------------------- # LOWER PRIORITY @@ -126,96 +123,10 @@ # ============================================================================= """ -from enum import Enum from typing import Any, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator -# ============================================================================= -# Enums -# ============================================================================= - - -class AdapterType(str, Enum): - """Types of adapters for check/remediation execution.""" - - PYTHON = "python" # Python module + function - COMMAND = "command" # External CLI tool - SCRIPT = "script" # Shell script - HTTP = "http" # REST API endpoint - - -# ============================================================================= -# Adapter Configuration -# ============================================================================= - - -class PythonAdapterConfig(BaseModel): - """Configuration for Python module-based adapters.""" - - type: AdapterType = AdapterType.PYTHON - module: str # e.g., "darnit_baseline.tools" - class_name: str | None = Field(default=None, alias="class") - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - -class CommandAdapterConfig(BaseModel): - """Configuration for external command adapters. - - # TODO: Add cache_key and batch_controls for shared execution - # ```toml - # [adapters.scorecard] - # type = "command" - # command = "scorecard" - # cache_key = "scorecard" # Cache results under this key - # batch_controls = true # Single run serves multiple controls - # ``` - """ - - type: AdapterType = AdapterType.COMMAND - command: str # e.g., "kusari", "trivy" - output_format: str = "json" # json, text, sarif - timeout: int = 300 # seconds - # TODO: cache_key: Optional[str] = None # Key for caching in ExecutionContext - # TODO: batch_controls: bool = False # Single run serves multiple controls - - model_config = ConfigDict(extra="allow") - - -class ScriptAdapterConfig(BaseModel): - """Configuration for shell script adapters.""" - - type: AdapterType = AdapterType.SCRIPT - command: str # e.g., "./scripts/check.sh" - output_format: str = "json" - timeout: int = 300 - - model_config = ConfigDict(extra="allow") - - -class HttpAdapterConfig(BaseModel): - """Configuration for HTTP API adapters.""" - - type: AdapterType = AdapterType.HTTP - endpoint: str # e.g., "https://api.example.com/check" - method: str = "POST" - auth: dict[str, str] | None = None # auth config - timeout: int = 30 - - model_config = ConfigDict(extra="allow") - - -# Union of all adapter configs -AdapterConfig = ( - PythonAdapterConfig - | CommandAdapterConfig - | ScriptAdapterConfig - | HttpAdapterConfig - | dict[str, Any] # Fallback for simple inline definitions -) - - # ============================================================================= # Pass Configuration (Verification Phases) # ============================================================================= @@ -372,109 +283,11 @@ class LocatorConfig(BaseModel): model_config = ConfigDict(extra="allow") -class OutputMapping(BaseModel): - """Map external tool output to standardized CheckOutput contract. - - When using external tools that produce their own output format, - this mapping extracts the relevant fields using JSONPath expressions. - - Example: - ```toml - [controls."OSPS-AC-03.01".check.output_mapping] - status_path = "$.checks.BranchProtection.pass" - score_path = "$.checks.BranchProtection.score" - pass_threshold = 8 - message_path = "$.checks.BranchProtection.reason" - found_path = "$.checks.BranchProtection.details.url" - ``` - """ - - # JSONPath to extract pass/fail status (bool or "pass"/"fail" string) - status_path: str | None = None - - # JSONPath to extract numeric score (0-10 scale) - score_path: str | None = None - - # Score threshold for pass (when using score_path) - # If score >= pass_threshold, status = "pass" - pass_threshold: float | None = None - - # JSONPath to extract message/reason - message_path: str | None = None - - # JSONPath to extract found evidence location (file path or URL) - found_path: str | None = None - - # JSONPath to extract evidence kind (file, url, api, config) - found_kind_path: str | None = None - - # Default kind if not extractable - found_kind_default: str = "file" - - model_config = ConfigDict(extra="allow") - - # ============================================================================= -# Check and Remediation Routing +# Remediation Routing # ============================================================================= -class CheckConfig(BaseModel): - """Configuration for how a control is checked. - - Supports both builtin adapters and external tools with output mapping. - - Example with builtin: - ```toml - [controls."OSPS-VM-01.01".check] - adapter = "builtin" - handler = "check_security_policy" - ``` - - Example with external tool and output mapping: - ```toml - [controls."OSPS-AC-03.01".check] - adapter = "scorecard" - - [controls."OSPS-AC-03.01".check.output_mapping] - status_path = "$.checks.BranchProtection.pass" - score_path = "$.checks.BranchProtection.score" - pass_threshold = 8 - ``` - - # TODO: Add 'extract' field for shared tool result extraction - # This would allow multiple controls to share a single tool run (e.g., Scorecard): - # - # ```toml - # [adapters.scorecard] - # type = "command" - # command = "scorecard" - # cache_key = "scorecard" # Results cached under this key - # - # [controls."OSPS-AC-03.01"] - # check = { adapter = "scorecard", extract = "checks.BranchProtection" } - # - # [controls."OSPS-QA-02.01"] - # check = { adapter = "scorecard", extract = "checks.CITests" } - # ``` - # - # The 'extract' field would be a JSONPath or dot-notation path to extract - # the specific result from the cached tool output. - """ - - adapter: str = "builtin" # Adapter name - handler: str | None = None # Specific handler function - config: dict[str, Any] = Field(default_factory=dict) # Adapter-specific config - - # Output mapping for external tools - # Maps tool output to standardized CheckOutput contract - output_mapping: OutputMapping | None = None - - # TODO: extract: Optional[str] = None # JSONPath to extract from cached tool output - - model_config = ConfigDict(extra="allow") - - class RemediationConfig(BaseModel): """Configuration for how a control is remediated. @@ -851,9 +664,6 @@ def validate_passes_format(cls, v: Any) -> list[HandlerInvocation] | None: ) return v - # Check routing (which adapter verifies this control) - check: CheckConfig | None = None - # Remediation routing remediation: RemediationConfig | None = None @@ -1448,20 +1258,6 @@ def is_plugin_trusted(self, name: str, publisher: str | None = None) -> bool: return publisher in trusted -# ============================================================================= -# Framework Defaults -# ============================================================================= - - -class FrameworkDefaults(BaseModel): - """Default settings for the framework.""" - - check_adapter: str = "builtin" - remediation_adapter: str = "builtin" - - model_config = ConfigDict(extra="allow") - - # ============================================================================= # Framework Metadata # ============================================================================= @@ -1683,13 +1479,6 @@ class FrameworkConfig(BaseModel): version = "0.1.0" spec_version = "OSPS v2025.10.10" - [defaults] - check_adapter = "builtin" - - [adapters.builtin] - type = "python" - module = "darnit_baseline.tools" - [templates.security_policy] content = ''' # Security Policy @@ -1707,12 +1496,6 @@ class FrameworkConfig(BaseModel): # Framework identification metadata: FrameworkMetadata - # Default settings - defaults: FrameworkDefaults = Field(default_factory=FrameworkDefaults) - - # Adapter definitions - adapters: dict[str, AdapterConfig] = Field(default_factory=dict) - # Template definitions for remediation templates: dict[str, TemplateConfig] = Field(default_factory=dict) @@ -1782,21 +1565,6 @@ def get_controls_by_domain(self, domain: str) -> dict[str, ControlConfig]: """ return {control_id: control for control_id, control in self.controls.items() if control.domain == domain} - def get_adapter_config(self, name: str) -> AdapterConfig | None: - """Get adapter configuration by name.""" - return self.adapters.get(name) - - def get_check_adapter(self, control_id: str) -> str: - """Get the adapter name for checking a control.""" - control = self.controls.get(control_id) - if control and control.check: - return control.check.adapter - return self.defaults.check_adapter - - def get_remediation_adapter(self, control_id: str) -> str: - """Get the adapter name for remediating a control.""" - return self.defaults.remediation_adapter - # ============================================================================= # Factory Functions @@ -1827,7 +1595,5 @@ def create_framework_config( version=version, spec_version=spec_version, ), - defaults=FrameworkDefaults(), - adapters={}, controls={}, ) diff --git a/packages/darnit/src/darnit/config/merger.py b/packages/darnit/src/darnit/config/merger.py index 2d088876..1898cec8 100644 --- a/packages/darnit/src/darnit/config/merger.py +++ b/packages/darnit/src/darnit/config/merger.py @@ -33,10 +33,8 @@ from darnit.core.logging import get_logger from .framework_schema import ( - AdapterConfig, ControlConfig, FrameworkConfig, - FrameworkDefaults, McpServerConfig, StoresConfig, ) @@ -70,13 +68,7 @@ class EffectiveControl: # Source tracking from_framework: bool = True - # Check routing - check_adapter: str = "builtin" - check_handler: str | None = None - check_config: dict[str, Any] = field(default_factory=dict) - # Remediation routing - remediation_adapter: str = "builtin" remediation_handler: str | None = None remediation_config: dict[str, Any] = field(default_factory=dict) @@ -110,9 +102,6 @@ class EffectiveConfig: framework_version: str spec_version: str | None = None - # Framework adapters - adapters: dict[str, AdapterConfig] = field(default_factory=dict) - # Merged controls controls: dict[str, EffectiveControl] = field(default_factory=dict) @@ -143,10 +132,6 @@ def get_controls_by_domain(self, domain: str) -> dict[str, EffectiveControl]: """ return {cid: ctrl for cid, ctrl in self.controls.items() if ctrl.domain == domain} - def get_adapter(self, name: str) -> AdapterConfig | None: - """Get adapter configuration by name.""" - return self.adapters.get(name) - # ============================================================================= # Merge Functions @@ -156,7 +141,6 @@ def get_adapter(self, name: str) -> AdapterConfig | None: def merge_control( control_id: str, framework_control: ControlConfig, - defaults: FrameworkDefaults, ) -> EffectiveControl: """Build a control's effective configuration from its definition. @@ -164,7 +148,6 @@ def merge_control( control_id: Control identifier framework_control: The control's definition (framework TOML or an operator custom control) - defaults: Framework defaults Returns: EffectiveControl @@ -185,8 +168,6 @@ def merge_control( domain=framework_control.domain, description=framework_control.description, from_framework=True, - check_adapter=defaults.check_adapter, - remediation_adapter=defaults.remediation_adapter, tags=tags, security_severity=framework_control.security_severity, docs_url=framework_control.docs_url, @@ -196,12 +177,6 @@ def merge_control( on_pass=framework_control.on_pass.model_dump() if framework_control.on_pass else None, ) - # Apply framework check config - if framework_control.check: - effective.check_adapter = framework_control.check.adapter - effective.check_handler = framework_control.check.handler - effective.check_config = dict(framework_control.check.config) - # Apply framework remediation config if framework_control.remediation: effective.remediation_config = dict(framework_control.remediation.config) @@ -263,8 +238,6 @@ def merge_configs( _framework_config=framework, ) - effective.adapters = dict(framework.adapters) - # Merge MCP-server allowlist (spec FR-016): each operator entry REPLACES # the framework's block for that name entirely; disjoint names coexist. effective.mcp_servers = dict(framework.mcp_servers) @@ -289,7 +262,6 @@ def merge_configs( effective.controls[control_id] = merge_control( control_id=control_id, framework_control=framework_control, - defaults=framework.defaults, ) if operator: @@ -297,7 +269,6 @@ def merge_configs( effective.controls[control_id] = merge_control( control_id=control_id, framework_control=control, - defaults=framework.defaults, ) effective.controls[control_id].steps_from_operator = True for control_id, override in operator.controls.items(): @@ -757,12 +728,4 @@ def validate_framework_config(config: FrameworkConfig) -> list[str]: if control.level is not None and control.level not in (1, 2, 3): errors.append(f"Control {control_id} has invalid level: {control.level}") - # Check adapter references exist - if control.check and control.check.adapter != "builtin": - if control.check.adapter not in config.adapters: - errors.append( - f"Control {control_id} references unknown adapter: " - f"{control.check.adapter}" - ) - return errors diff --git a/packages/darnit/src/darnit/core/__init__.py b/packages/darnit/src/darnit/core/__init__.py index 04885c9a..d112b263 100644 --- a/packages/darnit/src/darnit/core/__init__.py +++ b/packages/darnit/src/darnit/core/__init__.py @@ -5,39 +5,23 @@ - **Logging**: Structured logging configuration - **Models**: Data models for audit results, check results - **Utilities**: Git detection, path validation -- **Adapters**: Check and remediation adapter interfaces -- **Plugin Registry**: Unified discovery for frameworks and adapters +- **Plugin Registry**: Discovery of frameworks Plugin System: - The plugin registry discovers plugins via Python entry points: - - - ``darnit.frameworks`` - Framework TOML path providers - - ``darnit.check_adapters`` - Check adapter classes - - ``darnit.remediation_adapters`` - Remediation adapter classes + The plugin registry discovers frameworks via the ``darnit.frameworks`` + entry point group (framework TOML path providers). Example:: from darnit.core import get_plugin_registry registry = get_plugin_registry() - registry.discover_all() - - # List available plugins print(registry.list_frameworks()) - print(registry.list_check_adapters()) - - # Get an adapter by name - adapter = registry.get_check_adapter("kusari") See Also: - :mod:`darnit.core.registry` for the plugin registry - - :mod:`darnit.core.adapters` for adapter base classes """ -from .adapters import ( - CheckAdapter, - RemediationAdapter, -) from .discovery import ( discover_implementations, get_implementation, @@ -61,10 +45,7 @@ ControlSpec, ) from .registry import ( - ENTRY_POINT_CHECK_ADAPTERS, ENTRY_POINT_FRAMEWORKS, - ENTRY_POINT_REMEDIATION_ADAPTERS, - AdapterInfo, FrameworkInfo, PluginRegistry, get_plugin_registry, @@ -101,9 +82,6 @@ "get_git_commit", "get_git_ref", "gh_api_safe", - # Adapters - "CheckAdapter", - "RemediationAdapter", # Legacy plugin system "ControlSpec", "ComplianceImplementation", @@ -114,10 +92,7 @@ "get_plugin_registry", "reset_plugin_registry", "FrameworkInfo", - "AdapterInfo", "ENTRY_POINT_FRAMEWORKS", - "ENTRY_POINT_CHECK_ADAPTERS", - "ENTRY_POINT_REMEDIATION_ADAPTERS", # Handler registry (new) "HandlerRegistry", "HandlerInfo", diff --git a/packages/darnit/src/darnit/core/adapters.py b/packages/darnit/src/darnit/core/adapters.py deleted file mode 100644 index e5b0f40c..00000000 --- a/packages/darnit/src/darnit/core/adapters.py +++ /dev/null @@ -1,713 +0,0 @@ -"""Abstract base classes and registry for pluggable check and remediation adapters. - -This module provides: -- Abstract base classes for check and remediation adapters -- Concrete adapter implementations (Command, Script) -- AdapterRegistry for managing adapter discovery and instantiation -- Resolution functions for loading adapters from configuration -""" - -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, - RemediationResult, -) - -logger = logging.getLogger(__name__) - - -class CheckAdapter(ABC): - """Base class for check adapters. - - # TODO: Shared Execution Context (Future Enhancement) - # Currently each check() call is independent. To support tools like OpenSSF Scorecard - # that run once and produce results for multiple controls, we need: - # - # 1. Add ExecutionContext parameter to check() and check_batch(): - # def check(self, control_id, owner, repo, local_path, config, - # context: Optional[ExecutionContext] = None) -> CheckResult - # - # 2. Add prefetch() method for adapters to run tool once and cache results: - # def prefetch(self, context: ExecutionContext, control_ids: List[str]) -> None - # - # 3. ExecutionContext would contain: - # - tool_outputs: Dict[str, Any] # Cached tool outputs (scorecard, trivy, etc.) - # - api_responses: Dict[str, Any] # Cached GitHub API responses - # - cached_results: Dict[str, CheckResult] # Already-computed results - # - # See: darnit/core/models.py for ExecutionContext definition - """ - - @abstractmethod - def name(self) -> str: - """Return adapter name.""" - pass - - @abstractmethod - def capabilities(self) -> AdapterCapability: - """Return what controls this adapter can check.""" - pass - - @abstractmethod - def check( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: dict[str, Any] - ) -> CheckResult: - """Run check for a specific control.""" - pass - - def check_batch( - self, - control_ids: list[str], - owner: str, - repo: str, - local_path: str, - config: dict[str, Any] - ) -> list[CheckResult]: - """ - Run checks for multiple controls in a single invocation. - Default implementation calls check() for each control. - Override for adapters that support batch operations. - - # TODO: Enhance batch support for shared tool runs - # For tools like Scorecard that produce multiple results per run: - # - # 1. Add optional ExecutionContext parameter for result caching - # 2. Implement internal caching pattern for adapters: - # ```python - # _cached_result = None - # _cached_path = None - # - # def check(self, control_id, ...): - # if self._cached_path != local_path: - # self._cached_result = self._run_tool(local_path) - # self._cached_path = local_path - # return self._extract_control(control_id, self._cached_result) - # ``` - # - # 3. Consider adding cache_key to AdapterCapability for explicit caching - """ - results = [] - for control_id in control_ids: - results.append(self.check(control_id, owner, repo, local_path, config)) - return results - - def supports_control(self, control_id: str) -> bool: - """Check if this adapter can handle a specific control.""" - caps = self.capabilities() - return "*" in caps.control_ids or control_id in caps.control_ids - - -class RemediationAdapter(ABC): - """Base class for remediation adapters.""" - - @abstractmethod - def name(self) -> str: - """Return adapter name.""" - pass - - @abstractmethod - def capabilities(self) -> AdapterCapability: - """Return what controls this adapter can remediate.""" - pass - - @abstractmethod - def remediate( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: dict[str, Any], - dry_run: bool = True - ) -> RemediationResult: - """Apply remediation for a specific control.""" - pass - - def preview( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: dict[str, Any] - ) -> str: - """Preview what remediation would do (dry run).""" - result = self.remediate(control_id, owner, repo, local_path, config, dry_run=True) - return result.message - - def supports_control(self, control_id: str) -> bool: - """Check if this adapter can handle a specific control.""" - caps = self.capabilities() - return "*" in caps.control_ids or control_id in caps.control_ids - - -# ============================================================================= -# Concrete Adapter Implementations -# ============================================================================= - - -class CommandCheckAdapter(CheckAdapter): - """Adapter that runs an external command for checks. - - Supports CLI tools like Kusari, Trivy, etc. that output JSON results. - - Example config: - ```toml - [adapters.kusari] - type = "command" - command = "kusari" - output_format = "json" - ``` - """ - - def __init__( - self, - adapter_name: str, - command: str, - output_format: str = "json", - timeout: int = 300, - control_ids: list[str] | None = None, - ): - self._name = adapter_name - self._command = command - self._output_format = output_format - self._timeout = timeout - self._control_ids = control_ids or ["*"] - - def name(self) -> str: - return self._name - - def capabilities(self) -> AdapterCapability: - return AdapterCapability( - control_ids=set(self._control_ids), - supports_batch=True, - ) - - def check( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: dict[str, Any], - ) -> CheckResult: - """Run command and parse output.""" - - try: - # Build command with arguments - cmd = [self._command] - - # Add common arguments - if owner and repo: - cmd.extend(["--owner", owner, "--repo", repo]) - if local_path: - cmd.extend(["--path", local_path]) - if control_id: - cmd.extend(["--control", control_id]) - - # Add any extra config - for key, value in config.items(): - if isinstance(value, bool): - if value: - cmd.append(f"--{key}") - else: - cmd.extend([f"--{key}", str(value)]) - - logger.debug(f"Running command: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=self._timeout, - ) - - if self._output_format == "json": - try: - output = json.loads(result.stdout) - return CheckResult( - control_id=control_id, - status=output.get("status", "ERROR"), - message=output.get("message", result.stdout), - level=output.get("level", 1), - source=self._name, - ) - except json.JSONDecodeError: - pass - - # Non-JSON or parse failure - status = "PASS" if result.returncode == 0 else "FAIL" - return CheckResult( - control_id=control_id, - status=status, - message=result.stdout or result.stderr, - level=1, - source=self._name, - ) - - except subprocess.TimeoutExpired: - return CheckResult( - control_id=control_id, - status="ERROR", - message=f"Command timed out after {self._timeout}s", - level=1, - source=self._name, - ) - except FileNotFoundError: - return CheckResult( - control_id=control_id, - status="ERROR", - message=f"Command not found: {self._command}", - level=1, - source=self._name, - ) - except Exception as e: - return CheckResult( - control_id=control_id, - status="ERROR", - message=f"Command failed: {e}", - level=1, - source=self._name, - ) - - -class ScriptCheckAdapter(CheckAdapter): - """Adapter that runs a shell script for checks. - - The script receives environment variables: - - CONTROL_ID: The control being checked - - OWNER: Repository owner - - REPO: Repository name - - LOCAL_PATH: Path to local repository - - CONFIG_*: Any config values as env vars - - Example config: - ```toml - [adapters.custom] - type = "script" - command = "./scripts/check.sh" - ``` - """ - - def __init__( - self, - adapter_name: str, - script_path: str, - output_format: str = "json", - timeout: int = 300, - control_ids: list[str] | None = None, - ): - self._name = adapter_name - self._script_path = script_path - self._output_format = output_format - self._timeout = timeout - self._control_ids = control_ids or ["*"] - - def name(self) -> str: - return self._name - - def capabilities(self) -> AdapterCapability: - return AdapterCapability( - control_ids=set(self._control_ids), - supports_batch=False, - ) - - def check( - self, - control_id: str, - owner: str, - repo: str, - local_path: str, - config: dict[str, Any], - ) -> CheckResult: - """Run script and parse output.""" - import os as _os - - try: - # Build environment - env = { - "CONTROL_ID": control_id, - "OWNER": owner or "", - "REPO": repo or "", - "LOCAL_PATH": local_path or ".", - } - - # Add config as env vars - for key, value in config.items(): - env[f"CONFIG_{key.upper()}"] = str(value) - - logger.debug(f"Running script: {self._script_path}") - - result = subprocess.run( - [self._script_path], - capture_output=True, - text=True, - timeout=self._timeout, - env={**dict(_os.environ), **env}, - ) - - if self._output_format == "json": - try: - output = json.loads(result.stdout) - return CheckResult( - control_id=control_id, - status=output.get("status", "ERROR"), - message=output.get("message", result.stdout), - level=output.get("level", 1), - source=self._name, - ) - except json.JSONDecodeError: - pass - - # Non-JSON or parse failure - status = "PASS" if result.returncode == 0 else "FAIL" - return CheckResult( - control_id=control_id, - status=status, - message=result.stdout.strip() or result.stderr.strip(), - level=1, - source=self._name, - ) - - except subprocess.TimeoutExpired: - return CheckResult( - control_id=control_id, - status="ERROR", - message=f"Script timed out after {self._timeout}s", - level=1, - source=self._name, - ) - except FileNotFoundError: - return CheckResult( - control_id=control_id, - status="ERROR", - message=f"Script not found: {self._script_path}", - level=1, - source=self._name, - ) - except Exception as e: - return CheckResult( - control_id=control_id, - status="ERROR", - message=f"Script failed: {e}", - level=1, - source=self._name, - ) - - -# ============================================================================= -# Adapter Registry -# ============================================================================= - - -@dataclass -class AdapterRegistry: - """Registry for managing check and remediation adapters. - - Supports: - - Registration of adapter classes and instances - - Lazy loading from configuration - - Entry point discovery for plugins - - Example: - ```python - registry = AdapterRegistry() - - # Register a class - registry.register_check_adapter("builtin", BuiltinCheckAdapter) - - # Register from config - registry.register_from_config("kusari", { - "type": "command", - "command": "kusari", - }) - - # Get adapter instance - adapter = registry.get_check_adapter("kusari") - ``` - """ - - # Registered adapter classes - _check_classes: dict[str, type[CheckAdapter]] = field(default_factory=dict) - _remediation_classes: dict[str, type[RemediationAdapter]] = field( - default_factory=dict - ) - - # Instantiated adapters (cached) - _check_instances: dict[str, CheckAdapter] = field(default_factory=dict) - _remediation_instances: dict[str, RemediationAdapter] = field(default_factory=dict) - - # Config-based adapter definitions - _adapter_configs: dict[str, dict[str, Any]] = field(default_factory=dict) - - def register_check_adapter( - self, - name: str, - adapter: type[CheckAdapter] | CheckAdapter, - ) -> None: - """Register a check adapter class or instance. - - Args: - name: Adapter name - adapter: Adapter class or instance - """ - if isinstance(adapter, type): - self._check_classes[name] = adapter - else: - self._check_instances[name] = adapter - - def register_remediation_adapter( - self, - name: str, - adapter: type[RemediationAdapter] | RemediationAdapter, - ) -> None: - """Register a remediation adapter class or instance. - - Args: - name: Adapter name - adapter: Adapter class or instance - """ - if isinstance(adapter, type): - self._remediation_classes[name] = adapter - else: - self._remediation_instances[name] = adapter - - def register_from_config( - self, - name: str, - config: dict[str, Any], - ) -> None: - """Register an adapter from configuration. - - Args: - name: Adapter name - config: Adapter configuration dict - """ - self._adapter_configs[name] = config - - def get_check_adapter(self, name: str) -> CheckAdapter | None: - """Get a check adapter by name. - - Resolution order: - 1. Check local cache - 2. Instantiate from registered class - 3. Create from local config - 4. Lookup via PluginRegistry entry points - - Args: - name: Adapter name - - Returns: - CheckAdapter instance or None if not found - """ - # Check cache first - if name in self._check_instances: - return self._check_instances[name] - - # Try to instantiate from class - if name in self._check_classes: - instance = self._check_classes[name]() - self._check_instances[name] = instance - return instance - - # Try to create from config - if name in self._adapter_configs: - instance = self._create_check_adapter_from_config( - name, self._adapter_configs[name] - ) - if instance: - self._check_instances[name] = instance - return instance - - # Fallback: Try PluginRegistry for entry point lookup - try: - from .registry import get_plugin_registry - - registry = get_plugin_registry() - instance = registry.get_check_adapter(name) - if instance: - self._check_instances[name] = instance - return instance - except ImportError: - pass # Registry not available - - return None - - def get_remediation_adapter(self, name: str) -> RemediationAdapter | None: - """Get a remediation adapter by name. - - Resolution order: - 1. Check local cache - 2. Instantiate from registered class - 3. Lookup via PluginRegistry entry points - - Args: - name: Adapter name - - Returns: - RemediationAdapter instance or None if not found - """ - # Check cache first - if name in self._remediation_instances: - return self._remediation_instances[name] - - # Try to instantiate from class - if name in self._remediation_classes: - instance = self._remediation_classes[name]() - self._remediation_instances[name] = instance - return instance - - # Fallback: Try PluginRegistry for entry point lookup - try: - from .registry import get_plugin_registry - - registry = get_plugin_registry() - instance = registry.get_remediation_adapter(name) - if instance: - self._remediation_instances[name] = instance - return instance - except ImportError: - pass # Registry not available - - return None - - def _create_check_adapter_from_config( - self, - name: str, - config: dict[str, Any], - ) -> CheckAdapter | None: - """Create a check adapter from configuration. - - Args: - name: Adapter name - config: Adapter configuration - - Returns: - CheckAdapter instance or None - """ - adapter_type = config.get("type", "python") - - if adapter_type == "command": - return CommandCheckAdapter( - adapter_name=name, - command=config["command"], - output_format=config.get("output_format", "json"), - timeout=config.get("timeout", 300), - control_ids=config.get("controls"), - ) - - elif adapter_type == "script": - return ScriptCheckAdapter( - adapter_name=name, - script_path=config["command"], - output_format=config.get("output_format", "json"), - timeout=config.get("timeout", 300), - control_ids=config.get("controls"), - ) - - elif adapter_type == "python": - return self._load_python_adapter(name, config, CheckAdapter) - - return None - - def _load_python_adapter( - self, - name: str, - config: dict[str, Any], - expected_type: type, - ) -> Any | None: - """Load a Python adapter from module path. - - Args: - name: Adapter name - config: Config with 'module' and optionally 'class' keys - expected_type: Expected base class - - 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") - - if not module_path: - logger.error(f"Adapter {name} missing 'module' in config") - return None - - try: - adapter_class = resolve_module_path(f"{module_path}:{class_name}") - - if not issubclass(adapter_class, expected_type): - logger.error( - f"Adapter {name}: {class_name} is not a {expected_type.__name__}" - ) - return None - - return adapter_class() - - except ImportError as e: - logger.error(f"Failed to import adapter {name}: {e}") - return None - except AttributeError as e: - logger.error(f"Adapter {name}: class {class_name} not found: {e}") - return None - - def list_adapters(self) -> dict[str, list[str]]: - """List all registered adapters. - - Returns: - Dict with 'check' and 'remediation' adapter names - """ - check_names = set(self._check_classes.keys()) - check_names.update(self._check_instances.keys()) - check_names.update( - name - for name, cfg in self._adapter_configs.items() - if cfg.get("type") in ("command", "script", "python") - ) - - remediation_names = set(self._remediation_classes.keys()) - remediation_names.update(self._remediation_instances.keys()) - - return { - "check": sorted(check_names), - "remediation": sorted(remediation_names), - } - - -# Global registry instance -_global_registry: AdapterRegistry | None = None - - -def get_adapter_registry() -> AdapterRegistry: - """Get the global adapter registry. - - Returns: - Global AdapterRegistry instance - """ - global _global_registry - if _global_registry is None: - _global_registry = AdapterRegistry() - return _global_registry - - -def reset_adapter_registry() -> None: - """Reset the global adapter registry (for testing).""" - global _global_registry - _global_registry = None diff --git a/packages/darnit/src/darnit/core/models.py b/packages/darnit/src/darnit/core/models.py index 7a4ebfce..723a7c41 100644 --- a/packages/darnit/src/darnit/core/models.py +++ b/packages/darnit/src/darnit/core/models.py @@ -43,29 +43,6 @@ def to_dict(self) -> dict[str, Any]: } -@dataclass -class RemediationResult: - """Result of a remediation action.""" - - control_id: str - success: bool - message: str - changes_made: list[str] = field(default_factory=list) - requires_manual_action: bool = False - manual_steps: list[str] = field(default_factory=list) - source: str = "builtin" - - -@dataclass -class AdapterCapability: - """Describes what controls an adapter can handle.""" - - control_ids: set[str] # Specific control IDs, or {"*"} for all - supports_batch: bool = False # Can handle multiple controls in one call - batch_command: str | None = None # Command for batch mode - cache_key: str | None = None # Key for caching tool output (e.g., "scorecard") - - @dataclass class ExecutionContext: """Shared context for an audit run, enabling result caching across controls. diff --git a/packages/darnit/src/darnit/core/registry.py b/packages/darnit/src/darnit/core/registry.py index 5954a8d6..db8cab18 100644 --- a/packages/darnit/src/darnit/core/registry.py +++ b/packages/darnit/src/darnit/core/registry.py @@ -1,43 +1,26 @@ -"""Plugin Registry for discovering and managing darnit plugins. +"""Plugin Registry for discovering darnit frameworks. -This module provides a unified registry for discovering plugins via Python entry points: - -- **Frameworks**: Compliance framework definitions (TOML + adapters) -- **Check Adapters**: Verification implementations -- **Remediation Adapters**: Fix implementations +This module discovers compliance frameworks via Python entry points. Entry Point Groups: - ``darnit.frameworks`` - Framework TOML path providers - - ``darnit.check_adapters`` - Check adapter classes - - ``darnit.remediation_adapters`` - Remediation adapter classes - - ``darnit.implementations`` - Legacy full implementations (deprecated) + - ``darnit.implementations`` - Full implementations (see :mod:`darnit.core.discovery`) Example: - Discovering all plugins:: + Discovering frameworks:: from darnit.core.registry import get_plugin_registry registry = get_plugin_registry() - registry.discover_all() # List available frameworks for name in registry.list_frameworks(): print(f"Framework: {name}") - # Get an adapter by name - adapter = registry.get_check_adapter("kusari") - - Registering a plugin package (pyproject.toml):: - - [project.entry-points."darnit.check_adapters"] - kusari = "darnit_plugins.adapters.kusari:KusariCheckAdapter" + Registering a framework package (pyproject.toml):: [project.entry-points."darnit.frameworks"] my-framework = "my_package:get_framework_path" - -See Also: - - :doc:`/plugin-discovery-design` for architecture details - - :mod:`darnit.core.adapters` for adapter base classes """ from __future__ import annotations @@ -46,13 +29,6 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import ( - Any, -) - -from .adapters import CheckAdapter, RemediationAdapter -from .handlers import resolve_module_path -from .models import AdapterCapability logger = logging.getLogger(__name__) @@ -64,12 +40,6 @@ ENTRY_POINT_FRAMEWORKS = "darnit.frameworks" """Entry point group for framework TOML path providers.""" -ENTRY_POINT_CHECK_ADAPTERS = "darnit.check_adapters" -"""Entry point group for check adapter classes.""" - -ENTRY_POINT_REMEDIATION_ADAPTERS = "darnit.remediation_adapters" -"""Entry point group for remediation adapter classes.""" - ENTRY_POINT_IMPLEMENTATIONS = "darnit.implementations" """Entry point group for legacy implementations (deprecated).""" @@ -109,47 +79,6 @@ def path(self) -> Path: return self._path -@dataclass -class AdapterInfo: - """Metadata about a discovered adapter. - - Attributes: - name: Adapter identifier (e.g., "kusari") - package: Python package that provides this adapter - entry_point_name: Name as registered in entry points - adapter_class: The adapter class (not instantiated) - adapter_type: "check" or "remediation" - - Example: - >>> info = registry.get_adapter_info("kusari") - >>> adapter = info.get_instance() - >>> result = adapter.check("CTRL-001", ...) - """ - - name: str - package: str - entry_point_name: str - adapter_class: type - adapter_type: str # "check" or "remediation" - _instance: Any | None = field(default=None, repr=False) - _capabilities: AdapterCapability | None = field(default=None, repr=False) - - def get_instance(self) -> Any: - """Get the adapter instance (lazily instantiated).""" - if self._instance is None: - self._instance = self.adapter_class() - return self._instance - - @property - def capabilities(self) -> AdapterCapability | None: - """Get adapter capabilities (requires instantiation).""" - if self._capabilities is None: - instance = self.get_instance() - if hasattr(instance, "capabilities"): - self._capabilities = instance.capabilities() - return self._capabilities - - # ============================================================================= # Plugin Registry # ============================================================================= @@ -157,20 +86,12 @@ def capabilities(self) -> AdapterCapability | None: @dataclass class PluginRegistry: - """Central registry for all darnit plugins. + """Registry of the frameworks installed through entry points. - The PluginRegistry discovers and manages plugins from Python entry points: - - - **Frameworks**: Compliance framework definitions - - **Check Adapters**: Verification implementations - - **Remediation Adapters**: Fix implementations - - Thread-safe with lazy loading and caching. + Discovery is lazy and cached. Attributes: _frameworks: Discovered framework info objects - _check_adapters: Discovered check adapter info objects - _remediation_adapters: Discovered remediation adapter info objects _discovered: Set of entry point groups already discovered Example: @@ -178,43 +99,16 @@ class PluginRegistry: registry = get_plugin_registry() - # Discover all plugins - registry.discover_all() - - # List frameworks for name in registry.list_frameworks(): print(f"Found framework: {name}") - # Get a check adapter - adapter = registry.get_check_adapter("kusari") - if adapter: - result = adapter.check("CTRL-001", "", "", "/path", {}) - - Manual registration:: - - # Register a custom adapter - registry.register_check_adapter("custom", MyCustomAdapter) - - # Register from config - registry.register_from_adapter_config("kusari", { - "type": "command", - "command": "kusari", - }) - See Also: - :func:`get_plugin_registry` for accessing the global instance - :class:`FrameworkInfo` for framework metadata - - :class:`AdapterInfo` for adapter metadata """ # Discovered plugins _frameworks: dict[str, FrameworkInfo] = field(default_factory=dict) - _check_adapters: dict[str, AdapterInfo] = field(default_factory=dict) - _remediation_adapters: dict[str, AdapterInfo] = field(default_factory=dict) - - # Cached instances (separate from info to allow re-instantiation) - _check_instances: dict[str, CheckAdapter] = field(default_factory=dict) - _remediation_instances: dict[str, RemediationAdapter] = field(default_factory=dict) # Discovery state _discovered: set[str] = field(default_factory=set) @@ -223,21 +117,6 @@ class PluginRegistry: # Discovery Methods # ========================================================================= - def discover_all(self) -> None: - """Discover all plugins from entry points. - - Scans all entry point groups and populates the registry. - Safe to call multiple times (idempotent). - - Example: - >>> registry = get_plugin_registry() - >>> registry.discover_all() - >>> print(f"Found {len(registry.list_frameworks())} frameworks") - """ - self.discover_frameworks() - self.discover_check_adapters() - self.discover_remediation_adapters() - def discover_frameworks(self) -> dict[str, FrameworkInfo]: """Discover all installed frameworks from entry points. @@ -277,79 +156,6 @@ def discover_frameworks(self) -> dict[str, FrameworkInfo]: logger.info(f"Discovered {len(self._frameworks)} framework(s)") return self._frameworks - def discover_check_adapters(self) -> dict[str, AdapterInfo]: - """Discover all installed check adapters from entry points. - - Scans the ``darnit.check_adapters`` entry point group. - - Returns: - Dict mapping adapter names to AdapterInfo objects. - - Example: - >>> adapters = registry.discover_check_adapters() - >>> for name, info in adapters.items(): - ... print(f"{name}: {info.adapter_class}") - """ - if ENTRY_POINT_CHECK_ADAPTERS in self._discovered: - return self._check_adapters - - for ep in self._iter_entry_points(ENTRY_POINT_CHECK_ADAPTERS): - try: - adapter_class = ep.load() - name = ep.name - package = self._get_package_name(ep) - - self._check_adapters[name] = AdapterInfo( - name=name, - package=package, - entry_point_name=ep.name, - adapter_class=adapter_class, - adapter_type="check", - ) - logger.debug(f"Discovered check adapter: {name} from {package}") - - except Exception as e: - logger.warning(f"Failed to load check adapter {ep.name}: {e}") - - self._discovered.add(ENTRY_POINT_CHECK_ADAPTERS) - logger.info(f"Discovered {len(self._check_adapters)} check adapter(s)") - return self._check_adapters - - def discover_remediation_adapters(self) -> dict[str, AdapterInfo]: - """Discover all installed remediation adapters from entry points. - - Scans the ``darnit.remediation_adapters`` entry point group. - - Returns: - Dict mapping adapter names to AdapterInfo objects. - """ - if ENTRY_POINT_REMEDIATION_ADAPTERS in self._discovered: - return self._remediation_adapters - - for ep in self._iter_entry_points(ENTRY_POINT_REMEDIATION_ADAPTERS): - try: - adapter_class = ep.load() - name = ep.name - package = self._get_package_name(ep) - - self._remediation_adapters[name] = AdapterInfo( - name=name, - package=package, - entry_point_name=ep.name, - adapter_class=adapter_class, - adapter_type="remediation", - ) - logger.debug(f"Discovered remediation adapter: {name} from {package}") - - except Exception as e: - logger.warning(f"Failed to load remediation adapter {ep.name}: {e}") - - self._discovered.add(ENTRY_POINT_REMEDIATION_ADAPTERS) - logger.info( - f"Discovered {len(self._remediation_adapters)} remediation adapter(s)" - ) - return self._remediation_adapters - # ========================================================================= # Framework Access # ========================================================================= @@ -398,327 +204,6 @@ def get_framework_path(self, name: str) -> Path | None: info = self.get_framework_info(name) return info.path if info else None - def has_framework(self, name: str) -> bool: - """Check if a framework is available. - - Args: - name: Framework identifier - - Returns: - True if framework is registered. - """ - self.discover_frameworks() - return name in self._frameworks - - # ========================================================================= - # Check Adapter Access - # ========================================================================= - - def list_check_adapters(self) -> list[str]: - """List all available check adapter names. - - Returns: - Sorted list of check adapter names. - - Example: - >>> for name in registry.list_check_adapters(): - ... print(name) - builtin - kusari - trivy - """ - self.discover_check_adapters() - return sorted(self._check_adapters.keys()) - - def get_check_adapter_info(self, name: str) -> AdapterInfo | None: - """Get check adapter info by name. - - Args: - name: Adapter identifier (e.g., "kusari") - - Returns: - AdapterInfo or None if not found. - """ - self.discover_check_adapters() - return self._check_adapters.get(name) - - def get_check_adapter(self, name: str) -> CheckAdapter | None: - """Get a check adapter instance by name. - - Instances are cached for reuse. - - Args: - name: Adapter identifier (e.g., "kusari") - - Returns: - CheckAdapter instance or None if not found. - - Example: - >>> adapter = registry.get_check_adapter("kusari") - >>> if adapter: - ... result = adapter.check("CTRL-001", "", "", "/path", {}) - """ - # Check cache first - if name in self._check_instances: - return self._check_instances[name] - - # Try to get from discovered adapters - info = self.get_check_adapter_info(name) - if info: - instance = info.get_instance() - self._check_instances[name] = instance - return instance - - return None - - def has_check_adapter(self, name: str) -> bool: - """Check if a check adapter is available. - - Args: - name: Adapter identifier - - Returns: - True if adapter is registered. - """ - self.discover_check_adapters() - return name in self._check_adapters - - # ========================================================================= - # Remediation Adapter Access - # ========================================================================= - - def list_remediation_adapters(self) -> list[str]: - """List all available remediation adapter names. - - Returns: - Sorted list of remediation adapter names. - """ - self.discover_remediation_adapters() - return sorted(self._remediation_adapters.keys()) - - def get_remediation_adapter_info(self, name: str) -> AdapterInfo | None: - """Get remediation adapter info by name. - - Args: - name: Adapter identifier - - Returns: - AdapterInfo or None if not found. - """ - self.discover_remediation_adapters() - return self._remediation_adapters.get(name) - - def get_remediation_adapter(self, name: str) -> RemediationAdapter | None: - """Get a remediation adapter instance by name. - - Args: - name: Adapter identifier - - Returns: - RemediationAdapter instance or None if not found. - """ - # Check cache first - if name in self._remediation_instances: - return self._remediation_instances[name] - - # Try to get from discovered adapters - info = self.get_remediation_adapter_info(name) - if info: - instance = info.get_instance() - self._remediation_instances[name] = instance - return instance - - return None - - def has_remediation_adapter(self, name: str) -> bool: - """Check if a remediation adapter is available. - - Args: - name: Adapter identifier - - Returns: - True if adapter is registered. - """ - self.discover_remediation_adapters() - return name in self._remediation_adapters - - # ========================================================================= - # Manual Registration - # ========================================================================= - - def register_framework( - self, - name: str, - path_func: Callable[[], Path], - package: str = "manual", - ) -> None: - """Manually register a framework. - - Args: - name: Framework identifier - path_func: Callable that returns the framework TOML path - package: Package name for tracking (default: "manual") - - Example: - >>> registry.register_framework( - ... "custom", - ... lambda: Path("/path/to/custom.toml"), - ... ) - """ - self._frameworks[name] = FrameworkInfo( - name=name, - package=package, - entry_point_name=name, - path_func=path_func, - ) - logger.debug(f"Registered framework: {name}") - - def register_check_adapter( - self, - name: str, - adapter: type[CheckAdapter] | CheckAdapter, - package: str = "manual", - ) -> None: - """Manually register a check adapter. - - Args: - name: Adapter identifier - adapter: Adapter class or instance - package: Package name for tracking (default: "manual") - - Example: - >>> registry.register_check_adapter("custom", MyCustomAdapter) - >>> # Or with an instance - >>> registry.register_check_adapter("custom", MyCustomAdapter()) - """ - if isinstance(adapter, type): - # It's a class - self._check_adapters[name] = AdapterInfo( - name=name, - package=package, - entry_point_name=name, - adapter_class=adapter, - adapter_type="check", - ) - else: - # It's an instance - self._check_instances[name] = adapter - self._check_adapters[name] = AdapterInfo( - name=name, - package=package, - entry_point_name=name, - adapter_class=type(adapter), - adapter_type="check", - _instance=adapter, - ) - logger.debug(f"Registered check adapter: {name}") - - def register_remediation_adapter( - self, - name: str, - adapter: type[RemediationAdapter] | RemediationAdapter, - package: str = "manual", - ) -> None: - """Manually register a remediation adapter. - - Args: - name: Adapter identifier - adapter: Adapter class or instance - package: Package name for tracking (default: "manual") - """ - if isinstance(adapter, type): - self._remediation_adapters[name] = AdapterInfo( - name=name, - package=package, - entry_point_name=name, - adapter_class=adapter, - adapter_type="remediation", - ) - else: - self._remediation_instances[name] = adapter - self._remediation_adapters[name] = AdapterInfo( - name=name, - package=package, - entry_point_name=name, - adapter_class=type(adapter), - adapter_type="remediation", - _instance=adapter, - ) - logger.debug(f"Registered remediation adapter: {name}") - - def register_from_adapter_config( - self, - name: str, - config: dict[str, Any], - ) -> CheckAdapter | None: - """Register a check adapter from configuration dict. - - Supports the following adapter types: - - ``python``: Load from module path - - ``command``: Create CommandCheckAdapter - - ``script``: Create ScriptCheckAdapter - - ``plugin``: Resolve via entry points (by name) - - Args: - name: Adapter identifier - config: Adapter configuration dict - - Returns: - CheckAdapter instance or None if creation failed. - - Example: - >>> registry.register_from_adapter_config("kusari", { - ... "type": "command", - ... "command": "kusari", - ... "output_format": "json", - ... }) - """ - from .adapters import CommandCheckAdapter, ScriptCheckAdapter - - adapter_type = config.get("type", "python") - - try: - if adapter_type == "command": - adapter = CommandCheckAdapter( - adapter_name=name, - command=config["command"], - output_format=config.get("output_format", "json"), - timeout=config.get("timeout", 300), - control_ids=config.get("controls"), - ) - self.register_check_adapter(name, adapter, package="config") - return adapter - - elif adapter_type == "script": - adapter = ScriptCheckAdapter( - adapter_name=name, - script_path=config["command"], - output_format=config.get("output_format", "json"), - timeout=config.get("timeout", 300), - control_ids=config.get("controls"), - ) - self.register_check_adapter(name, adapter, package="config") - return adapter - - elif adapter_type == "python": - adapter = self._load_python_adapter(name, config) - if adapter: - self.register_check_adapter(name, adapter, package="config") - return adapter - - elif adapter_type == "plugin": - # Resolve by name from entry points - plugin_name = config.get("name", name) - return self.get_check_adapter(plugin_name) - - else: - logger.warning(f"Unknown adapter type: {adapter_type}") - return None - - except Exception as e: - logger.error(f"Failed to create adapter {name}: {e}") - return None - # ========================================================================= # Utilities # ========================================================================= @@ -729,45 +214,9 @@ def clear_cache(self) -> None: Useful for testing or when plugins may have changed. """ self._frameworks.clear() - self._check_adapters.clear() - self._remediation_adapters.clear() - self._check_instances.clear() - self._remediation_instances.clear() self._discovered.clear() logger.debug("Plugin registry cache cleared") - def get_plugin_summary(self) -> dict[str, Any]: - """Get summary of all discovered plugins. - - Returns: - Dict with plugin counts and names. - - Example: - >>> summary = registry.get_plugin_summary() - >>> print(summary) - { - "frameworks": ["openssf-baseline", "testchecks"], - "check_adapters": ["builtin", "kusari"], - "remediation_adapters": ["builtin"], - "counts": { - "frameworks": 2, - "check_adapters": 2, - "remediation_adapters": 1, - } - } - """ - self.discover_all() - return { - "frameworks": self.list_frameworks(), - "check_adapters": self.list_check_adapters(), - "remediation_adapters": self.list_remediation_adapters(), - "counts": { - "frameworks": len(self._frameworks), - "check_adapters": len(self._check_adapters), - "remediation_adapters": len(self._remediation_adapters), - }, - } - # ========================================================================= # Private Helpers # ========================================================================= @@ -788,30 +237,6 @@ def _get_package_name(self, entry_point) -> str: else: return "unknown" - def _load_python_adapter( - self, - name: str, - config: dict[str, Any], - ) -> CheckAdapter | None: - """Load a Python adapter from module path.""" - module_path = config.get("module") - class_name = config.get("class", "Adapter") - - if not module_path: - logger.error(f"Adapter {name} missing 'module' in config") - return None - - try: - adapter_class = resolve_module_path(f"{module_path}:{class_name}") - return adapter_class() - - except ImportError as e: - logger.error(f"Failed to import adapter {name}: {e}") - return None - except AttributeError as e: - logger.error(f"Adapter {name}: class {class_name} not found: {e}") - return None - # ============================================================================= # Global Registry Instance @@ -830,8 +255,7 @@ def get_plugin_registry() -> PluginRegistry: Example: >>> registry = get_plugin_registry() - >>> registry.discover_all() - >>> adapters = registry.list_check_adapters() + >>> frameworks = registry.list_frameworks() """ global _global_registry if _global_registry is None: @@ -851,12 +275,9 @@ def reset_plugin_registry() -> None: __all__ = [ # Entry point constants "ENTRY_POINT_FRAMEWORKS", - "ENTRY_POINT_CHECK_ADAPTERS", - "ENTRY_POINT_REMEDIATION_ADAPTERS", "ENTRY_POINT_IMPLEMENTATIONS", # Info classes "FrameworkInfo", - "AdapterInfo", # Registry "PluginRegistry", "get_plugin_registry", diff --git a/packages/darnit/src/darnit/filtering/filters.py b/packages/darnit/src/darnit/filtering/filters.py index 94c723a5..9b818303 100644 --- a/packages/darnit/src/darnit/filtering/filters.py +++ b/packages/darnit/src/darnit/filtering/filters.py @@ -13,7 +13,7 @@ Grammar: filter := key_value | bare_tag key_value := key operator value - key := "level" | "domain" | "severity" | "adapter" | + key := "level" | "domain" | "severity" | operator := "=" | "<=" | ">=" | "<" | ">" | "!=" bare_tag := string # Matches against tags dict (key exists with truthy value) @@ -44,7 +44,7 @@ class ControlFilter: """A single filter condition.""" - field: str # 'level', 'domain', 'tags', 'severity', 'adapter' + field: str # 'level', 'domain', 'tags', 'severity' operator: str # '=', '<=', '>=', '<', '>', '!=', 'in' value: Any # The value to compare @@ -227,18 +227,6 @@ def matches_filter(control: Any, f: ControlFilter) -> bool: return compare(severity, f.operator, float(f.value)) - elif f.field == "adapter": - # Match by check adapter name - adapter = getattr(control, "check_adapter", None) - if adapter is None and hasattr(control, "check"): - adapter = getattr(control.check, "adapter", None) - - if f.operator == "=": - return adapter == f.value - elif f.operator == "!=": - return adapter != f.value - return False - else: # Check if field exists in control's tags dict (for arbitrary tag filtering) tags = getattr(control, "tags", None) or {} diff --git a/packages/darnit/src/darnit/locate/__init__.py b/packages/darnit/src/darnit/locate/__init__.py deleted file mode 100644 index f4719448..00000000 --- a/packages/darnit/src/darnit/locate/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Unified evidence location system for darnit. - -This package provides: -- UnifiedLocator: Service for locating evidence with .project/ integration -- Tool output normalizer: Converts external tool outputs to CheckOutput contract -- Models: FoundEvidence, LocateResult, CheckOutput - -The locate system implements a three-phase lookup: -1. Check .project/ configuration references first -2. Fall back to pattern-based discovery -3. Optionally use LLM hints for investigation fallback - -All check adapters (builtin, command, script) must return CheckOutput, -which enables: -- Consistent status reporting (pass/fail/error/inconclusive) -- Evidence tracking for .project/ sync -- Remediation context (issues, suggestions) -""" - -from .locator import UnifiedLocator -from .models import ( - CheckOutput, - # Core models - FoundEvidence, - LocateResult, - create_error_output, - create_fail_output, - create_inconclusive_output, - # Factory functions - create_pass_output, -) -from .normalizer import ( - extract_jsonpath, - normalize_scorecard_output, - normalize_tool_output, -) - -__all__ = [ - # Core models - "FoundEvidence", - "LocateResult", - "CheckOutput", - # Factory functions - "create_pass_output", - "create_fail_output", - "create_error_output", - "create_inconclusive_output", - # Locator - "UnifiedLocator", - # Normalizer - "extract_jsonpath", - "normalize_tool_output", - "normalize_scorecard_output", -] diff --git a/packages/darnit/src/darnit/locate/locator.py b/packages/darnit/src/darnit/locate/locator.py deleted file mode 100644 index 86bed62b..00000000 --- a/packages/darnit/src/darnit/locate/locator.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Unified evidence location service. - -This module provides the UnifiedLocator class that handles all evidence -location for controls, including: -1. .project/ configuration reference lookup -2. Pattern-based file discovery -3. Syncing discovered evidence back to .project/ -""" - -import os - -from darnit.config.discovery import _set_config_path, discover_files -from darnit.config.framework_schema import LocatorConfig -from darnit.config.loader import load_project_config, load_project_config_checked, update_project_config -from darnit.config.schema import ProjectConfig -from darnit.core.logging import get_logger - -from .models import FoundEvidence, LocateResult - -logger = get_logger("locate.locator") - - -class UnifiedLocator: - """Unified evidence location service. - - Handles locating evidence for controls with .project/ integration: - 1. Check .project/ reference first (via project_path) - 2. Fall back to pattern-based discovery - 3. Optionally sync discovered evidence back to .project/ - - Example: - ```python - locator = UnifiedLocator("/path/to/repo") - - # Locate using LocatorConfig from TOML - config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md", ".github/SECURITY.md"], - kind="file" - ) - result = locator.locate("OSPS-VM-01.01", config) - - if result.success: - print(f"Found: {result.found.path}") - if result.needs_sync: - locator.sync_to_project("OSPS-VM-01.01", result.found, config) - ``` - """ - - def __init__( - self, - local_path: str, - project_config: ProjectConfig | None = None, - ): - """Initialize the locator. - - Args: - local_path: Repository path - project_config: Optional pre-loaded project config (loaded on demand if not provided) - """ - self.local_path = local_path - self._project_config = project_config - self._config_loaded = project_config is not None - - @property - def project_config(self) -> ProjectConfig | None: - """Lazy-load project config on first access.""" - if not self._config_loaded: - self._project_config = load_project_config(self.local_path) - self._config_loaded = True - return self._project_config - - def locate( - self, - control_id: str, - locator_config: LocatorConfig, - ) -> LocateResult: - """Locate evidence for a control. - - Implements three-phase lookup: - 1. Check .project/ reference (project_path) - 2. Fall back to discovery patterns - 3. Return location + source - - Args: - control_id: OSPS control ID (e.g., "OSPS-VM-01.01") - locator_config: Configuration for how to locate evidence - - Returns: - LocateResult with found evidence and source - """ - searched_locations: list[str] = [] - - # Phase 1: Check .project/ reference - if locator_config.project_path: - result = self._locate_via_config(control_id, locator_config) - if result.success: - logger.debug( - f"Control {control_id}: located via .project/ reference: {result.found.location}" - ) - return result - searched_locations.extend(result.searched_locations) - - # Phase 2: Fall back to pattern discovery - if locator_config.discover: - result = self._locate_via_discovery(control_id, locator_config) - if result.success: - logger.debug( - f"Control {control_id}: located via discovery: {result.found.location}" - ) - # Mark for sync since it was discovered, not in config - result.sync_recommended = True - result.searched_locations = searched_locations + result.searched_locations - return result - searched_locations.extend(result.searched_locations) - - # Not found - logger.debug(f"Control {control_id}: evidence not found") - return LocateResult( - found=None, - source="none", - searched_locations=searched_locations, - sync_recommended=False, - ) - - def _locate_via_config( - self, - control_id: str, - locator_config: LocatorConfig, - ) -> LocateResult: - """Try to locate evidence via .project/ reference. - - Args: - control_id: OSPS control ID - locator_config: Locator configuration - - Returns: - LocateResult (may be empty if not found via config) - """ - searched: list[str] = [] - - if not locator_config.project_path: - return LocateResult(source="none", searched_locations=searched) - - config = self.project_config - if not config: - searched.append(".project/ (not found)") - return LocateResult(source="none", searched_locations=searched) - - # Parse project_path (e.g., "security.policy" -> section="security", field="policy") - parts = locator_config.project_path.split(".", 1) - if len(parts) != 2: - logger.warning(f"Invalid project_path format: {locator_config.project_path}") - return LocateResult(source="none", searched_locations=searched) - - section, field = parts - config_path = config.get_path(section, field) - - if not config_path: - searched.append(f".project/{section}.{field} (not set)") - return LocateResult(source="none", searched_locations=searched) - - # Verify the file exists (for file kind) - if locator_config.kind == "file": - full_path = os.path.join(self.local_path, config_path) - if not os.path.exists(full_path): - searched.append(f"{config_path} (referenced but missing)") - return LocateResult(source="none", searched_locations=searched) - - # Found via config - found = FoundEvidence( - path=config_path if locator_config.kind == "file" else None, - url=config_path if locator_config.kind == "url" else None, - api_endpoint=config_path if locator_config.kind == "api" else None, - kind=locator_config.kind, - ) - searched.append(f".project/{section}.{field} = {config_path}") - - return LocateResult( - found=found, - source="config", - searched_locations=searched, - sync_recommended=False, # Already in config - ) - - def _locate_via_discovery( - self, - control_id: str, - locator_config: LocatorConfig, - ) -> LocateResult: - """Try to locate evidence via pattern discovery. - - Args: - control_id: OSPS control ID - locator_config: Locator configuration - - Returns: - LocateResult (may be empty if not found via discovery) - """ - searched: list[str] = [] - - if not locator_config.discover: - return LocateResult(source="none", searched_locations=searched) - - # Only file discovery is supported currently - if locator_config.kind != "file": - return LocateResult(source="none", searched_locations=searched) - - # Use the discover_files utility - # We need a ref_path for the discovery function, use a synthetic one - ref_path = locator_config.project_path or f"locate.{control_id}" - - discovered = discover_files( - self.local_path, - {ref_path: locator_config.discover} - ) - - searched.extend(locator_config.discover) - - if ref_path in discovered: - discovered_path = discovered[ref_path] - found = FoundEvidence( - path=discovered_path, - kind="file", - ) - return LocateResult( - found=found, - source="discovered", - searched_locations=searched, - sync_recommended=True, # Should be synced to config - ) - - return LocateResult(source="none", searched_locations=searched) - - def sync_to_project( - self, - control_id: str, - found: FoundEvidence, - locator_config: LocatorConfig, - ) -> bool: - """Sync found evidence back to .project/. - - Updates the .project/ configuration with the found evidence path, - so future lookups can find it via config reference. - - Args: - control_id: OSPS control ID - found: The evidence that was found - locator_config: Locator configuration (needs project_path) - - Returns: - True if config was updated, False otherwise - """ - if not locator_config.project_path: - logger.debug( - f"Control {control_id}: no project_path in locator config, cannot sync" - ) - return False - - if not found.path: - logger.debug( - f"Control {control_id}: found evidence has no path, cannot sync" - ) - return False - - parts = locator_config.project_path.split(".", 1) - if len(parts) != 2: - logger.debug(f"Invalid project_path format: {locator_config.project_path}") - return False - - section, field = parts - - files = load_project_config_checked(self.local_path) - if files.invalid: - logger.warning(f"Not updating .project/ for {control_id}: {'; '.join(files.errors)}") - return False - - config = self.project_config - if config is not None and config.get_path(section, field) == found.path: - logger.debug( - f"Control {control_id}: .project/{section}.{field} already set to {found.path}" - ) - return False - - written = update_project_config( - self.local_path, - [locator_config.project_path], - lambda config: _set_config_path(config, section, field, found.path), - ) - self._config_loaded = False - if not written: - return False - logger.info( - f"Updated .project/ with {section}.{field} = {found.path}" - ) - - return True - - def locate_and_sync( - self, - control_id: str, - locator_config: LocatorConfig, - auto_sync: bool = True, - ) -> LocateResult: - """Locate evidence and optionally sync to .project/. - - Convenience method that combines locate() and sync_to_project(). - - Args: - control_id: OSPS control ID - locator_config: Locator configuration - auto_sync: Whether to automatically sync discovered evidence - - Returns: - LocateResult with found evidence - """ - result = self.locate(control_id, locator_config) - - if auto_sync and result.needs_sync and result.found: - synced = self.sync_to_project(control_id, result.found, locator_config) - if synced: - # Update source to reflect that it's now in config - result.sync_recommended = False - - return result - - -__all__ = ["UnifiedLocator"] diff --git a/packages/darnit/src/darnit/locate/models.py b/packages/darnit/src/darnit/locate/models.py deleted file mode 100644 index 5c8a1b68..00000000 --- a/packages/darnit/src/darnit/locate/models.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Data models for the unified evidence location system. - -This module defines the core data structures for: -- FoundEvidence: What was located and where -- LocateResult: Result of a location operation -- CheckOutput: Standardized output from any check adapter - -These models form the tool output contract that all check adapters -(builtin, command, script, http) must return. -""" - -from dataclasses import dataclass, field -from typing import Any, Literal - -# ============================================================================= -# Found Evidence Models -# ============================================================================= - - -@dataclass -class FoundEvidence: - """What the check located. - - Represents evidence found for a control - could be a file, URL, API endpoint, - or configuration. This is used to: - 1. Report what was found during checks - 2. Sync back to .project/ configuration - - Attributes: - path: File path relative to repository root - url: External URL (e.g., docs.example.com/security) - api_endpoint: API endpoint that was checked (e.g., GitHub API) - kind: Type of evidence (file, url, api, config) - """ - path: str | None = None - url: str | None = None - api_endpoint: str | None = None - kind: Literal["file", "url", "api", "config"] = "file" - - def __post_init__(self): - """Validate that at least one location is provided.""" - if not any([self.path, self.url, self.api_endpoint]): - # Allow empty for cases where nothing was found but we want to record the attempt - pass - - @property - def location(self) -> str | None: - """Return the primary location identifier.""" - if self.path: - return self.path - if self.url: - return self.url - if self.api_endpoint: - return self.api_endpoint - return None - - -@dataclass -class LocateResult: - """Result of a location operation. - - Represents the outcome of trying to locate evidence for a control, - including where it was found and how (config reference vs discovery). - - Attributes: - found: The evidence that was found, or None if not found - source: How the evidence was located - searched_locations: List of locations that were checked - sync_recommended: Whether the found evidence should be synced to .project/ - """ - found: FoundEvidence | None = None - source: Literal["config", "discovered", "llm", "none"] = "none" - searched_locations: list[str] = field(default_factory=list) - sync_recommended: bool = False - - @property - def success(self) -> bool: - """Whether evidence was successfully located.""" - return self.found is not None - - @property - def needs_sync(self) -> bool: - """Whether the evidence should be synced to .project/. - - Returns True when evidence was discovered but not via config reference, - indicating the config should be updated. - """ - return self.sync_recommended and self.source in ("discovered", "llm") - - -# ============================================================================= -# Check Output Models -# ============================================================================= - - -@dataclass -class CheckOutput: - """Standardized output from any check adapter. - - This is the tool output contract that all check adapters must return. - It provides a unified interface for: - - Pass/fail status - - Confidence level - - What was found (for .project/ sync) - - Details for remediation context - - Attributes: - status: Check result status - message: Human-readable explanation - confidence: Confidence level (0.0 to 1.0) - found: Evidence that was located - evidence: Additional evidence details (adapter-specific) - issues: List of issues found - suggestions: Remediation suggestions - """ - # Core result - status: Literal["pass", "fail", "error", "inconclusive"] - message: str - confidence: float = 1.0 - - # What was found (for .project/ sync) - found: FoundEvidence | None = None - - # Validation details (adapter-specific) - evidence: dict[str, Any] = field(default_factory=dict) - - # For remediation context - issues: list[str] = field(default_factory=list) - suggestions: list[str] = field(default_factory=list) - - def __post_init__(self): - """Validate confidence is in valid range.""" - if not 0.0 <= self.confidence <= 1.0: - raise ValueError(f"Confidence must be between 0.0 and 1.0, got {self.confidence}") - - @property - def passed(self) -> bool: - """Whether the check passed.""" - return self.status == "pass" - - @property - def failed(self) -> bool: - """Whether the check failed.""" - return self.status == "fail" - - @property - def has_evidence(self) -> bool: - """Whether evidence was found.""" - return self.found is not None - - -# ============================================================================= -# Factory Functions -# ============================================================================= - - -def create_pass_output( - message: str, - found: FoundEvidence | None = None, - confidence: float = 1.0, - **evidence: Any, -) -> CheckOutput: - """Create a passing check output. - - Args: - message: Success message - found: Evidence that was found - confidence: Confidence level - **evidence: Additional evidence details - - Returns: - CheckOutput with status="pass" - """ - return CheckOutput( - status="pass", - message=message, - confidence=confidence, - found=found, - evidence=evidence, - ) - - -def create_fail_output( - message: str, - issues: list[str] | None = None, - suggestions: list[str] | None = None, - confidence: float = 1.0, - **evidence: Any, -) -> CheckOutput: - """Create a failing check output. - - Args: - message: Failure message - issues: List of specific issues found - suggestions: Remediation suggestions - confidence: Confidence level - **evidence: Additional evidence details - - Returns: - CheckOutput with status="fail" - """ - return CheckOutput( - status="fail", - message=message, - confidence=confidence, - issues=issues or [], - suggestions=suggestions or [], - evidence=evidence, - ) - - -def create_error_output( - message: str, - exception: Exception | None = None, -) -> CheckOutput: - """Create an error check output. - - Args: - message: Error message - exception: The exception that occurred - - Returns: - CheckOutput with status="error" - """ - evidence = {} - if exception: - evidence["exception_type"] = type(exception).__name__ - evidence["exception_message"] = str(exception) - - return CheckOutput( - status="error", - message=message, - confidence=0.0, - evidence=evidence, - ) - - -def create_inconclusive_output( - message: str, - confidence: float = 0.5, - suggestions: list[str] | None = None, - **evidence: Any, -) -> CheckOutput: - """Create an inconclusive check output. - - Args: - message: Explanation of why inconclusive - confidence: Confidence level (typically low) - suggestions: Suggestions for manual verification - **evidence: Additional evidence details - - Returns: - CheckOutput with status="inconclusive" - """ - return CheckOutput( - status="inconclusive", - message=message, - confidence=confidence, - suggestions=suggestions or [], - evidence=evidence, - ) - - -__all__ = [ - # Models - "FoundEvidence", - "LocateResult", - "CheckOutput", - # Factory functions - "create_pass_output", - "create_fail_output", - "create_error_output", - "create_inconclusive_output", -] diff --git a/packages/darnit/src/darnit/locate/normalizer.py b/packages/darnit/src/darnit/locate/normalizer.py deleted file mode 100644 index a61a58fb..00000000 --- a/packages/darnit/src/darnit/locate/normalizer.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Tool output normalizer. - -This module provides functions to normalize external tool outputs -(like Scorecard, Trivy, Kusari) to the standardized CheckOutput format. - -The normalizer uses JSONPath expressions from OutputMapping to extract -relevant fields from tool outputs. -""" - -from typing import Any - -from darnit.config.framework_schema import OutputMapping -from darnit.core.logging import get_logger - -from .models import CheckOutput, FoundEvidence, create_error_output - -logger = get_logger("locate.normalizer") - - -def extract_jsonpath(data: Any, path: str | None) -> Any: - """Extract a value from data using a simple JSONPath expression. - - Supports a subset of JSONPath: - - $.field - Root field access - - $.field.nested - Nested field access - - $.array[0] - Array index access - - $.field[*].subfield - Not supported (returns None) - - Args: - data: The data to extract from (dict or list) - path: JSONPath expression (e.g., "$.checks.BranchProtection.pass") - - Returns: - Extracted value or None if not found - """ - if path is None or data is None: - return None - - # Remove leading $. if present - if path.startswith("$."): - path = path[2:] - elif path.startswith("$"): - path = path[1:] - - # Split path into segments - segments = [] - current = "" - i = 0 - while i < len(path): - char = path[i] - if char == ".": - if current: - segments.append(current) - current = "" - elif char == "[": - if current: - segments.append(current) - current = "" - # Find matching ] - j = i + 1 - while j < len(path) and path[j] != "]": - j += 1 - index_str = path[i + 1:j] - if index_str.isdigit(): - segments.append(int(index_str)) - i = j - else: - current += char - i += 1 - - if current: - segments.append(current) - - # Navigate through data - result = data - for segment in segments: - if result is None: - return None - if isinstance(segment, int): - if isinstance(result, list) and 0 <= segment < len(result): - result = result[segment] - else: - return None - elif isinstance(result, dict): - result = result.get(segment) - else: - return None - - return result - - -def normalize_tool_output( - raw_output: dict[str, Any] | str, - output_mapping: OutputMapping, -) -> CheckOutput: - """Normalize external tool output to CheckOutput contract. - - Args: - raw_output: Raw output from the tool (typically JSON dict) - output_mapping: Mapping configuration for extraction - - Returns: - Normalized CheckOutput - """ - # Handle string input (try to parse as JSON) - if isinstance(raw_output, str): - import json - try: - raw_output = json.loads(raw_output) - except json.JSONDecodeError: - return create_error_output( - message="Failed to parse tool output as JSON", - ) - - if not isinstance(raw_output, dict): - return create_error_output( - message=f"Expected dict output, got {type(raw_output).__name__}", - ) - - # Extract status - status = _extract_status(raw_output, output_mapping) - - # Extract message - message = _extract_message(raw_output, output_mapping, status) - - # Extract score and apply threshold - score = None - if output_mapping.score_path: - score = extract_jsonpath(raw_output, output_mapping.score_path) - if score is not None and output_mapping.pass_threshold is not None: - try: - score_float = float(score) - if score_float >= output_mapping.pass_threshold: - status = "pass" - else: - status = "fail" - except (ValueError, TypeError): - logger.warning(f"Could not convert score to float: {score}") - - # Extract found evidence - found = _extract_found(raw_output, output_mapping) - - # Build confidence based on how complete the output is - confidence = 1.0 if status in ("pass", "fail") else 0.5 - - return CheckOutput( - status=status, - message=message, - confidence=confidence, - found=found, - evidence={ - "raw_output": raw_output, - "score": score, - }, - ) - - -def _extract_status( - raw_output: dict[str, Any], - output_mapping: OutputMapping, -) -> str: - """Extract status from raw output. - - Args: - raw_output: Raw tool output - output_mapping: Output mapping configuration - - Returns: - Status string (pass, fail, error, inconclusive) - """ - if not output_mapping.status_path: - return "inconclusive" - - status_value = extract_jsonpath(raw_output, output_mapping.status_path) - - if status_value is None: - return "inconclusive" - - # Handle boolean - if isinstance(status_value, bool): - return "pass" if status_value else "fail" - - # Handle string - if isinstance(status_value, str): - status_lower = status_value.lower() - if status_lower in ("pass", "passed", "success", "true", "ok"): - return "pass" - elif status_lower in ("fail", "failed", "failure", "false", "error"): - return "fail" - elif status_lower in ("error", "exception"): - return "error" - else: - return "inconclusive" - - # Handle numeric (treat non-zero as pass) - if isinstance(status_value, (int, float)): - return "pass" if status_value else "fail" - - return "inconclusive" - - -def _extract_message( - raw_output: dict[str, Any], - output_mapping: OutputMapping, - status: str, -) -> str: - """Extract message from raw output. - - Args: - raw_output: Raw tool output - output_mapping: Output mapping configuration - status: Extracted status - - Returns: - Message string - """ - if output_mapping.message_path: - message = extract_jsonpath(raw_output, output_mapping.message_path) - if message is not None: - return str(message) - - # Generate default message - if status == "pass": - return "Check passed" - elif status == "fail": - return "Check failed" - elif status == "error": - return "Check encountered an error" - else: - return "Check result inconclusive" - - -def _extract_found( - raw_output: dict[str, Any], - output_mapping: OutputMapping, -) -> FoundEvidence | None: - """Extract found evidence from raw output. - - Args: - raw_output: Raw tool output - output_mapping: Output mapping configuration - - Returns: - FoundEvidence or None - """ - if not output_mapping.found_path: - return None - - found_value = extract_jsonpath(raw_output, output_mapping.found_path) - if found_value is None: - return None - - # Determine kind - kind = output_mapping.found_kind_default - if output_mapping.found_kind_path: - extracted_kind = extract_jsonpath(raw_output, output_mapping.found_kind_path) - if extracted_kind in ("file", "url", "api", "config"): - kind = extracted_kind - - # Build FoundEvidence based on kind - found_str = str(found_value) - if kind == "file": - return FoundEvidence(path=found_str, kind="file") - elif kind == "url": - return FoundEvidence(url=found_str, kind="url") - elif kind == "api": - return FoundEvidence(api_endpoint=found_str, kind="api") - elif kind == "config": - return FoundEvidence(path=found_str, kind="config") - - return None - - -def normalize_scorecard_output( - raw_output: dict[str, Any], - check_name: str, -) -> CheckOutput: - """Convenience function to normalize Scorecard output. - - Scorecard has a known output format, so we can use a predefined mapping. - - Args: - raw_output: Scorecard JSON output - check_name: Name of the check to extract (e.g., "BranchProtection") - - Returns: - Normalized CheckOutput - """ - # Note: OutputMapping with JSONPath filter expressions ($.checks[?(@.name=='...')]) - # is not supported by extract_jsonpath, so we manually find the check below. - # Scorecard uses 0-10 scale with pass_threshold of 8.0. - - # Manually find the check since we don't support JSONPath filter expressions - checks = raw_output.get("checks", []) - target_check = None - for check in checks: - if check.get("name") == check_name: - target_check = check - break - - if target_check is None: - return CheckOutput( - status="inconclusive", - message=f"Check '{check_name}' not found in Scorecard output", - confidence=0.5, - evidence={"raw_output": raw_output}, - ) - - # Extract from the check - score = target_check.get("score", -1) - reason = target_check.get("reason", "") - - # Scorecard uses -1 for inconclusive - if score == -1: - return CheckOutput( - status="inconclusive", - message=reason or f"Scorecard returned inconclusive for {check_name}", - confidence=0.5, - evidence={"raw_output": raw_output, "score": score}, - ) - - # Apply threshold - status = "pass" if score >= 8 else "fail" - - return CheckOutput( - status=status, - message=reason or f"Scorecard {check_name}: score {score}/10", - confidence=1.0 if status != "inconclusive" else 0.5, - evidence={"raw_output": raw_output, "score": score}, - ) - - -__all__ = [ - "extract_jsonpath", - "normalize_tool_output", - "normalize_scorecard_output", -] diff --git a/packages/darnit/src/darnit/remediation/executor.py b/packages/darnit/src/darnit/remediation/executor.py index d1d79a99..9ad47d0e 100644 --- a/packages/darnit/src/darnit/remediation/executor.py +++ b/packages/darnit/src/darnit/remediation/executor.py @@ -33,7 +33,6 @@ from __future__ import annotations -import json import os import stat import subprocess @@ -139,39 +138,6 @@ class RemediationResult: needs_approval: list[str] = field(default_factory=list) approvals: list[Approval] = field(default_factory=list) - def to_markdown(self) -> str: - """Format result as markdown.""" - if self.dry_run: - prefix = "🔍 **DRY RUN**" - elif self.success: - prefix = "✅" - else: - prefix = "❌" - - lines = [f"{prefix} {self.message}"] - - if self.details: - lines.append("") - for key, value in self.details.items(): - if isinstance(value, list): - # Check for llm_enhance in handler results - for item in value: - if isinstance(item, dict) and "llm_enhance" in item: - enhance = item["llm_enhance"] - lines.append("") - lines.append(f"**AI Enhancement Available** for `{enhance.get('file_path', '')}`:") - lines.append(f"> {enhance.get('prompt', '')}") - lines.append(f"**{key}:**") - for item in value: - lines.append(f" - {item}") - elif isinstance(value, dict): - lines.append(f"**{key}:**") - lines.append(f"```json\n{json.dumps(value, indent=2)}\n```") - else: - lines.append(f"**{key}:** {value}") - - return "\n".join(lines) - class RemediationExecutor: """Executes declarative remediations from framework TOML configs. diff --git a/packages/darnit/src/darnit/sieve/models.py b/packages/darnit/src/darnit/sieve/models.py index 10d56460..a6babf07 100644 --- a/packages/darnit/src/darnit/sieve/models.py +++ b/packages/darnit/src/darnit/sieve/models.py @@ -7,7 +7,6 @@ if TYPE_CHECKING: from darnit.config.framework_schema import LocatorConfig from darnit.core.models import ExecutionContext - from darnit.locate import UnifiedLocator class VerificationPhase(Enum): @@ -43,8 +42,6 @@ class CheckContext: gathered_evidence: dict[str, Any] = field(default_factory=dict) # Locator integration - # UnifiedLocator instance for .project/-aware file resolution - locator: Optional["UnifiedLocator"] = None # LocatorConfig for this specific control (from TOML) locator_config: Optional["LocatorConfig"] = None diff --git a/packages/darnit/src/darnit/storage/__init__.py b/packages/darnit/src/darnit/storage/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/darnit/src/darnit/storage/backends.py b/packages/darnit/src/darnit/storage/backends.py deleted file mode 100644 index 1ab0fdf4..00000000 --- a/packages/darnit/src/darnit/storage/backends.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Pluggable storage backends for Darnit. - -Darnit needs to store three kinds of data: - 1. Attestations — signed in-toto statements from audits - 2. Project metadata — .project.yaml contents when we can't write to the repo - 3. Research results — reproducibility check outputs keyed by repo + commit - -This module defines a pluggable StorageBackend interface so teams can -swap in a real database (Archivista, SQL, etc.) without changing the -rest of the codebase. - -Configuration (a ``[storage]`` mapping passed by the caller): - [storage] - backend = "file" # file | archivista | memory - archivista_url = "http://localhost:8082" # only for archivista backend - -Usage: - from darnit.storage.backends import get_backend - - storage = get_backend(config) - storage.store_attestation(repo_url, commit, attestation_json) - storage.store_metadata(repo_url, metadata_dict) - storage.store_research_result(repo_url, commit, result_dict) -""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -from typing import Any - -from darnit.core.logging import get_logger - -logger = get_logger("storage.backends") - - -# ============================================================================= -# Data model -# ============================================================================= - -class StorageRecord: - """A single stored record with its key and value.""" - - def __init__(self, key: str, value: Any, record_type: str) -> None: - self.key = key - self.value = value - self.record_type = record_type # "attestation" | "metadata" | "research" - - def __repr__(self) -> str: - return f"StorageRecord(type={self.record_type}, key={self.key})" - - -# ============================================================================= -# Base interface -# ============================================================================= - -class StorageBackend: - """Base class for storage backends. - - All three kinds of data (attestations, metadata, research results) - go through the same three methods. The backend decides where and - how to store them. - """ - - def store_attestation( - self, - repo_url: str, - commit: str, - attestation: dict[str, Any], - ) -> str | None: - """Store a signed attestation. - - Args: - repo_url: The repository URL (e.g. https://github.com/org/repo) - commit: The git commit SHA the attestation is for - attestation: The attestation dict (in-toto format) - - Returns: - A reference ID or URL where the attestation was stored, or None on failure. - """ - raise NotImplementedError - - def retrieve_attestation( - self, - repo_url: str, - commit: str, - ) -> dict[str, Any] | None: - """Retrieve a stored attestation. - - Args: - repo_url: The repository URL - commit: The git commit SHA - - Returns: - The attestation dict, or None if not found. - """ - raise NotImplementedError - - def store_metadata( - self, - repo_url: str, - metadata: dict[str, Any], - ) -> bool: - """Store project metadata externally. - - Used when Darnit can't write .project.yaml back to the repo - (e.g. auditing a repo without write access). - - Args: - repo_url: The repository URL (used as the key) - metadata: The project metadata dict (.project.yaml contents) - - Returns: - True if stored successfully, False otherwise. - """ - raise NotImplementedError - - def retrieve_metadata( - self, - repo_url: str, - ) -> dict[str, Any] | None: - """Retrieve stored project metadata. - - Args: - repo_url: The repository URL - - Returns: - The metadata dict, or None if not found. - """ - raise NotImplementedError - - def store_research_result( - self, - repo_url: str, - commit: str, - result: dict[str, Any], - ) -> bool: - """Store a reproducibility research result. - - Args: - repo_url: The repository URL - commit: The git commit SHA the result is for - result: The research result dict (reproducibility check outputs) - - Returns: - True if stored successfully, False otherwise. - """ - raise NotImplementedError - - def retrieve_research_result( - self, - repo_url: str, - commit: str, - ) -> dict[str, Any] | None: - """Retrieve a stored research result. - - Args: - repo_url: The repository URL - commit: The git commit SHA - - Returns: - The result dict, or None if not found. - """ - raise NotImplementedError - - -# ============================================================================= -# File backend — default, stores in .darnit/ directory -# ============================================================================= - -class FileBackend(StorageBackend): - """Stores data as JSON files in a local directory. - - This is the default backend. It keeps the current behaviour of - storing everything locally, but now in a structured directory - rather than scattered files. - - Directory structure: - .darnit/ - attestations//.json - metadata/.json - research//.json - """ - - def __init__(self, base_dir: str = ".darnit") -> None: - self.base_dir = Path(base_dir) - - def _repo_slug(self, repo_url: str) -> str: - """Convert a repo URL to a safe directory name. - - Uses double underscore as separator to avoid collisions between - org/repo and org_repo — e.g. github.com/org/repo becomes - github.com__org__repo. - """ - return repo_url.replace("https://", "").replace("http://", "").replace("/", "__") - - def _ensure_dir(self, path: Path) -> None: - path.mkdir(parents=True, exist_ok=True) - - def store_attestation(self, repo_url: str, commit: str, attestation: dict[str, Any]) -> str | None: - slug = self._repo_slug(repo_url) - dir_path = self.base_dir / "attestations" / slug - self._ensure_dir(dir_path) - file_path = dir_path / f"{commit}.json" - try: - file_path.write_text(json.dumps(attestation, indent=2), encoding="utf-8") - logger.info(f"Stored attestation for {repo_url}@{commit[:8]} at {file_path}") - return str(file_path) - except OSError as e: - logger.error(f"Failed to store attestation: {e}") - return None - - def retrieve_attestation(self, repo_url: str, commit: str) -> dict[str, Any] | None: - slug = self._repo_slug(repo_url) - file_path = self.base_dir / "attestations" / slug / f"{commit}.json" - if not file_path.exists(): - return None - try: - return json.loads(file_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as e: - logger.error(f"Failed to retrieve attestation: {e}") - return None - - def store_metadata(self, repo_url: str, metadata: dict[str, Any]) -> bool: - slug = self._repo_slug(repo_url) - dir_path = self.base_dir / "metadata" - self._ensure_dir(dir_path) - file_path = dir_path / f"{slug}.json" - try: - file_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") - logger.info(f"Stored metadata for {repo_url} at {file_path}") - return True - except OSError as e: - logger.error(f"Failed to store metadata: {e}") - return False - - def retrieve_metadata(self, repo_url: str) -> dict[str, Any] | None: - slug = self._repo_slug(repo_url) - file_path = self.base_dir / "metadata" / f"{slug}.json" - if not file_path.exists(): - return None - try: - return json.loads(file_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as e: - logger.error(f"Failed to retrieve metadata: {e}") - return None - - def store_research_result(self, repo_url: str, commit: str, result: dict[str, Any]) -> bool: - slug = self._repo_slug(repo_url) - dir_path = self.base_dir / "research" / slug - self._ensure_dir(dir_path) - file_path = dir_path / f"{commit}.json" - try: - file_path.write_text(json.dumps(result, indent=2), encoding="utf-8") - logger.info(f"Stored research result for {repo_url}@{commit[:8]} at {file_path}") - return True - except OSError as e: - logger.error(f"Failed to store research result: {e}") - return False - - def retrieve_research_result(self, repo_url: str, commit: str) -> dict[str, Any] | None: - slug = self._repo_slug(repo_url) - file_path = self.base_dir / "research" / slug / f"{commit}.json" - if not file_path.exists(): - return None - try: - return json.loads(file_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as e: - logger.error(f"Failed to retrieve research result: {e}") - return None - - -# ============================================================================= -# Archivista backend — stores attestations via Archivista HTTP API -# ============================================================================= - -class ArchivistaBackend(StorageBackend): - """Stores attestations in an Archivista instance. - - Archivista is a graph and storage service for in-toto attestations. - It exposes two simple HTTP endpoints: - POST /upload — upload an attestation (body = attestation JSON) - GET /download/:gitoid — download by gitoid - - Metadata and research results fall back to the FileBackend since - Archivista only handles in-toto attestations. - - Config: - archivista_url = "http://localhost:8082" - """ - - def __init__(self, archivista_url: str = "http://localhost:8082", base_dir: str = ".darnit") -> None: - self.archivista_url = archivista_url.rstrip("/") - self._file_fallback = FileBackend(base_dir=base_dir) - # Maps repo_url+commit -> gitoid for later retrieval - self._gitoid_index: dict[str, str] = {} - - def store_attestation(self, repo_url: str, commit: str, attestation: dict[str, Any]) -> str | None: - try: - import urllib.request - payload = json.dumps(attestation).encode("utf-8") - req = urllib.request.Request( - f"{self.archivista_url}/upload", - data=payload, - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=30) as resp: - body = resp.read().decode("utf-8") - data = json.loads(body) - gitoid = data.get("gitoid", "unknown") - self._gitoid_index[f"{repo_url}@{commit}"] = gitoid - logger.info(f"Stored attestation in Archivista for {repo_url}@{commit[:8]}, gitoid={gitoid}") - return f"{self.archivista_url}/download/{gitoid}" - except Exception as e: - logger.error(f"Archivista upload failed: {e}, falling back to file storage") - return self._file_fallback.store_attestation(repo_url, commit, attestation) - - def retrieve_attestation(self, repo_url: str, commit: str) -> dict[str, Any] | None: - gitoid = self._gitoid_index.get(f"{repo_url}@{commit}") - if gitoid: - try: - import urllib.request - url = f"{self.archivista_url}/download/{gitoid}" - with urllib.request.urlopen(url, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) - except Exception as e: - logger.error(f"Archivista retrieval failed: {e}, falling back to file storage") - return self._file_fallback.retrieve_attestation(repo_url, commit) - - def store_metadata(self, repo_url: str, metadata: dict[str, Any]) -> bool: - # Archivista only handles attestations — metadata goes to file - return self._file_fallback.store_metadata(repo_url, metadata) - - def retrieve_metadata(self, repo_url: str) -> dict[str, Any] | None: - return self._file_fallback.retrieve_metadata(repo_url) - - def store_research_result(self, repo_url: str, commit: str, result: dict[str, Any]) -> bool: - return self._file_fallback.store_research_result(repo_url, commit, result) - - def retrieve_research_result(self, repo_url: str, commit: str) -> dict[str, Any] | None: - return self._file_fallback.retrieve_research_result(repo_url, commit) - - -# ============================================================================= -# Memory backend — for testing only -# ============================================================================= - -class MemoryBackend(StorageBackend): - """Stores everything in memory. For testing only — nothing persists.""" - - def __init__(self) -> None: - self._attestations: dict[str, dict[str, Any]] = {} - self._metadata: dict[str, dict[str, Any]] = {} - self._research: dict[str, dict[str, Any]] = {} - - def _key(self, repo_url: str, commit: str | None = None) -> str: - return f"{repo_url}@{commit}" if commit else repo_url - - def store_attestation(self, repo_url: str, commit: str, attestation: dict[str, Any]) -> str | None: - self._attestations[self._key(repo_url, commit)] = attestation - return f"memory://{self._key(repo_url, commit)}" - - def retrieve_attestation(self, repo_url: str, commit: str) -> dict[str, Any] | None: - return self._attestations.get(self._key(repo_url, commit)) - - def store_metadata(self, repo_url: str, metadata: dict[str, Any]) -> bool: - self._metadata[repo_url] = metadata - return True - - def retrieve_metadata(self, repo_url: str) -> dict[str, Any] | None: - return self._metadata.get(repo_url) - - def store_research_result(self, repo_url: str, commit: str, result: dict[str, Any]) -> bool: - self._research[self._key(repo_url, commit)] = result - return True - - def retrieve_research_result(self, repo_url: str, commit: str) -> dict[str, Any] | None: - return self._research.get(self._key(repo_url, commit)) - - -# ============================================================================= -# Factory -# ============================================================================= - -def get_backend(config: dict[str, Any] | None = None) -> StorageBackend: - """Return the configured storage backend. - - Args: - config: A ``[storage]`` mapping, e.g.: - {"backend": "archivista", "archivista_url": "http://localhost:8082"} - - Returns: - A StorageBackend instance ready to use. - """ - if not config: - return FileBackend() - - backend_name = config.get("backend", "file").lower() - base_dir = config.get("base_dir", ".darnit") - - if backend_name == "file": - return FileBackend(base_dir=base_dir) - elif backend_name == "archivista": - archivista_url = config.get("archivista_url", os.environ.get("ARCHIVISTA_URL", "http://localhost:8082")) - return ArchivistaBackend(archivista_url=archivista_url, base_dir=base_dir) - elif backend_name == "memory": - return MemoryBackend() - else: - logger.warning(f"Unknown storage backend '{backend_name}', defaulting to file") - return FileBackend(base_dir=base_dir) diff --git a/packages/darnit/src/darnit/tools/audit.py b/packages/darnit/src/darnit/tools/audit.py index ffb6a70f..6295bf6f 100644 --- a/packages/darnit/src/darnit/tools/audit.py +++ b/packages/darnit/src/darnit/tools/audit.py @@ -944,18 +944,6 @@ def run_sieve_audit( repository_values, ) - # Create UnifiedLocator for .project/-aware file resolution - locator = None - try: - from darnit.locate import UnifiedLocator - - locator = UnifiedLocator(local_path) - logger.debug("UnifiedLocator created for .project/ integration") - except ImportError: - logger.debug("UnifiedLocator not available, using direct file resolution") - except (RuntimeError, ValueError, TypeError, KeyError, AttributeError, OSError) as e: - logger.warning(f"Failed to create UnifiedLocator: {e}") - from darnit.sieve.models import SieveResult from darnit.sieve.orchestrator import evaluate_when_clause from darnit.trust.assertions import neutral_context @@ -1000,7 +988,6 @@ def run_sieve_audit( "description": spec.description, "full": spec.metadata.get("full", ""), }, - locator=locator, locator_config=spec.locator_config, project_context=dict(control_context), usable_project=dict(usable_project), diff --git a/packaging/README.md b/packaging/README.md index 2f207a91..1a87edd3 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -34,7 +34,7 @@ All releases are tag-driven. Tag patterns: `v` (stable) or `vrc ## Public package set -Authoritative list: [`packaging/pypi/public-packages.txt`](pypi/public-packages.txt). The release workflow refuses to publish anything not in that list. Internal packages (`darnit-example`, `darnit-testchecks`, `darnit-plugins`) live in `packages/` but are never published to PyPI. +Authoritative list: [`packaging/pypi/public-packages.txt`](pypi/public-packages.txt). The release workflow refuses to publish anything not in that list. Internal packages (`darnit-example`, `darnit-testchecks`) live in `packages/` but are never published to PyPI. ## External setup (one-time) diff --git a/tests/darnit/config/test_merger.py b/tests/darnit/config/test_merger.py index 5b4a0217..24287276 100644 --- a/tests/darnit/config/test_merger.py +++ b/tests/darnit/config/test_merger.py @@ -7,10 +7,8 @@ from darnit.config.control_loader import control_from_effective from darnit.config.framework_schema import ( - CheckConfig, ControlConfig, FrameworkConfig, - FrameworkDefaults, FrameworkMetadata, OnPassConfig, ) @@ -35,28 +33,63 @@ def test_framework_only(self): description="Test description", tags={"category": "test"}, ) - defaults = FrameworkDefaults() - result = merge_control("TEST-01", framework_control, defaults) + result = merge_control("TEST-01", framework_control) assert isinstance(result, EffectiveControl) assert result.name == "TestControl" assert result.level == 1 assert result.domain == "AC" - def test_framework_check_adapter(self): - """Test the framework's check adapter carries through.""" - framework_control = ControlConfig( - name="TestControl", - level=1, - domain="AC", - description="Test description", - check=CheckConfig(adapter="kusari"), + +class TestRemovedAdapterConfiguration: + """The adapter keys removed in #487 (framework-design Appendix C).""" + + METADATA = '''[metadata] +name = "removed-adapters" +display_name = "Removed Adapters" +version = "0.1.0" +''' + + def test_control_check_key_fails_loading(self, tmp_path): + path = tmp_path / "framework.toml" + path.write_text( + self.METADATA + + ''' +[controls."TEST-01"] +name = "TestControl" +description = "Test description" +check = { adapter = "kusari" } +''' + ) + + with pytest.raises(ValueError, match="control 'TEST-01' has unknown key 'check'"): + load_framework_config(path) + + def test_adapter_tables_still_load_and_are_not_read(self, tmp_path): + path = tmp_path / "framework.toml" + path.write_text( + self.METADATA + + ''' +[defaults] +check_adapter = "kusari" +remediation_adapter = "builtin" + +[adapters.kusari] +type = "command" +command = "kusari" + +[controls."TEST-01"] +name = "TestControl" +description = "Test description" +''' ) - result = merge_control("TEST-01", framework_control, FrameworkDefaults(check_adapter="builtin")) + effective = merge_configs(load_framework_config(path)) - assert result.check_adapter == "kusari" + assert list(effective.controls) == ["TEST-01"] + assert not hasattr(effective, "adapters") + assert not hasattr(effective.controls["TEST-01"], "check_adapter") class TestMergeConfigs: @@ -158,9 +191,8 @@ def _make_framework_control_with_gating(self) -> ControlConfig: def test_merge_control_preserves_gating_metadata(self): """All four gating fields must survive merge_control into EffectiveControl.""" framework_control = self._make_framework_control_with_gating() - defaults = FrameworkDefaults() - effective = merge_control("OSPS-QA-02.01", framework_control, defaults) + effective = merge_control("OSPS-QA-02.01", framework_control) assert effective.when == {"has_releases": True}, ( "merge_control dropped 'when' — when-gates will be silently ignored" @@ -179,9 +211,8 @@ def test_merge_control_preserves_gating_metadata(self): def test_control_from_effective_preserves_gating_metadata(self): """All four gating fields must survive control_from_effective into ControlSpec.metadata.""" framework_control = self._make_framework_control_with_gating() - defaults = FrameworkDefaults() - effective = merge_control("OSPS-QA-02.01", framework_control, defaults) + effective = merge_control("OSPS-QA-02.01", framework_control) spec = control_from_effective("OSPS-QA-02.01", effective) assert "when" in spec.metadata, ( @@ -212,9 +243,8 @@ def test_control_without_gating_metadata_is_unaffected(self): domain="BR", description="A control with no optional gating fields", ) - defaults = FrameworkDefaults() - effective = merge_control("OSPS-BR-01.01", framework_control, defaults) + effective = merge_control("OSPS-BR-01.01", framework_control) spec = control_from_effective("OSPS-BR-01.01", effective) # Optional fields default to None — must not appear in metadata diff --git a/tests/darnit/context_integrity/test_single_writer.py b/tests/darnit/context_integrity/test_single_writer.py index e9768b62..07ce5026 100644 --- a/tests/darnit/context_integrity/test_single_writer.py +++ b/tests/darnit/context_integrity/test_single_writer.py @@ -27,7 +27,6 @@ # Applied (non-dry-run) remediation. "darnit/src/darnit/remediation/executor.py", # File-location references, not context values. - "darnit/src/darnit/locate/locator.py", "darnit/src/darnit/config/resolver.py", # `darnit config migrate` moves .baseline.toml claims into `controls:` (feature 040). "darnit/src/darnit/config/operator/migrate.py", diff --git a/tests/darnit/context_integrity/test_user_files_survive.py b/tests/darnit/context_integrity/test_user_files_survive.py index 9796f543..d70d88d9 100644 --- a/tests/darnit/context_integrity/test_user_files_survive.py +++ b/tests/darnit/context_integrity/test_user_files_survive.py @@ -9,11 +9,9 @@ import pytest import yaml -from darnit.config.framework_schema import HandlerInvocation, LocatorConfig, ProjectUpdateRemediationConfig +from darnit.config.framework_schema import HandlerInvocation, ProjectUpdateRemediationConfig from darnit.config.framework_schema import RemediationConfig as RemediationSpec from darnit.config.resolver import update_config_after_file_create -from darnit.locate.locator import UnifiedLocator -from darnit.locate.models import FoundEvidence from darnit.remediation.executor import RemediationExecutor, plan_project_update from darnit.server.tools.project_data import confirm_project_data_impl from darnit_baseline.tools import audit_openssf_baseline @@ -187,14 +185,6 @@ def test_file_reference_sync_is_refused(self, invalid_repo: tuple[Path, str]) -> before = snapshot(repo) assert update_config_after_file_create(str(repo), "OSPS-VM-02.01", "SECURITY.md", POLICY) is False - assert ( - UnifiedLocator(str(repo)).sync_to_project( - "OSPS-VM-02.01", - FoundEvidence(path="SECURITY.md", kind="file"), - LocatorConfig(project_path="security.policy", discover=["SECURITY.md"], kind="file"), - ) - is False - ) assert_unchanged(repo, before) @@ -235,18 +225,6 @@ def test_file_reference_sync_changes_only_the_targeted_field(self, tmp_path: Pat assert (repo / ".project" / "project.yaml").read_text(encoding="utf-8") == HAND_WRITTEN_WITH_POLICY assert (repo / ".project" / "darnit.yaml").read_text(encoding="utf-8") == HAND_WRITTEN_DARNIT_YAML - def test_locator_sync_changes_only_the_targeted_field(self, tmp_path: Path) -> None: - repo = _hand_written(tmp_path) - - synced = UnifiedLocator(str(repo)).sync_to_project( - "OSPS-VM-02.01", - FoundEvidence(path="SECURITY.md", kind="file"), - LocatorConfig(project_path="security.policy", discover=["SECURITY.md"], kind="file"), - ) - - assert synced is True - assert (repo / ".project" / "project.yaml").read_text(encoding="utf-8") == HAND_WRITTEN_WITH_POLICY - def test_an_absent_project_directory_gets_only_what_is_needed(self, tmp_path: Path) -> None: assert _apply_update(tmp_path, {"security.policy.path": "SECURITY.md"}).changed diff --git a/tests/darnit/core/test_models.py b/tests/darnit/core/test_models.py index eab31f4d..4df253ee 100644 --- a/tests/darnit/core/test_models.py +++ b/tests/darnit/core/test_models.py @@ -6,7 +6,6 @@ AuditResult, CheckResult, CheckStatus, - RemediationResult, ) @@ -37,37 +36,6 @@ def test_to_dict(self): assert d["source"] == "sieve" -class TestRemediationResult: - """Tests for RemediationResult dataclass.""" - - @pytest.mark.unit - def test_successful_remediation(self): - """Test successful remediation result.""" - result = RemediationResult( - control_id="OSPS-VM-02.01", - success=True, - message="Created SECURITY.md", - changes_made=["Created SECURITY.md"], - ) - assert result.success is True - assert len(result.changes_made) == 1 - assert result.requires_manual_action is False - - @pytest.mark.unit - def test_manual_action_required(self): - """Test remediation requiring manual action.""" - result = RemediationResult( - control_id="OSPS-GV-01.01", - success=False, - message="Cannot automate governance structure", - requires_manual_action=True, - manual_steps=["Define governance roles", "Document in GOVERNANCE.md"], - ) - assert result.success is False - assert result.requires_manual_action is True - assert len(result.manual_steps) == 2 - - class TestAuditResult: """Tests for AuditResult dataclass.""" diff --git a/tests/darnit/core/test_module_path_policy.py b/tests/darnit/core/test_module_path_policy.py index 27032656..482b72d2 100644 --- a/tests/darnit/core/test_module_path_policy.py +++ b/tests/darnit/core/test_module_path_policy.py @@ -127,10 +127,10 @@ def test_allowed_module_without_the_attribute_raises_attribute_error(self) -> No class TestOnePolicy: def test_no_prefix_allowlist_remains(self) -> None: - from darnit.core.adapters import AdapterRegistry from darnit.core.registry import PluginRegistry + from darnit.server.registry import ToolRegistry - for owner in (HandlerRegistry, PluginRegistry, AdapterRegistry): + for owner in (HandlerRegistry, PluginRegistry, ToolRegistry): assert not hasattr(owner, "ALLOWED_MODULE_PREFIXES") offenders = [p for p in CORE_SRC.rglob("*.py") if "ALLOWED_MODULE_PREFIXES" in p.read_text(encoding="utf-8")] assert offenders == [] @@ -142,12 +142,3 @@ def test_core_imports_by_name_in_one_place(self) -> None: if "import_module(" in p.read_text(encoding="utf-8") ] assert sites == ["core/handlers.py"] - - def test_adapter_loaders_use_the_policy(self) -> None: - from darnit.core.adapters import AdapterRegistry, CheckAdapter - from darnit.core.registry import PluginRegistry - - with pytest.raises(HandlerImportRefused): - AdapterRegistry()._load_python_adapter("x", {"module": "os", "class": "system"}, CheckAdapter) - with pytest.raises(HandlerImportRefused): - PluginRegistry()._load_python_adapter("x", {"module": "os", "class": "system"}) diff --git a/tests/darnit/filtering/test_filters.py b/tests/darnit/filtering/test_filters.py index 3c8250da..f7ed8bf4 100644 --- a/tests/darnit/filtering/test_filters.py +++ b/tests/darnit/filtering/test_filters.py @@ -30,7 +30,6 @@ class MockControl: domain: str | None = None name: str = "Test Control" metadata: dict[str, Any] = None - check_adapter: str = "builtin" def __post_init__(self): if self.metadata is None: @@ -320,20 +319,6 @@ def test_match_severity(self): f = ControlFilter(field="severity", operator=">=", value=8.0) assert matches_filter(control, f) is False - def test_match_adapter(self): - """Test matching adapter.""" - control = MockControl( - control_id="OSPS-AC-01.01", - level=1, - check_adapter="scorecard", - ) - - f = ControlFilter(field="adapter", operator="=", value="scorecard") - assert matches_filter(control, f) is True - - f = ControlFilter(field="adapter", operator="=", value="builtin") - assert matches_filter(control, f) is False - def test_unknown_field(self): """Test unknown field excludes control (tag doesn't exist).""" control = MockControl(control_id="OSPS-AC-01.01", level=1) diff --git a/tests/darnit/fixtures/composite/_sources/mock-source-a-variant.toml b/tests/darnit/fixtures/composite/_sources/mock-source-a-variant.toml index 97ce7142..918580a8 100644 --- a/tests/darnit/fixtures/composite/_sources/mock-source-a-variant.toml +++ b/tests/darnit/fixtures/composite/_sources/mock-source-a-variant.toml @@ -8,10 +8,6 @@ display_name = "Mock Source A Variant (test fixture)" version = "1.0.0" spec_version = "mock-variant v1" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - [controls."MOCK-AC-01.01"] name = "MockAccessControlOne_Variant" description = "VARIANT: a-variant's redefinition of MOCK-AC-01.01." diff --git a/tests/darnit/fixtures/composite/_sources/mock-source-a.toml b/tests/darnit/fixtures/composite/_sources/mock-source-a.toml index e7cee068..1ba754de 100644 --- a/tests/darnit/fixtures/composite/_sources/mock-source-a.toml +++ b/tests/darnit/fixtures/composite/_sources/mock-source-a.toml @@ -10,10 +10,6 @@ display_name = "Mock Source A (test fixture)" version = "1.5.0" spec_version = "mock v1" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - [controls."MOCK-AC-01.01"] name = "MockAccessControlOne" description = "Mock AC control at level 1, domain AC. Sources tests." diff --git a/tests/darnit/fixtures/composite/_sources/mock-source-b.toml b/tests/darnit/fixtures/composite/_sources/mock-source-b.toml index c3d7694b..9b01c8af 100644 --- a/tests/darnit/fixtures/composite/_sources/mock-source-b.toml +++ b/tests/darnit/fixtures/composite/_sources/mock-source-b.toml @@ -9,10 +9,6 @@ display_name = "Mock Source B (test fixture)" version = "0.9.0" spec_version = "mock v1" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - [controls."MOCK-B-01.01"] name = "MockBOne" description = "Mock B control at level 1." diff --git a/tests/darnit/fixtures/composite/_sources/mock-source-c-leaf.toml b/tests/darnit/fixtures/composite/_sources/mock-source-c-leaf.toml index d982c0eb..c398ae9a 100644 --- a/tests/darnit/fixtures/composite/_sources/mock-source-c-leaf.toml +++ b/tests/darnit/fixtures/composite/_sources/mock-source-c-leaf.toml @@ -7,10 +7,6 @@ display_name = "Mock Source C (leaf, test fixture)" version = "1.0.0" spec_version = "mock v1" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - [controls."LEAF-01.01"] name = "MockLeafOne" description = "Leaf control 1 — the ultimate non-composite origin." diff --git a/tests/darnit/fixtures/composite/_sources/mock-source-mid-composite.toml b/tests/darnit/fixtures/composite/_sources/mock-source-mid-composite.toml index e7dafe70..36735813 100644 --- a/tests/darnit/fixtures/composite/_sources/mock-source-mid-composite.toml +++ b/tests/darnit/fixtures/composite/_sources/mock-source-mid-composite.toml @@ -8,10 +8,6 @@ display_name = "Mock Mid Composite (test fixture)" version = "1.0.0" spec_version = "mock v1" -[defaults] -check_adapter = "builtin" -remediation_adapter = "builtin" - [[compose]] source = "mock-source-c-leaf" include_all = true diff --git a/tests/darnit/locate/test_locator.py b/tests/darnit/locate/test_locator.py deleted file mode 100644 index 940be224..00000000 --- a/tests/darnit/locate/test_locator.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Tests for the UnifiedLocator class.""" - -import pytest - -from darnit.config.framework_schema import LocatorConfig -from darnit.config.loader import clear_config_cache, save_project_config -from darnit.config.schema import PathRef, SecurityConfig, create_minimal_config -from darnit.locate import FoundEvidence, LocateResult, UnifiedLocator - - -@pytest.fixture(autouse=True) -def clear_cache(): - """Clear config cache before each test.""" - clear_config_cache() - yield - clear_config_cache() - - -class TestUnifiedLocatorLocate: - """Tests for UnifiedLocator.locate().""" - - def test_locate_via_config_reference(self, tmp_path): - """Test locating file via .project/ reference.""" - # Create .project/ config with a reference - project_dir = tmp_path / ".project" - project_dir.mkdir() - - config = create_minimal_config(name="test", project_type="software") - config.security = SecurityConfig(policy=PathRef(path="docs/security/SECURITY.md")) - save_project_config(config, str(tmp_path)) - - # Create the referenced file - docs_dir = tmp_path / "docs" / "security" - docs_dir.mkdir(parents=True) - (docs_dir / "SECURITY.md").write_text("# Security Policy") - - # Create locator and config - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md", ".github/SECURITY.md"], - kind="file", - ) - - # Locate - should find via config - result = locator.locate("OSPS-VM-01.01", locator_config) - - assert result.success - assert result.source == "config" - assert result.found.path == "docs/security/SECURITY.md" - assert not result.needs_sync # Already in config - - def test_locate_via_discovery(self, tmp_path): - """Test locating file via pattern discovery.""" - # Create SECURITY.md at root (no .project/ config) - (tmp_path / "SECURITY.md").write_text("# Security Policy") - - # Create locator and config - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md", ".github/SECURITY.md"], - kind="file", - ) - - # Locate - should find via discovery - result = locator.locate("OSPS-VM-01.01", locator_config) - - assert result.success - assert result.source == "discovered" - assert result.found.path == "SECURITY.md" - assert result.needs_sync # Should be synced to config - - def test_locate_config_reference_missing_file(self, tmp_path): - """Test fallback when config reference points to non-existent file.""" - # Create .project/ config with a reference to non-existent file - project_dir = tmp_path / ".project" - project_dir.mkdir() - - config = create_minimal_config(name="test", project_type="software") - config.security = SecurityConfig(policy=PathRef(path="docs/MISSING.md")) - save_project_config(config, str(tmp_path)) - - # Create a SECURITY.md at root as fallback - (tmp_path / "SECURITY.md").write_text("# Security Policy") - - # Create locator and config - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md", ".github/SECURITY.md"], - kind="file", - ) - - # Locate - should fall back to discovery - result = locator.locate("OSPS-VM-01.01", locator_config) - - assert result.success - assert result.source == "discovered" - assert result.found.path == "SECURITY.md" - - def test_locate_not_found(self, tmp_path): - """Test when file is not found anywhere.""" - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md", ".github/SECURITY.md"], - kind="file", - ) - - result = locator.locate("OSPS-VM-01.01", locator_config) - - assert not result.success - assert result.source == "none" - assert result.found is None - - def test_locate_github_security_md(self, tmp_path): - """Test locating SECURITY.md in .github directory.""" - github_dir = tmp_path / ".github" - github_dir.mkdir() - (github_dir / "SECURITY.md").write_text("# Security Policy") - - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md", ".github/SECURITY.md"], - kind="file", - ) - - result = locator.locate("OSPS-VM-01.01", locator_config) - - assert result.success - assert result.found.path == ".github/SECURITY.md" - - -class TestUnifiedLocatorSync: - """Tests for UnifiedLocator.sync_to_project().""" - - def test_sync_creates_config(self, tmp_path): - """Test that sync creates .project/ if it doesn't exist.""" - # Create a file but no .project/ - (tmp_path / "SECURITY.md").write_text("# Security Policy") - - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md"], - kind="file", - ) - - found = FoundEvidence(path="SECURITY.md", kind="file") - - result = locator.sync_to_project("OSPS-VM-01.01", found, locator_config) - - assert result is True - assert (tmp_path / ".project" / "project.yaml").exists() - - def test_sync_updates_existing_config(self, tmp_path): - """Test that sync updates existing .project/ config.""" - # Create .project/ config without security reference - project_dir = tmp_path / ".project" - project_dir.mkdir() - - config = create_minimal_config(name="test-project", project_type="software") - save_project_config(config, str(tmp_path)) - - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md"], - kind="file", - ) - - found = FoundEvidence(path="SECURITY.md", kind="file") - - result = locator.sync_to_project("OSPS-VM-01.01", found, locator_config) - - assert result is True - - # Reload and verify - clear_config_cache() - from darnit.config.loader import load_project_config - - config = load_project_config(str(tmp_path)) - assert config.security.policy.path == "SECURITY.md" - - def test_sync_skips_if_already_set(self, tmp_path): - """Test that sync is skipped if reference already matches.""" - # Create .project/ config with existing reference - project_dir = tmp_path / ".project" - project_dir.mkdir() - - config = create_minimal_config(name="test", project_type="software") - config.security = SecurityConfig(policy=PathRef(path="SECURITY.md")) - save_project_config(config, str(tmp_path)) - - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md"], - kind="file", - ) - - found = FoundEvidence(path="SECURITY.md", kind="file") - - result = locator.sync_to_project("OSPS-VM-01.01", found, locator_config) - - # Should return False since no change was needed - assert result is False - - -class TestUnifiedLocatorLocateAndSync: - """Tests for UnifiedLocator.locate_and_sync().""" - - def test_locate_and_auto_sync(self, tmp_path): - """Test that locate_and_sync automatically syncs discovered files.""" - # Create a file but no .project/ - (tmp_path / "SECURITY.md").write_text("# Security Policy") - - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md"], - kind="file", - ) - - result = locator.locate_and_sync("OSPS-VM-01.01", locator_config, auto_sync=True) - - assert result.success - assert result.found.path == "SECURITY.md" - - # Verify config was created - assert (tmp_path / ".project" / "project.yaml").exists() - - def test_locate_and_sync_disabled(self, tmp_path): - """Test that locate_and_sync respects auto_sync=False.""" - # Create a file but no .project/ - (tmp_path / "SECURITY.md").write_text("# Security Policy") - - locator = UnifiedLocator(str(tmp_path)) - locator_config = LocatorConfig( - project_path="security.policy", - discover=["SECURITY.md"], - kind="file", - ) - - result = locator.locate_and_sync("OSPS-VM-01.01", locator_config, auto_sync=False) - - assert result.success - assert result.needs_sync # Should still indicate sync is recommended - - # Verify config was NOT created - assert not (tmp_path / ".project" / "project.yaml").exists() - - -class TestLocateResult: - """Tests for LocateResult properties.""" - - def test_success_property(self): - """Test success property.""" - result = LocateResult(found=FoundEvidence(path="file.txt")) - assert result.success is True - - result = LocateResult(found=None) - assert result.success is False - - def test_needs_sync_property(self): - """Test needs_sync property.""" - # Discovered and sync recommended - result = LocateResult( - found=FoundEvidence(path="file.txt"), - source="discovered", - sync_recommended=True, - ) - assert result.needs_sync is True - - # Config reference - no sync needed - result = LocateResult( - found=FoundEvidence(path="file.txt"), - source="config", - sync_recommended=False, - ) - assert result.needs_sync is False - - -class TestFoundEvidence: - """Tests for FoundEvidence.""" - - def test_location_property_file(self): - """Test location property for file.""" - fe = FoundEvidence(path="SECURITY.md", kind="file") - assert fe.location == "SECURITY.md" - - def test_location_property_url(self): - """Test location property for URL.""" - fe = FoundEvidence(url="https://docs.example.com/security", kind="url") - assert fe.location == "https://docs.example.com/security" - - def test_location_property_api(self): - """Test location property for API endpoint.""" - fe = FoundEvidence(api_endpoint="/repos/owner/repo/branches/main/protection", kind="api") - assert fe.location == "/repos/owner/repo/branches/main/protection" diff --git a/tests/darnit/locate/test_normalizer.py b/tests/darnit/locate/test_normalizer.py deleted file mode 100644 index de3fa259..00000000 --- a/tests/darnit/locate/test_normalizer.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Tests for the tool output normalizer.""" - - -from darnit.config.framework_schema import OutputMapping -from darnit.locate import ( - CheckOutput, - FoundEvidence, - extract_jsonpath, - normalize_scorecard_output, - normalize_tool_output, -) - - -class TestExtractJsonpath: - """Tests for extract_jsonpath().""" - - def test_simple_field_access(self): - """Test extracting a simple field.""" - data = {"name": "test", "value": 42} - assert extract_jsonpath(data, "$.name") == "test" - assert extract_jsonpath(data, "$.value") == 42 - - def test_nested_field_access(self): - """Test extracting nested fields.""" - data = { - "checks": { - "BranchProtection": { - "pass": True, - "score": 8, - } - } - } - assert extract_jsonpath(data, "$.checks.BranchProtection.pass") is True - assert extract_jsonpath(data, "$.checks.BranchProtection.score") == 8 - - def test_array_index_access(self): - """Test extracting from arrays.""" - data = { - "items": [ - {"name": "first"}, - {"name": "second"}, - {"name": "third"}, - ] - } - assert extract_jsonpath(data, "$.items[0].name") == "first" - assert extract_jsonpath(data, "$.items[1].name") == "second" - assert extract_jsonpath(data, "$.items[2].name") == "third" - - def test_array_out_of_bounds(self): - """Test array access out of bounds returns None.""" - data = {"items": [1, 2, 3]} - assert extract_jsonpath(data, "$.items[10]") is None - - def test_missing_field_returns_none(self): - """Test missing field returns None.""" - data = {"name": "test"} - assert extract_jsonpath(data, "$.missing") is None - assert extract_jsonpath(data, "$.nested.missing") is None - - def test_path_without_dollar_sign(self): - """Test path without leading $.""" - data = {"name": "test"} - assert extract_jsonpath(data, "name") == "test" - assert extract_jsonpath(data, ".name") == "test" - - def test_none_data_returns_none(self): - """Test None data returns None.""" - assert extract_jsonpath(None, "$.name") is None - - def test_none_path_returns_none(self): - """Test None path returns None.""" - assert extract_jsonpath({"name": "test"}, None) is None - - def test_complex_nested_path(self): - """Test complex nested path with arrays and objects.""" - data = { - "results": [ - { - "checks": [ - {"id": "check1", "status": "pass"}, - {"id": "check2", "status": "fail"}, - ] - } - ] - } - assert extract_jsonpath(data, "$.results[0].checks[0].status") == "pass" - assert extract_jsonpath(data, "$.results[0].checks[1].id") == "check2" - - -class TestNormalizeToolOutput: - """Tests for normalize_tool_output().""" - - def test_basic_pass_status(self): - """Test normalizing output with pass status.""" - raw = {"result": True} - mapping = OutputMapping(status_path="$.result") - - output = normalize_tool_output(raw, mapping) - - assert output.status == "pass" - assert output.confidence == 1.0 - - def test_basic_fail_status(self): - """Test normalizing output with fail status.""" - raw = {"result": False} - mapping = OutputMapping(status_path="$.result") - - output = normalize_tool_output(raw, mapping) - - assert output.status == "fail" - assert output.confidence == 1.0 - - def test_string_status_values(self): - """Test normalizing string status values.""" - mapping = OutputMapping(status_path="$.status") - - # Pass variants - for status in ["pass", "passed", "success", "true", "ok"]: - raw = {"status": status} - output = normalize_tool_output(raw, mapping) - assert output.status == "pass", f"Failed for '{status}'" - - # Fail variants - for status in ["fail", "failed", "failure", "false"]: - raw = {"status": status} - output = normalize_tool_output(raw, mapping) - assert output.status == "fail", f"Failed for '{status}'" - - def test_score_with_threshold(self): - """Test score-based status with threshold.""" - raw = {"score": 8} - mapping = OutputMapping( - status_path="$.status", # Not present - score_path="$.score", - pass_threshold=7.0, - ) - - output = normalize_tool_output(raw, mapping) - assert output.status == "pass" - - # Below threshold - raw = {"score": 5} - output = normalize_tool_output(raw, mapping) - assert output.status == "fail" - - def test_message_extraction(self): - """Test message extraction from output.""" - raw = {"status": "pass", "reason": "All checks passed"} - mapping = OutputMapping( - status_path="$.status", - message_path="$.reason", - ) - - output = normalize_tool_output(raw, mapping) - assert output.message == "All checks passed" - - def test_default_message_generation(self): - """Test default message generation when no message_path.""" - mapping = OutputMapping(status_path="$.status") - - raw = {"status": "pass"} - output = normalize_tool_output(raw, mapping) - assert output.message == "Check passed" - - raw = {"status": "fail"} - output = normalize_tool_output(raw, mapping) - assert output.message == "Check failed" - - def test_found_evidence_extraction(self): - """Test extracting found evidence from output.""" - raw = { - "status": "pass", - "file": "SECURITY.md", - } - mapping = OutputMapping( - status_path="$.status", - found_path="$.file", - found_kind_default="file", - ) - - output = normalize_tool_output(raw, mapping) - assert output.found is not None - assert output.found.path == "SECURITY.md" - assert output.found.kind == "file" - - def test_found_evidence_with_kind(self): - """Test extracting found evidence with kind from output.""" - raw = { - "status": "pass", - "location": "https://docs.example.com/security", - "type": "url", - } - mapping = OutputMapping( - status_path="$.status", - found_path="$.location", - found_kind_path="$.type", - found_kind_default="file", - ) - - output = normalize_tool_output(raw, mapping) - assert output.found is not None - assert output.found.url == "https://docs.example.com/security" - assert output.found.kind == "url" - - def test_json_string_input(self): - """Test normalizing JSON string input.""" - raw = '{"status": "pass", "message": "OK"}' - mapping = OutputMapping( - status_path="$.status", - message_path="$.message", - ) - - output = normalize_tool_output(raw, mapping) - assert output.status == "pass" - assert output.message == "OK" - - def test_invalid_json_string(self): - """Test handling invalid JSON string.""" - raw = "not valid json" - mapping = OutputMapping(status_path="$.status") - - output = normalize_tool_output(raw, mapping) - assert output.status == "error" - assert "Failed to parse" in output.message - - def test_non_dict_input(self): - """Test handling non-dict input.""" - raw = ["a", "list"] - mapping = OutputMapping(status_path="$.status") - - output = normalize_tool_output(raw, mapping) - assert output.status == "error" - assert "Expected dict" in output.message - - def test_no_status_path_inconclusive(self): - """Test that missing status_path returns inconclusive.""" - raw = {"data": "value"} - mapping = OutputMapping() # No status_path - - output = normalize_tool_output(raw, mapping) - assert output.status == "inconclusive" - - def test_evidence_preserved(self): - """Test that raw output is preserved in evidence.""" - raw = {"status": "pass", "extra": "data"} - mapping = OutputMapping(status_path="$.status") - - output = normalize_tool_output(raw, mapping) - assert output.evidence["raw_output"] == raw - - -class TestNormalizeScorecardOutput: - """Tests for normalize_scorecard_output().""" - - def test_passing_score(self): - """Test Scorecard output with passing score.""" - raw = { - "checks": [ - { - "name": "BranchProtection", - "score": 9, - "reason": "Branch protection is enabled", - } - ] - } - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "pass" - assert output.confidence == 1.0 - assert "Branch protection is enabled" in output.message - assert output.evidence["score"] == 9 - - def test_failing_score(self): - """Test Scorecard output with failing score.""" - raw = { - "checks": [ - { - "name": "BranchProtection", - "score": 5, - "reason": "Branch protection is partially configured", - } - ] - } - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "fail" - assert output.evidence["score"] == 5 - - def test_inconclusive_score(self): - """Test Scorecard output with inconclusive score (-1).""" - raw = { - "checks": [ - { - "name": "BranchProtection", - "score": -1, - "reason": "Could not determine branch protection status", - } - ] - } - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "inconclusive" - assert output.confidence == 0.5 - - def test_check_not_found(self): - """Test when requested check is not in output.""" - raw = { - "checks": [ - { - "name": "OtherCheck", - "score": 10, - } - ] - } - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "inconclusive" - assert "not found" in output.message - - def test_multiple_checks(self): - """Test selecting correct check from multiple.""" - raw = { - "checks": [ - {"name": "Check1", "score": 5, "reason": "Reason 1"}, - {"name": "BranchProtection", "score": 9, "reason": "Reason 2"}, - {"name": "Check3", "score": 3, "reason": "Reason 3"}, - ] - } - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "pass" - assert output.evidence["score"] == 9 - assert "Reason 2" in output.message - - def test_empty_checks_list(self): - """Test with empty checks list.""" - raw = {"checks": []} - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "inconclusive" - assert "not found" in output.message - - def test_missing_checks_key(self): - """Test with missing checks key.""" - raw = {"other": "data"} - - output = normalize_scorecard_output(raw, "BranchProtection") - - assert output.status == "inconclusive" - - def test_exact_threshold_score(self): - """Test score exactly at threshold (8).""" - raw = { - "checks": [ - {"name": "TestCheck", "score": 8, "reason": "Score is 8"} - ] - } - - output = normalize_scorecard_output(raw, "TestCheck") - - assert output.status == "pass" # 8 >= 8 - - def test_just_below_threshold(self): - """Test score just below threshold.""" - raw = { - "checks": [ - {"name": "TestCheck", "score": 7, "reason": "Score is 7"} - ] - } - - output = normalize_scorecard_output(raw, "TestCheck") - - assert output.status == "fail" # 7 < 8 - - -class TestCheckOutputModel: - """Tests for CheckOutput model from models.py.""" - - def test_create_with_all_fields(self): - """Test creating CheckOutput with all fields.""" - output = CheckOutput( - status="pass", - message="All checks passed", - confidence=0.95, - found=FoundEvidence(path="file.txt"), - evidence={"key": "value"}, - issues=["issue1"], - suggestions=["suggestion1"], - ) - - assert output.status == "pass" - assert output.message == "All checks passed" - assert output.confidence == 0.95 - assert output.found.path == "file.txt" - assert output.evidence == {"key": "value"} - assert output.issues == ["issue1"] - assert output.suggestions == ["suggestion1"] - - def test_create_minimal(self): - """Test creating CheckOutput with minimal fields.""" - output = CheckOutput( - status="fail", - message="Check failed", - ) - - assert output.status == "fail" - assert output.message == "Check failed" - assert output.confidence == 1.0 # Default - assert output.found is None - assert output.evidence == {} - assert output.issues == [] - assert output.suggestions == [] diff --git a/tests/darnit/remediation/test_executor.py b/tests/darnit/remediation/test_executor.py index 4a55696e..f653862f 100644 --- a/tests/darnit/remediation/test_executor.py +++ b/tests/darnit/remediation/test_executor.py @@ -41,20 +41,6 @@ def test_dry_run_result(self): assert result.dry_run assert "Would" in result.message - def test_to_markdown(self): - """Test markdown formatting.""" - result = RemediationResult( - success=True, - message="Created file", - control_id="TEST-01", - remediation_type="file_create", - dry_run=False, - details={"path": "test.md"}, - ) - md = result.to_markdown() - assert "✅" in md - assert "Created file" in md - class TestRemediationExecutor: """Test RemediationExecutor class.""" @@ -747,34 +733,6 @@ def test_llm_enhance_not_propagated_in_dry_run(self): handlers = result.details["handlers"] assert "llm_enhance" not in handlers[0] - def test_llm_enhance_in_markdown_output(self): - """to_markdown() should mention AI Enhancement when llm_enhance is present.""" - result = RemediationResult( - success=True, - message="Executed 1 remediation handler(s)", - control_id="TEST-01", - remediation_type="handler_pipeline", - dry_run=False, - details={ - "handlers": [ - { - "handler": "file_create", - "status": "pass", - "message": "Created file: README.md", - "llm_enhance": { - "prompt": "Customize this README.", - "file_path": "README.md", - }, - } - ] - }, - ) - - md = result.to_markdown() - assert "AI Enhancement Available" in md - assert "README.md" in md - assert "Customize this README." in md - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/darnit/sieve/test_handler_architecture.py b/tests/darnit/sieve/test_handler_architecture.py index d66b91e9..6ef265f4 100644 --- a/tests/darnit/sieve/test_handler_architecture.py +++ b/tests/darnit/sieve/test_handler_architecture.py @@ -790,7 +790,6 @@ def test_use_locator_resolved_through_effective_config(self): """ from darnit.config.framework_schema import ( ControlConfig, - FrameworkDefaults, HandlerInvocation, LocatorConfig, ) @@ -809,9 +808,8 @@ def test_use_locator_resolved_through_effective_config(self): kind="file", ), ) - defaults = FrameworkDefaults() - effective = merge_control("T-01", control, defaults) + effective = merge_control("T-01", control) # The passes_config should have files resolved from locator assert effective.passes_config is not None diff --git a/tests/darnit/sieve/test_models.py b/tests/darnit/sieve/test_models.py index f56845be..6a73b0bb 100644 --- a/tests/darnit/sieve/test_models.py +++ b/tests/darnit/sieve/test_models.py @@ -55,7 +55,6 @@ def test_construction_and_defaults(self): assert context.control_metadata == {} assert context.gathered_evidence == {} assert context.project_context == {} - assert context.locator is None assert context.locator_config is None @pytest.mark.unit diff --git a/tests/darnit/storage/__init__.py b/tests/darnit/storage/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/darnit/storage/test_backends.py b/tests/darnit/storage/test_backends.py deleted file mode 100644 index f50a2e57..00000000 --- a/tests/darnit/storage/test_backends.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Tests for storage backends.""" - -from pathlib import Path - -import pytest - -from darnit.storage.backends import ( - ArchivistaBackend, - FileBackend, - MemoryBackend, - get_backend, -) - -SAMPLE_ATTESTATION = { - "_type": "https://in-toto.io/Statement/v1", - "subject": [{"name": "git+https://github.com/org/repo", "digest": {"gitCommit": "abc123"}}], - "predicateType": "https://openssf.org/baseline/assessment/v1", - "predicate": {"result": "pass"}, -} - -SAMPLE_METADATA = { - "name": "my-project", - "ci": {"provider": "github_actions"}, -} - -SAMPLE_RESULT = { - "control": "RE-01.01", - "status": "PASS", - "details": "uv.lock found", -} - -REPO_URL = "https://github.com/org/repo" -COMMIT = "abc123def456" - -@pytest.mark.unit -class TestMemoryBackend: - """Tests for MemoryBackend — simplest to test, no filesystem.""" - - def setup_method(self): - self.backend = MemoryBackend() - - def test_store_and_retrieve_attestation(self): - ref = self.backend.store_attestation(REPO_URL, COMMIT, SAMPLE_ATTESTATION) - assert ref is not None - result = self.backend.retrieve_attestation(REPO_URL, COMMIT) - assert result == SAMPLE_ATTESTATION - - def test_retrieve_missing_attestation_returns_none(self): - result = self.backend.retrieve_attestation(REPO_URL, "nonexistent") - assert result is None - - def test_store_and_retrieve_metadata(self): - success = self.backend.store_metadata(REPO_URL, SAMPLE_METADATA) - assert success is True - result = self.backend.retrieve_metadata(REPO_URL) - assert result == SAMPLE_METADATA - - def test_retrieve_missing_metadata_returns_none(self): - result = self.backend.retrieve_metadata("https://github.com/nobody/nothing") - assert result is None - - def test_store_and_retrieve_research_result(self): - success = self.backend.store_research_result(REPO_URL, COMMIT, SAMPLE_RESULT) - assert success is True - result = self.backend.retrieve_research_result(REPO_URL, COMMIT) - assert result == SAMPLE_RESULT - - def test_retrieve_missing_research_result_returns_none(self): - result = self.backend.retrieve_research_result(REPO_URL, "nonexistent") - assert result is None - - def test_different_commits_are_independent(self): - self.backend.store_attestation(REPO_URL, "commit1", {"data": "first"}) - self.backend.store_attestation(REPO_URL, "commit2", {"data": "second"}) - assert self.backend.retrieve_attestation(REPO_URL, "commit1") == {"data": "first"} - assert self.backend.retrieve_attestation(REPO_URL, "commit2") == {"data": "second"} - - def test_different_repos_are_independent(self): - repo2 = "https://github.com/org/other-repo" - self.backend.store_metadata(REPO_URL, {"name": "repo1"}) - self.backend.store_metadata(repo2, {"name": "repo2"}) - assert self.backend.retrieve_metadata(REPO_URL)["name"] == "repo1" - assert self.backend.retrieve_metadata(repo2)["name"] == "repo2" - -@pytest.mark.unit -class TestFileBackend: - """Tests for FileBackend — uses tmp_path, no real filesystem pollution.""" - - def test_store_and_retrieve_attestation(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - ref = backend.store_attestation(REPO_URL, COMMIT, SAMPLE_ATTESTATION) - assert ref is not None - assert Path(ref).exists() - result = backend.retrieve_attestation(REPO_URL, COMMIT) - assert result == SAMPLE_ATTESTATION - - def test_retrieve_missing_attestation_returns_none(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - result = backend.retrieve_attestation(REPO_URL, "nonexistent") - assert result is None - - def test_store_and_retrieve_metadata(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - success = backend.store_metadata(REPO_URL, SAMPLE_METADATA) - assert success is True - result = backend.retrieve_metadata(REPO_URL) - assert result == SAMPLE_METADATA - - def test_retrieve_missing_metadata_returns_none(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - result = backend.retrieve_metadata("https://github.com/nobody/nothing") - assert result is None - - def test_store_and_retrieve_research_result(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - success = backend.store_research_result(REPO_URL, COMMIT, SAMPLE_RESULT) - assert success is True - result = backend.retrieve_research_result(REPO_URL, COMMIT) - assert result == SAMPLE_RESULT - - def test_creates_directories_automatically(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / "deep" / "nested" / ".darnit")) - backend.store_attestation(REPO_URL, COMMIT, SAMPLE_ATTESTATION) - result = backend.retrieve_attestation(REPO_URL, COMMIT) - assert result == SAMPLE_ATTESTATION - - def test_repo_slug_handles_special_chars(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - backend.store_metadata("https://github.com/my-org/my-repo", {"name": "test"}) - result = backend.retrieve_metadata("https://github.com/my-org/my-repo") - assert result == {"name": "test"} - - def test_different_commits_stored_separately(self, tmp_path: Path): - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - backend.store_attestation(REPO_URL, "commit1", {"data": "first"}) - backend.store_attestation(REPO_URL, "commit2", {"data": "second"}) - assert backend.retrieve_attestation(REPO_URL, "commit1") == {"data": "first"} - assert backend.retrieve_attestation(REPO_URL, "commit2") == {"data": "second"} - - def test_repo_slug_no_collision_between_slash_and_underscore(self, tmp_path: Path) -> None: - """org/repo and org_repo should produce different slugs.""" - backend = FileBackend(base_dir=str(tmp_path / ".darnit")) - backend.store_metadata("https://github.com/org/repo", {"name": "slash"}) - backend.store_metadata("https://github.com/org_repo", {"name": "underscore"}) - assert backend.retrieve_metadata("https://github.com/org/repo")["name"] == "slash" - assert backend.retrieve_metadata("https://github.com/org_repo")["name"] == "underscore" - -@pytest.mark.unit -class TestArchivistaBackend: - """Tests for ArchivistaBackend. - - We don't make real HTTP calls — we verify that it falls back to - FileBackend correctly when Archivista is unavailable. - """ - - def test_falls_back_to_file_on_connection_error(self, tmp_path: Path): - backend = ArchivistaBackend( - archivista_url="http://localhost:99999", - base_dir=str(tmp_path / ".darnit"), - ) - ref = backend.store_attestation(REPO_URL, COMMIT, SAMPLE_ATTESTATION) - # Should fall back to file storage - assert ref is not None - assert Path(ref).exists() - - def test_metadata_always_uses_file(self, tmp_path: Path): - backend = ArchivistaBackend( - archivista_url="http://localhost:99999", - base_dir=str(tmp_path / ".darnit"), - ) - success = backend.store_metadata(REPO_URL, SAMPLE_METADATA) - assert success is True - result = backend.retrieve_metadata(REPO_URL) - assert result == SAMPLE_METADATA - - def test_research_always_uses_file(self, tmp_path: Path): - backend = ArchivistaBackend( - archivista_url="http://localhost:99999", - base_dir=str(tmp_path / ".darnit"), - ) - success = backend.store_research_result(REPO_URL, COMMIT, SAMPLE_RESULT) - assert success is True - result = backend.retrieve_research_result(REPO_URL, COMMIT) - assert result == SAMPLE_RESULT - -@pytest.mark.unit -class TestGetBackendFactory: - """Tests for the get_backend() factory function.""" - - def test_returns_file_backend_by_default(self): - backend = get_backend() - assert isinstance(backend, FileBackend) - - def test_returns_file_backend_explicitly(self): - backend = get_backend({"backend": "file"}) - assert isinstance(backend, FileBackend) - - def test_returns_memory_backend(self): - backend = get_backend({"backend": "memory"}) - assert isinstance(backend, MemoryBackend) - - def test_returns_archivista_backend(self): - backend = get_backend({"backend": "archivista"}) - assert isinstance(backend, ArchivistaBackend) - - def test_archivista_url_passed_through(self): - backend = get_backend({ - "backend": "archivista", - "archivista_url": "http://my-archivista:8082", - }) - assert isinstance(backend, ArchivistaBackend) - assert backend.archivista_url == "http://my-archivista:8082" - - def test_unknown_backend_falls_back_to_file(self): - backend = get_backend({"backend": "something_weird"}) - assert isinstance(backend, FileBackend) - - def test_none_config_returns_file(self): - backend = get_backend(None) - assert isinstance(backend, FileBackend) - - def test_empty_config_returns_file(self): - backend = get_backend({}) - assert isinstance(backend, FileBackend) diff --git a/tests/darnit_baseline/remediation/test_routing.py b/tests/darnit_baseline/remediation/test_routing.py deleted file mode 100644 index 769cc80c..00000000 --- a/tests/darnit_baseline/remediation/test_routing.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for write-back routing classification.""" - -from __future__ import annotations - -from darnit_baseline.remediation.routing import ( - classify_remediation_actions, - classify_writeback, - format_routing_report, -) - - -class TestClassifyWriteback: - """Tests for classify_writeback.""" - - def test_org_field_present_in_org_config(self): - """Org-level field routes to 'org' when present in org config.""" - org_config = {"security": {"contact": "sec@example.com"}} - assert classify_writeback("security.contact", org_config) == "org" - - def test_org_field_missing_from_org_config(self): - """Org-level field routes to 'repo' when not in org config.""" - org_config = {"security": {}} - assert classify_writeback("security.contact", org_config) == "repo" - - def test_always_repo_field(self): - """Always-repo fields route to 'repo' regardless of org config.""" - org_config = {"security": {"contact": "sec@example.com"}} - assert classify_writeback("SECURITY.md", org_config) == "repo" - assert classify_writeback("CODEOWNERS", org_config) == "repo" - - def test_no_org_config(self): - """Everything routes to 'repo' when no org config exists.""" - assert classify_writeback("security.contact", None) == "repo" - assert classify_writeback("maintainers", None) == "repo" - - def test_unknown_field_routes_to_repo(self): - """Unknown fields route to 'repo'.""" - org_config = {"security": {"contact": "sec@example.com"}} - assert classify_writeback("unknown.field", org_config) == "repo" - - def test_maintainers_in_org_config(self): - """Maintainers field routes to 'org' when present.""" - org_config = {"maintainers": ["@alice", "@bob"]} - assert classify_writeback("maintainers", org_config) == "org" - - def test_governance_in_org_config(self): - """Governance field routes to 'org' when present.""" - org_config = {"governance": {"codeowners": {"path": ".github/CODEOWNERS"}}} - assert classify_writeback("governance.codeowners", org_config) == "org" - - def test_empty_org_field_routes_to_repo(self): - """Org-level field with empty value routes to 'repo'.""" - org_config = {"maintainers": []} - assert classify_writeback("maintainers", org_config) == "repo" - - -class TestClassifyRemediationActions: - """Tests for classify_remediation_actions.""" - - def test_classifies_actions(self): - """Actions get routing labels.""" - actions = [ - {"field": "security.contact", "description": "Set security contact"}, - {"artifact": "SECURITY.md", "description": "Create SECURITY.md"}, - ] - org_config = {"security": {"contact": "sec@example.com"}} - result = classify_remediation_actions(actions, org_config) - assert result[0]["routing"] == "org" - assert result[1]["routing"] == "repo" - - -class TestFormatRoutingReport: - """Tests for format_routing_report.""" - - def test_format_with_mixed_routing(self): - """Report includes both org and repo sections.""" - actions = [ - {"field": "security.contact", "description": "Set security contact", "routing": "org"}, - {"artifact": "SECURITY.md", "description": "Create SECURITY.md", "routing": "repo"}, - ] - report = format_routing_report(actions, "my-org") - assert "## Write-back Routing" in report - assert "[org]" in report - assert "[repo]" in report - assert "my-org/.project" in report - - def test_format_empty_actions(self): - """Empty actions produce empty report.""" - assert format_routing_report([], "my-org") == "" diff --git a/tests/darnit_baseline/test_attestation_storage.py b/tests/darnit_baseline/test_attestation_storage.py deleted file mode 100644 index ab0b14b6..00000000 --- a/tests/darnit_baseline/test_attestation_storage.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Tests for storage backend wiring in the attestation generator.""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from darnit.storage.backends import MemoryBackend -from darnit_baseline.attestation.generator import generate_attestation_from_results - - -def make_audit_result(tmp_path: Path) -> MagicMock: - """Create a minimal AuditResult mock.""" - result = MagicMock() - result.owner = "org" - result.repo = "repo" - result.commit = "abc123def456" - result.ref = "refs/heads/main" - result.level = 1 - result.all_results = [] - result.project_config = None - result.local_path = str(tmp_path) - return result - - -@pytest.mark.unit -class TestAttestationStorageWiring: - """Tests that the storage backend is called correctly from the generator.""" - - def test_stores_attestation_via_memory_backend(self, tmp_path: Path) -> None: - """With a MemoryBackend, the attestation should be stored and retrievable.""" - backend = MemoryBackend() - audit_result = make_audit_result(tmp_path) - - with patch("darnit.storage.backends.get_backend", return_value=backend), \ - patch("darnit_baseline.attestation.generator.ATTESTATION_AVAILABLE", False): - - generate_attestation_from_results( - audit_result=audit_result, - sign=False, - storage_config={"backend": "memory"}, - ) - - stored = backend.retrieve_attestation( - "https://github.com/org/repo", "abc123def456" - ) - assert stored is not None - assert stored["subject"][0]["digest"]["gitCommit"] == "abc123def456" - - def test_storage_failure_does_not_raise(self, tmp_path: Path) -> None: - """If the storage backend fails, the generator should still return the attestation.""" - broken_backend = MagicMock() - broken_backend.store_attestation.side_effect = RuntimeError("backend down") - audit_result = make_audit_result(tmp_path) - - with patch("darnit.storage.backends.get_backend", return_value=broken_backend), \ - patch("darnit_baseline.attestation.generator.ATTESTATION_AVAILABLE", False): - - result = generate_attestation_from_results( - audit_result=audit_result, - sign=False, - storage_config={"backend": "memory"}, - ) - - # Should still return the attestation despite storage failure - assert "abc123def456" in result - - def test_no_storage_config_uses_file_backend(self, tmp_path: Path) -> None: - """Without storage_config, falls back to default FileBackend.""" - audit_result = make_audit_result(tmp_path) - - with patch("darnit_baseline.attestation.generator.ATTESTATION_AVAILABLE", False): - result = generate_attestation_from_results( - audit_result=audit_result, - sign=False, - storage_config=None, - ) - - assert "abc123def456" in result - - def test_storage_ref_logged_on_success(self, tmp_path: Path) -> None: - """A successful store should log the storage reference.""" - backend = MemoryBackend() - audit_result = make_audit_result(tmp_path) - - with patch("darnit.storage.backends.get_backend", return_value=backend), \ - patch("darnit_baseline.attestation.generator.ATTESTATION_AVAILABLE", False), \ - patch("darnit_baseline.attestation.generator.logger") as mock_logger: - - generate_attestation_from_results( - audit_result=audit_result, - sign=False, - storage_config={"backend": "memory"}, - ) - - # The generator emits multiple info logs (file save + storage backend). - # Verify the storage backend log is among them. - assert mock_logger.info.called - log_messages = [ - call.args[0] for call in mock_logger.info.call_args_list if call.args - ] - assert any("memory://" in msg for msg in log_messages), ( - f"Expected a log message containing 'memory://', got: {log_messages}" - ) diff --git a/tests/darnit_baseline/threat_model/test_models.py b/tests/darnit_baseline/threat_model/test_models.py deleted file mode 100644 index 176a61bd..00000000 --- a/tests/darnit_baseline/threat_model/test_models.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for threat model data model extensions.""" - -from darnit_baseline.threat_model.models import ( - AttackChain, - DetailLevel, - RankedControl, - RiskLevel, - RiskScore, - StrideCategory, - Threat, - ThreatAnalysis, -) - - -def _make_risk() -> RiskScore: - return RiskScore( - overall=0.5, - level=RiskLevel.MEDIUM, - likelihood=0.6, - impact=0.5, - control_effectiveness=0.0, - ) - - -class TestThreatBackwardCompat: - """Verify Threat can be constructed without new fields.""" - - def test_threat_without_new_fields(self): - t = Threat( - id="TM-S-001", - category=StrideCategory.SPOOFING, - title="Test", - description="desc", - affected_assets=[], - attack_vector="vec", - prerequisites=[], - risk=_make_risk(), - existing_controls=[], - recommended_controls=["ctrl"], - code_locations=[], - ) - assert t.exploitation_scenario == [] - assert t.data_flow_impact == "" - assert t.ranked_controls == [] - assert t.attack_chain_ids == [] - - def test_threat_with_new_fields(self): - rc = RankedControl(control="Use MFA", effectiveness="high", rationale="Prevents spoofing") - t = Threat( - id="TM-S-001", - category=StrideCategory.SPOOFING, - title="Test", - description="desc", - affected_assets=[], - attack_vector="vec", - prerequisites=[], - risk=_make_risk(), - existing_controls=[], - recommended_controls=[], - code_locations=[], - exploitation_scenario=["Step 1", "Step 2", "Step 3"], - data_flow_impact="client → endpoint → db", - ranked_controls=[rc], - attack_chain_ids=["TC-001"], - ) - assert len(t.exploitation_scenario) == 3 - assert t.data_flow_impact == "client → endpoint → db" - assert t.ranked_controls[0].effectiveness == "high" - assert t.attack_chain_ids == ["TC-001"] - - -class TestThreatAnalysisBackwardCompat: - """Verify ThreatAnalysis can be constructed without new fields.""" - - def test_without_attack_chains(self): - ta = ThreatAnalysis( - methodology="STRIDE", - threats=[], - control_gaps=[], - summary={}, - ) - assert ta.attack_chains == [] - - def test_with_attack_chains(self): - chain = AttackChain( - id="TC-001", - name="Test Chain", - description="desc", - threat_ids=["TM-S-001", "TM-I-002"], - categories=[StrideCategory.SPOOFING, StrideCategory.INFORMATION_DISCLOSURE], - shared_assets=["ep-1"], - composite_risk=_make_risk(), - ) - ta = ThreatAnalysis( - methodology="STRIDE", - threats=[], - control_gaps=[], - summary={}, - attack_chains=[chain], - ) - assert len(ta.attack_chains) == 1 - assert ta.attack_chains[0].id == "TC-001" - - -class TestNewDataclasses: - """Verify new dataclasses work correctly.""" - - def test_ranked_control(self): - rc = RankedControl(control="Enable WAF", effectiveness="medium", rationale="Filters malicious traffic") - assert rc.control == "Enable WAF" - assert rc.effectiveness == "medium" - - def test_attack_chain(self): - chain = AttackChain( - id="TC-001", - name="Credential Theft → Data Exfiltration", - description="desc", - threat_ids=["TM-S-001", "TM-I-002"], - categories=[StrideCategory.SPOOFING, StrideCategory.INFORMATION_DISCLOSURE], - shared_assets=["ep-1"], - composite_risk=_make_risk(), - ) - assert len(chain.threat_ids) == 2 - assert len(chain.categories) == 2 - - def test_detail_level_enum(self): - assert DetailLevel.SUMMARY.value == "summary" - assert DetailLevel.DETAILED.value == "detailed" diff --git a/uv.lock b/uv.lock index 4ae63373..747d1149 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,6 @@ members = [ "darnit-gittuf", "darnit-hello", "darnit-mcp", - "darnit-plugins", "darnit-reproducibility", "darnit-testchecks", ] @@ -639,28 +638,6 @@ dev = [ { name = "syrupy", specifier = ">=4.0.0" }, ] -[[package]] -name = "darnit-plugins" -version = "0.1.0" -source = { editable = "packages/darnit-plugins" } -dependencies = [ - { name = "darnit-core" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "darnit-core", editable = "packages/darnit" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, -] -provides-extras = ["dev"] - [[package]] name = "darnit-reproducibility" version = "0.1.1"