From 587637b0f39c6905e0425e3ea422224cba850e7e Mon Sep 17 00:00:00 2001 From: David Waltermire Date: Wed, 17 Dec 2025 09:27:50 -0500 Subject: [PATCH 1/4] docs: add PRD for allowlist URI resolver (issue #183) --- PRDs/20251217-allowlist-resolver/PRD.md | 618 +++++ .../implementation-plan.md | 2210 +++++++++++++++++ 2 files changed, 2828 insertions(+) create mode 100644 PRDs/20251217-allowlist-resolver/PRD.md create mode 100644 PRDs/20251217-allowlist-resolver/implementation-plan.md diff --git a/PRDs/20251217-allowlist-resolver/PRD.md b/PRDs/20251217-allowlist-resolver/PRD.md new file mode 100644 index 0000000000..2fc4af920c --- /dev/null +++ b/PRDs/20251217-allowlist-resolver/PRD.md @@ -0,0 +1,618 @@ +# Allowlist URI Resolver PRD + +**Issue:** [#183 - Add new allowlist-only resolver for loading models, instances, and dynamic model generation](https://github.com/metaschema-framework/metaschema-java/issues/183) + +**Goal:** Provide a secure-by-default URI resolver that restricts resource access to explicitly allowed directories, domains, and URI schemes - preventing local file inclusion, SSRF, and other resource access attacks. + +**Architecture:** Implement `IAllowlistUriResolver` extending `IUriResolver` with hierarchical rules (scheme → file/HTTP-specific policies). Integrate at all resolution points: module loading, document loading, constraint loading, and XML entity resolution. Defense-in-depth via user-defined allowlist plus always-enforced built-in denylist. + +**Tech Stack:** Java 11, existing Metaschema core interfaces, YAML (SnakeYAML) for configuration files, SLF4J for audit logging. + +--- + +## Problem Statement + +As a developer of Metaschema-based tooling deploying services, I need a resolver subsystem that: +1. Restricts access to an allowlist of local filesystem directories +2. Restricts access to an allowlist of remote HTTP services +3. Prevents SSRF attacks to internal services (localhost, cloud metadata endpoints) +4. Prevents local file inclusion attacks (directory traversal, sensitive system files) + +### Security Threats Addressed + +| Threat | Attack Vector | Mitigation | +|--------|--------------|------------| +| Local File Inclusion | `../../../etc/passwd` in imports | File path normalization + base directory validation | +| SSRF to Internal Services | `http://localhost:8080/admin` | Built-in denylist for localhost, private IPs | +| Cloud Metadata Access | `http://169.254.169.254/` | Built-in denylist for link-local addresses | +| XXE Attacks | XML entity resolution to arbitrary URLs | Route entity resolution through allowlist | +| Scheme Injection | `file://`, `ftp://`, `gopher://` | Scheme allowlist (default: https only) | + +--- + +## Design Decisions + +### 1. Primary Use Cases +- **Server/API deployment**: Untrusted users submitting URIs for validation +- **Library security**: Secure defaults for developers integrating the library +- **CLI hardening**: Command-line tools processing user-provided files + +### 2. Configuration Model +- **Programmatic API**: Builder pattern for library integrations +- **File-based**: YAML configuration for deployments +- **Hierarchical**: Global defaults with per-loader overrides +- **Secure defaults**: Deny all schemes except https; require explicit allowlist + +### 3. Rule Granularity +- **Scheme policies**: Allow/deny by URI scheme (file, http, https, jar) +- **File system rules**: Base directory + recursive/single-level scope +- **HTTP rules**: Domain allowlist + optional path prefix restrictions +- **JAR resources**: Path patterns within JAR files + +### 4. Defense in Depth +- **User allowlist**: Explicit permissions required +- **Built-in denylist**: Always enforced, cannot be disabled + - Localhost and loopback addresses + - Private IP ranges (10.x, 172.16-31.x, 192.168.x) + - Link-local addresses (169.254.x.x - cloud metadata) + - Sensitive system paths (/etc/, /proc/, /sys/, C:\Windows\) + +### 5. Access Denied Behavior +- **Default**: Throw `AccessDeniedException` with clear message +- **Configurable**: Custom handler for alternative behavior +- **Audit logging**: Always log blocked attempts via SLF4J + +### 6. Integration Points +All resolution paths route through the allowlist resolver: + +| Component | Current Behavior | Change Required | +|-----------|-----------------|-----------------| +| `DefaultBoundLoader` | Uses `IUriResolver` | None - already integrated | +| `AbstractModuleLoader` | Raw `URI.resolve()` for imports | Route through `IUriResolver` | +| `BindingConstraintLoader` | Raw `URI.resolve()` for imports | Route through `IUriResolver` | +| `DefaultXmlDeserializer` | Custom `XMLResolver` for entities | Use `IUriResolver` | +| `DefaultJsonDeserializer` | Reads from provided `Reader` | None - uses loader with allowlist | +| `DefaultYamlDeserializer` | Reads from provided `Reader` | None - uses loader with allowlist | + +--- + +## Architecture + +### Component Diagram + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ IAllowlistUriResolver │ +│ (extends IUriResolver) │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌──────────────────────────────────────┐ │ +│ │ SchemePolicy │ │ ResourceRules │ │ +│ │ ───────────── │ │ ──────────────────────────────── │ │ +│ │ file: DENY │ │ FileSystemRules: │ │ +│ │ http: DENY │ │ - baseDirs with scope │ │ +│ │ https: ALLOW │ │ - path patterns │ │ +│ │ jar: ALLOW │ │ HttpRules: │ │ +│ │ │ │ - domain allowlist │ │ +│ │ │ │ - path prefix restrictions │ │ +│ │ │ │ JarRules: │ │ +│ │ │ │ - allowed resource paths │ │ +│ └─────────────────┘ └──────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ BuiltInDenylist (always enforced) │ │ +│ │ ─────────────────────────────────────────────────────── │ │ +│ │ Network: localhost, 127.*, 10.*, 172.16-31.*, 192.168.* │ │ +│ │ 169.254.* (cloud metadata), [::1], etc. │ │ +│ │ Filesystem: /etc/, /proc/, /sys/, /dev/, ~/.ssh/ │ │ +│ │ C:\Windows\, C:\Users\*\AppData\ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ AccessDeniedHandler (configurable) │ │ +│ │ ─────────────────────────────────────────────────────── │ │ +│ │ Default: throw AccessDeniedException │ │ +│ │ Custom: user-provided handler │ │ +│ │ Logging: always audit via SLF4J │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Class Hierarchy + +```text +gov.nist.secauto.metaschema.core.model.resolver/ +├── IAllowlistUriResolver.java # Main interface +├── AllowlistUriResolver.java # Default implementation +├── AllowlistUriResolverBuilder.java # Fluent builder +├── AccessDeniedException.java # Exception for blocked URIs +├── IAccessDeniedHandler.java # Custom handler interface +├── config/ +│ ├── AllowlistConfiguration.java # Configuration POJO +│ ├── AllowlistConfigurationLoader.java # YAML loader +│ ├── SchemePolicy.java # Enum: ALLOW, DENY +│ ├── FileSystemRule.java # File path rules +│ ├── HttpRule.java # Domain/path rules +│ └── JarRule.java # JAR resource rules +└── denylist/ + ├── BuiltInDenylist.java # Immutable security rules + ├── NetworkDenylist.java # IP/hostname patterns + └── FileSystemDenylist.java # Sensitive path patterns +``` + +### Integration Flow + +```text +User Request (URI) + │ + ▼ +┌──────────────────┐ +│ IModuleLoader │──────┐ +│ IDocumentLoader │ │ +│ IConstraintLoader │ +│ XMLResolver │ │ +└──────────────────┘ │ + │ │ + ▼ ▼ +┌──────────────────────────────────────┐ +│ IAllowlistUriResolver │ +│ ┌────────────────────────────────┐ │ +│ │ 1. Check built-in denylist │ │ +│ │ 2. Check scheme policy │ │ +│ │ 3. Check resource-specific │ │ +│ │ rules (file/http/jar) │ │ +│ │ 4. Log attempt │ │ +│ │ 5. Return URI or throw │ │ +│ └────────────────────────────────┘ │ +└──────────────────────────────────────┘ + │ + ▼ + Allowed URI → Resource Access + or + AccessDeniedException → Blocked +``` + +--- + +## API Design + +### Programmatic Configuration (Fluent API) + +```java +// Strict server mode - HTTPS only +AllowlistUriResolver serverResolver = AllowlistUriResolver.builder() + .forScheme("https") + .allowDomain("pages.nist.gov") + .allowDomain("raw.githubusercontent.com") + .restrictToPath("/metaschema-framework/") + .forScheme("http") + .denyAll() + .forScheme("file") + .denyAll() + .forScheme("jar") + .allowPath("/schema/") + .allowPath("/META-INF/metaschema/") + .defaultDeny() // deny unlisted schemes + .onAccessDenied((uri, reason) -> { + auditLog.warn("Blocked resource access: {} - {}", uri, reason); + throw new AccessDeniedException(uri, reason); + }) + .build(); + +// Development mode - allow local files +AllowlistUriResolver devResolver = AllowlistUriResolver.builder() + .forScheme("https") + .allowDomain("pages.nist.gov") + .forScheme("file") + .allowDirectory("/workspace/schemas").recursive() + .allowDirectory("/workspace/examples").recursive() + .forScheme("jar") + .allowPath("/schema/") + .defaultDeny() + .build(); + +// Hierarchical - inherit global with overrides +AllowlistUriResolver.setGlobalDefaults(serverResolver); + +IModuleLoader loader = context.newModuleLoader(); +loader.setUriResolver(AllowlistUriResolver.builder() + .inheritGlobalDefaults() + .forScheme("file") // Override for this loader + .allowDirectory(trustedSchemaPath).recursive() + .build()); +``` + +**Convenience constants (optional):** +```java +public final class Schemes { + public static final String HTTPS = "https"; + public static final String HTTP = "http"; + public static final String FILE = "file"; + public static final String JAR = "jar"; + // Users can use any string: forScheme("custom-protocol") +} +``` + +### YAML Configuration (Scheme-First Hierarchy) + +```yaml +# allowlist-config.yaml +default: deny # deny unlisted schemes + +schemes: + https: + enabled: true + rules: + - domain: pages.nist.gov + paths: any + - domain: raw.githubusercontent.com + paths: + - prefix: /metaschema-framework/ + - prefix: /usnistgov/OSCAL/ + - domain: "*.nist.gov" + paths: + - prefix: /schemas/ + + http: + enabled: false + + file: + enabled: true # set to false for server mode + rules: + - path: /data/schemas + scope: recursive + - path: /app/config + scope: single-level + + jar: + enabled: true + rules: + - path: /schema/ + - path: /META-INF/metaschema/ + +# Audit logging configuration +logging: + level: WARN + include_allowed: false +``` + +### Loading Configuration + +```java +// From file +AllowlistUriResolver resolver = AllowlistUriResolver + .fromYaml(Path.of("/etc/metaschema/allowlist.yaml")); + +// From classpath resource +AllowlistUriResolver resolver = AllowlistUriResolver + .fromYaml(getClass().getResourceAsStream("/allowlist-config.yaml")); + +// From environment variable +String configPath = System.getenv("METASCHEMA_ALLOWLIST_CONFIG"); +if (configPath != null) { + AllowlistUriResolver.setGlobalDefaults( + AllowlistUriResolver.fromYaml(Path.of(configPath))); +} +``` + +--- + +## Configuration System + +The allowlist configuration uses a layered configuration system that loads and merges configs from multiple locations, providing flexibility for different deployment scenarios. + +### Configuration Directory Locations + +Configurations are loaded from the following locations in precedence order (lowest to highest): + +| Priority | Location | Platform | Purpose | +|----------|----------|----------|---------| +| 1 (lowest) | `/config/` | All | Shipped defaults bundled with distribution | +| 2 | `/etc/metaschema-cli/` | Unix | System-wide administrator settings | +| 2 | `%ProgramData%\metaschema-cli\` | Windows | System-wide administrator settings | +| 3 | `~/.metaschema-cli/` | All | User-specific preferences | +| 4 | `./.metaschema/` | All | Project-specific overrides | +| 5 (highest) | `--config-dir=` | All | CLI argument override | +| 5 (highest) | `METASCHEMA_CONFIG_DIR` | All | Environment variable override | + +**Install Directory Structure:** +```text +metaschema-cli/ +├── bin/ +│ └── metaschema-cli # launcher script +├── lib/ +│ └── metaschema-cli.jar # main JAR +└── config/ # install-level configs + └── allowlist.yaml +``` + +**Config Files:** +Each directory can contain: +- `allowlist.yaml` - URI resolver security rules +- `logging.yaml` - Log level configuration (future) +- Other feature-specific configs as needed + +### Merge Semantics + +Configurations from all discovered locations are merged using the following rules: + +- **Deep merge on scheme**: When multiple config files define rules for the same scheme (e.g., `https`), all domain rules are combined from all layers +- **Shallow merge on domain**: When the same domain appears in multiple layers, the higher-precedence layer's rules completely replace the lower one + +**Merge Example:** + +```yaml +# Install config (priority 1) - /config/allowlist.yaml +default: deny + +schemes: + https: + enabled: true + rules: + - domain: pages.nist.gov + paths: [/schemas/] + - domain: raw.githubusercontent.com + paths: [/metaschema-framework/] + file: + enabled: false +``` + +```yaml +# User config (priority 3) - ~/.metaschema-cli/allowlist.yaml +schemes: + https: + rules: + - domain: pages.nist.gov # Same domain - REPLACES install's rules + paths: [/schemas/, /docs/] + - domain: internal.example.com # New domain - ADDED + paths: any + file: + enabled: true # Overrides install's file policy + rules: + - path: /home/user/schemas + scope: recursive +``` + +**Merged Result:** +```yaml +default: deny # From install (not overridden) + +schemes: + https: + enabled: true # From install + rules: + - domain: pages.nist.gov # User's version (shallow merge on domain) + paths: [/schemas/, /docs/] + - domain: raw.githubusercontent.com # From install (kept) + paths: [/metaschema-framework/] + - domain: internal.example.com # From user (added) + paths: any + file: + enabled: true # User override + rules: + - path: /home/user/schemas + scope: recursive +``` + +### Configuration Service API + +```java +public interface IConfigurationService { + /** + * Get the merged configuration for a specific config file. + * + * @param configName the config file name (e.g., "allowlist.yaml") + * @return the merged configuration, or empty if no configs found + */ + Optional getConfiguration(String configName); + + /** + * Get all discovered config directory paths in precedence order. + * + * @return list of paths (lowest to highest precedence) + */ + List getConfigDirectories(); + + /** + * Reload all configurations from disk. + */ + void reload(); +} +``` + +**Integration with CLI:** + +```java +// In CLI.java or CLIProcessor initialization +IConfigurationService configService = ConfigurationService.getInstance(); + +// Get allowlist config and create resolver +Optional allowlistConfig = configService + .getConfiguration("allowlist.yaml") + .map(AllowlistConfiguration::fromYaml); + +if (allowlistConfig.isPresent()) { + AllowlistUriResolver.setGlobalDefaults( + AllowlistUriResolver.fromConfiguration(allowlistConfig.get())); +} +``` + +### Configuration Loading Process + +1. **Discovery Phase**: Scan all config locations in order, collect paths that exist +2. **Load Phase**: Parse each discovered config file (YAML via SnakeYAML) +3. **Merge Phase**: Apply merge rules to produce final configuration +4. **Validation Phase**: Validate merged config against expected schema + +**Caching Behavior:** +- Configs loaded once at startup +- `reload()` available for long-running processes +- No file watching (explicit reload only) + +### Performance Analysis + +| Operation | Expected Time | Notes | +|-----------|---------------|-------| +| Directory existence checks (5-6 paths) | ~1-5ms | Filesystem stat calls | +| YAML parsing (per file, ~1-5KB) | ~5-15ms | SnakeYAML parsing | +| Merge operation | <1ms | In-memory, small data structures | +| **Total (typical: 1-2 configs)** | **~10-30ms** | Negligible for CLI startup | +| **Total (worst case: all 5 locations)** | **~50-100ms** | Still acceptable | + +**Context:** +- JVM startup itself takes 50-200ms +- Current CLI startup (loading modules, initializing databind) takes 200-500ms +- Config loading adds ~5-10% overhead in typical case + +**Built-in Optimizations:** +- Short-circuit on CLI `--config-dir` override (skip other locations) +- Lazy loading option for configs not needed by every command +- No file watching or polling overhead + +**Future Optimizations (if needed):** +- Cache merged config to temp file with checksum validation +- Parallel directory scanning +- Native YAML parser + +--- + +## Built-In Denylist + +These patterns are **blocked by default** but can be explicitly overridden when necessary (e.g., for local testing): + +### Network Addresses +```java +// IPv4 +"127.*.*.*" // Loopback +"10.*.*.*" // Private Class A +"172.16-31.*.*" // Private Class B +"192.168.*.*" // Private Class C +"169.254.*.*" // Link-local (AWS/GCP/Azure metadata) +"0.0.0.0" // All interfaces + +// IPv6 +"::1" // Loopback +"fe80::*" // Link-local +"fc00::*" // Unique local + +// Hostnames +"localhost" +"*.localhost" +"*.local" +"metadata.google.internal" +"instance-data" // EC2 metadata hostname +``` + +**Overriding for local testing:** +```java +AllowlistUriResolver.builder() + .forScheme("http") + .allowHost("localhost") // explicitly override denylist + .allowHost("127.0.0.1") + .restrictToPort(8080) // optional: restrict to specific port + .build(); +``` + +### File System Paths (Unix) +```java +"/etc/" +"/proc/" +"/sys/" +"/dev/" +"/root/" +"/home/*/.*" // All hidden files/directories in home +"/var/run/" +"/tmp/" // Optional - may be needed for some use cases +``` + +### File System Paths (Windows) +```java +"C:\\Windows\\" +"C:\\Users\\*\\AppData\\" +"C:\\ProgramData\\" +"C:\\$Recycle.Bin\\" +"*\\.ssh\\" +"*\\.aws\\" +``` + +--- + +## Success Criteria + +From Issue #183: +- [ ] All website and readme documentation affected by the changes have been updated +- [ ] A Pull Request is submitted that fully addresses the goals +- [ ] The CI-CD build process runs without any reported errors + +### Additional Acceptance Criteria + +**Functional:** +- [ ] Module loading respects allowlist for imports +- [ ] Document loading respects allowlist +- [ ] Constraint loading respects allowlist for imports +- [ ] XML entity resolution respects allowlist +- [ ] Built-in denylist blocks all defined patterns +- [ ] Scheme policies correctly allow/deny by scheme +- [ ] File system rules enforce directory boundaries +- [ ] HTTP rules enforce domain and path restrictions +- [ ] JAR rules enforce resource path restrictions +- [ ] Hierarchical configuration (global + per-loader) works correctly +- [ ] YAML configuration loading works correctly + +**Security:** +- [ ] Path traversal attacks are blocked (../../../etc/passwd) +- [ ] SSRF to localhost is blocked +- [ ] SSRF to private IP ranges is blocked +- [ ] Cloud metadata endpoints are blocked (169.254.169.254) +- [ ] Sensitive system paths are blocked + +**Non-Functional:** +- [ ] Clear error messages when access is denied +- [ ] Audit logging for all blocked attempts +- [ ] Minimal performance overhead for resolution +- [ ] 80%+ test coverage for resolver code + +--- + +## Testing Strategy + +### Unit Tests +- SchemePolicy allow/deny behavior +- FileSystemRule path matching and boundary validation +- HttpRule domain and path matching +- JarRule resource path matching +- BuiltInDenylist pattern matching +- AllowlistUriResolverBuilder configuration +- YAML configuration parsing + +### Integration Tests +- Module loading with allowlist enabled +- Document loading with allowlist enabled +- Constraint loading with allowlist enabled +- XML entity resolution with allowlist enabled +- Hierarchical configuration inheritance + +### Security Tests +- Path traversal attack vectors +- SSRF attack vectors (localhost, private IPs, metadata endpoints) +- Scheme injection attacks +- Unicode/encoding bypass attempts +- Case sensitivity handling (Windows paths) + +--- + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking existing applications | High | Opt-in by default; document migration path | +| Performance overhead | Medium | Efficient pattern matching; caching | +| Incomplete denylist | High | Research common attack vectors; allow updates | +| Configuration complexity | Medium | Sensible defaults; clear documentation | +| Platform-specific path issues | Medium | Test on Windows/Linux/Mac; normalize paths | + +--- + +## Out of Scope + +- Authentication/authorization for HTTP resources (use existing HTTP client config) +- Rate limiting or request throttling +- Content inspection (only URI-based filtering) +- Certificate validation (use JVM truststore config) diff --git a/PRDs/20251217-allowlist-resolver/implementation-plan.md b/PRDs/20251217-allowlist-resolver/implementation-plan.md new file mode 100644 index 0000000000..f81e6e838e --- /dev/null +++ b/PRDs/20251217-allowlist-resolver/implementation-plan.md @@ -0,0 +1,2210 @@ +# Allowlist URI Resolver - Implementation Plan + +**Goal:** Implement secure-by-default URI resolver with allowlist/denylist controls for all resource loading paths. + +**Architecture:** Layered approach - core interfaces first, then rules engine, then integration points. + +**Tech Stack:** Java 11, JUnit 5, Mockito, SnakeYAML, SLF4J + +--- + +## PR Breakdown + +Single PR with multiple commits organized by feature area. + +| PR | Scope | Commits | Files | Estimated Size | +|----|-------|---------|-------|----------------| +| PR1 | Complete allowlist resolver implementation | ~15 commits | ~40 files | Large | + +### Commit Sequence + +**Phase 1: Core Resolver** +1. Core interfaces and exceptions (AccessDeniedException, IAccessDeniedHandler, IUriAccessRule, IAllowlistUriResolver) +2. SchemePolicy implementation +3. Built-in denylist (NetworkDenylist, FileSystemDenylist, BuiltInDenylist) +4. FileSystemRule implementation +5. HttpRule implementation +6. JarRule implementation +7. AllowlistUriResolver and builder + +**Phase 2: Configuration System** +8. IConfigurationService interface +9. ConfigurationService implementation with directory discovery +10. AllowlistConfigurationMerger (deep merge on scheme, shallow on domain) +11. AllowlistConfiguration POJO and YAML loading + +**Phase 3: Integration** +12. Update AbstractModuleLoader to use IUriResolver +13. Update BindingConstraintLoader to use IUriResolver +14. Update DefaultXmlDeserializer to use IUriResolver +15. CLI integration (--config-dir option, METASCHEMA_CONFIG_DIR env var) +16. Documentation and examples + +--- + +## PR1: Core Interfaces and Exceptions + +**Goal:** Establish foundational interfaces and exception types. + +**Package:** `gov.nist.secauto.metaschema.core.model.resolver` + +### Task 1.1: Create AccessDeniedException + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedException.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedExceptionTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.core.model.resolver; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.net.URI; + +class AccessDeniedExceptionTest { + + @Test + void testExceptionContainsUriAndReason() { + URI uri = URI.create("file:///etc/passwd"); + String reason = "File system access denied by allowlist policy"; + + AccessDeniedException ex = new AccessDeniedException(uri, reason); + + assertEquals(uri, ex.getUri()); + assertEquals(reason, ex.getReason()); + assertTrue(ex.getMessage().contains(uri.toString())); + assertTrue(ex.getMessage().contains(reason)); + } + + @Test + void testExceptionWithCause() { + URI uri = URI.create("http://localhost:8080/admin"); + String reason = "Built-in denylist: localhost"; + Throwable cause = new SecurityException("Blocked"); + + AccessDeniedException ex = new AccessDeniedException(uri, reason, cause); + + assertEquals(uri, ex.getUri()); + assertEquals(reason, ex.getReason()); + assertEquals(cause, ex.getCause()); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +mvn -pl core test -Dtest=AccessDeniedExceptionTest -DfailIfNoTests=false +``` + +Expected: Compilation error - class does not exist + +**Step 3: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver; + +import java.net.URI; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Exception thrown when URI access is denied by the allowlist resolver. + *

+ * This exception provides details about which URI was blocked and why, + * supporting both security auditing and debugging. + */ +public class AccessDeniedException extends SecurityException { + private static final long serialVersionUID = 1L; + + @NonNull + private final URI uri; + @NonNull + private final String reason; + + /** + * Constructs a new access denied exception. + * + * @param uri + * the URI that was denied access + * @param reason + * human-readable explanation of why access was denied + */ + public AccessDeniedException(@NonNull URI uri, @NonNull String reason) { + super(formatMessage(uri, reason)); + this.uri = uri; + this.reason = reason; + } + + /** + * Constructs a new access denied exception with a cause. + * + * @param uri + * the URI that was denied access + * @param reason + * human-readable explanation of why access was denied + * @param cause + * the underlying cause of the denial + */ + public AccessDeniedException(@NonNull URI uri, @NonNull String reason, @NonNull Throwable cause) { + super(formatMessage(uri, reason), cause); + this.uri = uri; + this.reason = reason; + } + + private static String formatMessage(URI uri, String reason) { + return String.format("Access denied to URI '%s': %s", uri, reason); + } + + /** + * Returns the URI that was denied access. + * + * @return the blocked URI + */ + @NonNull + public URI getUri() { + return uri; + } + + /** + * Returns the reason access was denied. + * + * @return human-readable denial reason + */ + @NonNull + public String getReason() { + return reason; + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +mvn -pl core test -Dtest=AccessDeniedExceptionTest +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedException.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedExceptionTest.java +git commit -m "feat(resolver): add AccessDeniedException for blocked URI access" +``` + +--- + +### Task 1.2: Create IAccessDeniedHandler Interface + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAccessDeniedHandler.java` + +**Step 1: Write implementation** (interface-only, no test needed) + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver; + +import java.net.URI; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Handler invoked when URI access is denied by the allowlist resolver. + *

+ * Implementations can customize behavior when access is blocked, such as + * logging, throwing custom exceptions, or returning alternative resources. + */ +@FunctionalInterface +public interface IAccessDeniedHandler { + + /** + * Default handler that throws {@link AccessDeniedException}. + */ + IAccessDeniedHandler THROW_EXCEPTION = (uri, reason) -> { + throw new AccessDeniedException(uri, reason); + }; + + /** + * Called when access to a URI is denied. + * + * @param uri + * the URI that was denied access + * @param reason + * human-readable explanation of why access was denied + * @throws AccessDeniedException + * if the handler chooses to throw (default behavior) + */ + void handleAccessDenied(@NonNull URI uri, @NonNull String reason); +} +``` + +**Step 2: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAccessDeniedHandler.java +git commit -m "feat(resolver): add IAccessDeniedHandler interface for custom denial handling" +``` + +--- + +### Task 1.3: Create IUriAccessRule Interface + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IUriAccessRule.java` + +**Step 1: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver; + +import java.net.URI; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A rule that determines whether access to a URI should be allowed or denied. + *

+ * Rules are evaluated in order, and the first matching rule determines the + * access decision. If no rule matches, access is denied by default. + */ +public interface IUriAccessRule { + + /** + * Result of evaluating a URI against this rule. + */ + enum RuleResult { + /** The rule allows access to the URI. */ + ALLOW, + /** The rule denies access to the URI. */ + DENY, + /** The rule does not apply to this URI; check next rule. */ + NO_MATCH + } + + /** + * Evaluates whether this rule applies to the given URI and what the access + * decision is. + * + * @param uri + * the URI to evaluate + * @return the rule result indicating allow, deny, or no match + */ + @NonNull + RuleResult evaluate(@NonNull URI uri); + + /** + * Returns a human-readable description of this rule for logging and debugging. + * + * @return rule description + */ + @NonNull + String getDescription(); +} +``` + +**Step 2: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IUriAccessRule.java +git commit -m "feat(resolver): add IUriAccessRule interface for access decisions" +``` + +--- + +### Task 1.4: Create IAllowlistUriResolver Interface + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAllowlistUriResolver.java` + +**Step 1: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver; + +import gov.nist.secauto.metaschema.core.model.IUriResolver; + +import java.net.URI; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * A URI resolver that enforces allowlist-based access control. + *

+ * This resolver validates URIs against configured rules before allowing access, + * providing defense against local file inclusion, SSRF, and other URI-based + * attacks. + *

+ * The resolver enforces: + *

    + *
  • Scheme policies (allow/deny by URI scheme)
  • + *
  • File system rules (allowed directories and paths)
  • + *
  • HTTP rules (allowed domains and path prefixes)
  • + *
  • JAR resource rules (allowed paths within JARs)
  • + *
  • Built-in denylist (always blocks dangerous patterns)
  • + *
+ * + * @see AllowlistUriResolver + */ +public interface IAllowlistUriResolver extends IUriResolver { + + /** + * Checks whether access to the given URI would be allowed without actually + * resolving it. + *

+ * This method is useful for pre-validation or UI feedback without triggering + * the access denied handler. + * + * @param uri + * the URI to check + * @return {@code true} if the URI would be allowed, {@code false} otherwise + */ + boolean isAllowed(@NonNull URI uri); + + /** + * Returns the reason why a URI would be denied, or empty if allowed. + * + * @param uri + * the URI to check + * @return denial reason, or {@code null} if the URI is allowed + */ + String getDenialReason(@NonNull URI uri); +} +``` + +**Step 2: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAllowlistUriResolver.java +git commit -m "feat(resolver): add IAllowlistUriResolver interface extending IUriResolver" +``` + +--- + +### Task 1.5: Create SchemePolicy Enum + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicy.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicyTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.core.model.resolver; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class SchemePolicyTest { + + @Test + void testDefaultPolicyDeniesAll() { + SchemePolicy policy = SchemePolicy.denyAll(); + + assertFalse(policy.isAllowed("file")); + assertFalse(policy.isAllowed("http")); + assertFalse(policy.isAllowed("https")); + assertFalse(policy.isAllowed("jar")); + } + + @Test + void testAllowSpecificSchemes() { + SchemePolicy policy = SchemePolicy.denyAll() + .withAllowed("https") + .withAllowed("jar"); + + assertFalse(policy.isAllowed("file")); + assertFalse(policy.isAllowed("http")); + assertTrue(policy.isAllowed("https")); + assertTrue(policy.isAllowed("jar")); + } + + @Test + void testDenySpecificSchemes() { + SchemePolicy policy = SchemePolicy.allowAll() + .withDenied("file") + .withDenied("ftp"); + + assertFalse(policy.isAllowed("file")); + assertFalse(policy.isAllowed("ftp")); + assertTrue(policy.isAllowed("http")); + assertTrue(policy.isAllowed("https")); + } + + @ParameterizedTest + @CsvSource({ + "FILE, file", + "HTTPS, https", + "HTTP, http" + }) + void testSchemeNormalization(String input, String expected) { + SchemePolicy policy = SchemePolicy.denyAll().withAllowed(input); + assertTrue(policy.isAllowed(expected)); + assertTrue(policy.isAllowed(input.toUpperCase())); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +mvn -pl core test -Dtest=SchemePolicyTest -DfailIfNoTests=false +``` + +Expected: Compilation error + +**Step 3: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver; + +import gov.nist.secauto.metaschema.core.util.CollectionUtil; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Policy for allowing or denying URI schemes. + *

+ * Schemes are normalized to lowercase for comparison. + */ +public final class SchemePolicy { + + private final boolean defaultAllow; + @NonNull + private final Set allowedSchemes; + @NonNull + private final Set deniedSchemes; + + private SchemePolicy(boolean defaultAllow, Set allowed, Set denied) { + this.defaultAllow = defaultAllow; + this.allowedSchemes = CollectionUtil.unmodifiableSet(new HashSet<>(allowed)); + this.deniedSchemes = CollectionUtil.unmodifiableSet(new HashSet<>(denied)); + } + + /** + * Creates a policy that denies all schemes by default. + * + * @return a deny-all policy + */ + @NonNull + public static SchemePolicy denyAll() { + return new SchemePolicy(false, Set.of(), Set.of()); + } + + /** + * Creates a policy that allows all schemes by default. + * + * @return an allow-all policy + */ + @NonNull + public static SchemePolicy allowAll() { + return new SchemePolicy(true, Set.of(), Set.of()); + } + + /** + * Returns a new policy with the specified scheme allowed. + * + * @param scheme + * the scheme to allow + * @return new policy with scheme allowed + */ + @NonNull + public SchemePolicy withAllowed(@NonNull String scheme) { + Set newAllowed = new HashSet<>(allowedSchemes); + newAllowed.add(normalizeScheme(scheme)); + return new SchemePolicy(defaultAllow, newAllowed, deniedSchemes); + } + + /** + * Returns a new policy with the specified scheme denied. + * + * @param scheme + * the scheme to deny + * @return new policy with scheme denied + */ + @NonNull + public SchemePolicy withDenied(@NonNull String scheme) { + Set newDenied = new HashSet<>(deniedSchemes); + newDenied.add(normalizeScheme(scheme)); + return new SchemePolicy(defaultAllow, allowedSchemes, newDenied); + } + + /** + * Checks if the given scheme is allowed by this policy. + * + * @param scheme + * the scheme to check + * @return {@code true} if allowed, {@code false} if denied + */ + public boolean isAllowed(@NonNull String scheme) { + String normalized = normalizeScheme(scheme); + + // Explicit deny takes precedence + if (deniedSchemes.contains(normalized)) { + return false; + } + + // Explicit allow + if (allowedSchemes.contains(normalized)) { + return true; + } + + // Fall back to default + return defaultAllow; + } + + private static String normalizeScheme(String scheme) { + return scheme.toLowerCase(Locale.ROOT); + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +mvn -pl core test -Dtest=SchemePolicyTest +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicy.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicyTest.java +git commit -m "feat(resolver): add SchemePolicy for URI scheme allow/deny decisions" +``` + +--- + +### Task 1.6: Verify PR1 Build + +**Step 1: Run full build for core module** + +```bash +mvn -pl core clean install +``` + +Expected: BUILD SUCCESS + +**Step 2: Run checkstyle** + +```bash +mvn -pl core checkstyle:check +``` + +Expected: No violations + +**Step 3: Commit and prepare PR** + +```bash +git push -u me feature/183-allowlist-resolver +``` + +--- + +## PR2: Built-In Denylist + +**Goal:** Implement always-enforced security rules that cannot be disabled. + +### Task 2.1: Create NetworkDenylist + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylist.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylistTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.URI; + +class NetworkDenylistTest { + + private final NetworkDenylist denylist = NetworkDenylist.getInstance(); + + @ParameterizedTest + @ValueSource(strings = { + "http://localhost/admin", + "http://localhost:8080/api", + "http://127.0.0.1/secret", + "http://127.0.0.2:9000/data", + "http://[::1]/internal", + "http://169.254.169.254/latest/meta-data/", // AWS metadata + "http://metadata.google.internal/", // GCP metadata + "http://10.0.0.1/internal", // Private Class A + "http://172.16.0.1/internal", // Private Class B + "http://172.31.255.255/internal", // Private Class B upper + "http://192.168.1.1/router", // Private Class C + "http://0.0.0.0/", // All interfaces + }) + void testBlockedAddresses(String uriString) { + URI uri = URI.create(uriString); + assertTrue(denylist.isDenied(uri), "Should block: " + uriString); + assertNotNull(denylist.getDenialReason(uri)); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://example.com/api", + "https://pages.nist.gov/schema", + "https://raw.githubusercontent.com/file.xml", + "http://8.8.8.8/dns", // Public IP + "https://172.217.0.1/google", // Public IP in 172.x range but not private + }) + void testAllowedAddresses(String uriString) { + URI uri = URI.create(uriString); + assertFalse(denylist.isDenied(uri), "Should allow: " + uriString); + } + + @Test + void testNonHttpSchemesNotChecked() { + // File URIs don't have network hosts - handled by FileSystemDenylist + URI fileUri = URI.create("file:///etc/passwd"); + assertFalse(denylist.isDenied(fileUri)); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +mvn -pl core test -Dtest=NetworkDenylistTest -DfailIfNoTests=false +``` + +Expected: Compilation error + +**Step 3: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Built-in denylist for network addresses that should never be accessed. + *

+ * This includes: + *

    + *
  • Localhost and loopback addresses
  • + *
  • Private IP ranges (RFC 1918)
  • + *
  • Link-local addresses (cloud metadata endpoints)
  • + *
  • Known metadata service hostnames
  • + *
+ *

+ * This denylist cannot be disabled and is always enforced. + */ +public final class NetworkDenylist { + + private static final NetworkDenylist INSTANCE = new NetworkDenylist(); + + /** Hostnames that are always blocked. */ + private static final List BLOCKED_HOSTNAMES = List.of( + "localhost", + "metadata.google.internal", + "instance-data", + "kubernetes.default.svc" + ); + + /** Hostname patterns that are always blocked. */ + private static final List BLOCKED_HOSTNAME_PATTERNS = List.of( + Pattern.compile(".*\\.localhost$", Pattern.CASE_INSENSITIVE), + Pattern.compile(".*\\.local$", Pattern.CASE_INSENSITIVE) + ); + + private NetworkDenylist() { + // singleton + } + + /** + * Returns the singleton instance. + * + * @return the network denylist instance + */ + @NonNull + public static NetworkDenylist getInstance() { + return INSTANCE; + } + + /** + * Checks if the given URI's host is on the denylist. + * + * @param uri + * the URI to check + * @return {@code true} if the host is denied + */ + public boolean isDenied(@NonNull URI uri) { + return getDenialReason(uri) != null; + } + + /** + * Returns the reason why the URI's host is denied, or null if allowed. + * + * @param uri + * the URI to check + * @return denial reason or null + */ + @Nullable + public String getDenialReason(@NonNull URI uri) { + String host = uri.getHost(); + if (host == null) { + return null; // No host component (e.g., file:// URIs) + } + + String lowerHost = host.toLowerCase(Locale.ROOT); + + // Check exact hostname matches + if (BLOCKED_HOSTNAMES.contains(lowerHost)) { + return "Built-in denylist: blocked hostname '" + host + "'"; + } + + // Check hostname patterns + for (Pattern pattern : BLOCKED_HOSTNAME_PATTERNS) { + if (pattern.matcher(lowerHost).matches()) { + return "Built-in denylist: blocked hostname pattern '" + host + "'"; + } + } + + // Check IP addresses + return checkIpAddress(host); + } + + @Nullable + private String checkIpAddress(String host) { + // Handle IPv6 addresses in brackets + String ipString = host; + if (host.startsWith("[") && host.endsWith("]")) { + ipString = host.substring(1, host.length() - 1); + } + + try { + InetAddress addr = InetAddress.getByName(ipString); + + if (addr.isLoopbackAddress()) { + return "Built-in denylist: loopback address"; + } + + if (addr.isLinkLocalAddress()) { + return "Built-in denylist: link-local address (potential cloud metadata endpoint)"; + } + + if (addr.isSiteLocalAddress()) { + return "Built-in denylist: private network address"; + } + + if (addr.isAnyLocalAddress()) { + return "Built-in denylist: wildcard address"; + } + + // Check for 0.0.0.0 explicitly + byte[] bytes = addr.getAddress(); + if (bytes.length == 4 && bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0) { + return "Built-in denylist: all-interfaces address"; + } + + } catch (UnknownHostException e) { + // Not a valid IP address, treat as hostname (already checked above) + } + + return null; + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +mvn -pl core test -Dtest=NetworkDenylistTest +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylist.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylistTest.java +git commit -m "feat(resolver): add NetworkDenylist for blocking internal network access" +``` + +--- + +### Task 2.2: Create FileSystemDenylist + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylist.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylistTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.URI; + +class FileSystemDenylistTest { + + private final FileSystemDenylist denylist = FileSystemDenylist.getInstance(); + + @ParameterizedTest + @EnabledOnOs(OS.LINUX) + @ValueSource(strings = { + "file:///etc/passwd", + "file:///etc/shadow", + "file:///proc/self/environ", + "file:///sys/kernel/debug", + "file:///dev/null", + "file:///root/.ssh/id_rsa", + "file:///home/user/.ssh/known_hosts", + "file:///home/user/.aws/credentials", + "file:///home/user/.gnupg/private-keys-v1.d/key", + "file:///var/run/secrets/kubernetes.io/token", + }) + void testBlockedUnixPaths(String uriString) { + URI uri = URI.create(uriString); + assertTrue(denylist.isDenied(uri), "Should block: " + uriString); + assertNotNull(denylist.getDenialReason(uri)); + } + + @ParameterizedTest + @EnabledOnOs(OS.WINDOWS) + @ValueSource(strings = { + "file:///C:/Windows/System32/config/SAM", + "file:///C:/Users/admin/AppData/Local/secret", + "file:///C:/ProgramData/sensitive", + "file:///C:/Users/admin/.ssh/id_rsa", + "file:///C:/Users/admin/.aws/credentials", + }) + void testBlockedWindowsPaths(String uriString) { + URI uri = URI.create(uriString); + assertTrue(denylist.isDenied(uri), "Should block: " + uriString); + } + + @ParameterizedTest + @ValueSource(strings = { + "file:///data/schemas/module.xml", + "file:///app/config/settings.yaml", + "file:///workspace/project/src/main.java", + }) + void testAllowedPaths(String uriString) { + URI uri = URI.create(uriString); + assertFalse(denylist.isDenied(uri), "Should allow: " + uriString); + } + + @Test + void testPathTraversalNormalization() { + // These should be blocked after normalization + URI traversal1 = URI.create("file:///data/../etc/passwd"); + URI traversal2 = URI.create("file:///app/config/../../etc/shadow"); + + // Note: URI normalization happens before denylist check + // The denylist checks the normalized path + assertEquals("/etc/passwd", traversal1.normalize().getPath()); + assertTrue(denylist.isDenied(traversal1.normalize())); + } + + @Test + void testNonFileSchemesNotChecked() { + URI httpUri = URI.create("http://example.com/etc/passwd"); + assertFalse(denylist.isDenied(httpUri)); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +mvn -pl core test -Dtest=FileSystemDenylistTest -DfailIfNoTests=false +``` + +Expected: Compilation error + +**Step 3: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import java.net.URI; +import java.util.List; +import java.util.Locale; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Built-in denylist for sensitive file system paths. + *

+ * This includes: + *

    + *
  • System configuration directories (/etc, C:\Windows)
  • + *
  • Process and kernel interfaces (/proc, /sys)
  • + *
  • User credential directories (.ssh, .aws, .gnupg)
  • + *
  • Temporary and runtime directories
  • + *
+ *

+ * This denylist cannot be disabled and is always enforced. + */ +public final class FileSystemDenylist { + + private static final FileSystemDenylist INSTANCE = new FileSystemDenylist(); + private static final boolean IS_WINDOWS = System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT).contains("windows"); + + /** Unix paths that are always blocked. */ + private static final List BLOCKED_UNIX_PREFIXES = List.of( + "/etc/", + "/proc/", + "/sys/", + "/dev/", + "/root/", + "/var/run/", + "/run/" + ); + + /** Unix path patterns (checked as substrings). */ + private static final List BLOCKED_UNIX_PATTERNS = List.of( + "/.ssh/", + "/.gnupg/", + "/.aws/", + "/.azure/", + "/.config/gcloud/" + ); + + /** Windows paths that are always blocked (lowercase for comparison). */ + private static final List BLOCKED_WINDOWS_PREFIXES = List.of( + "/c:/windows/", + "/c:/programdata/", + "/c:/$recycle.bin/" + ); + + /** Windows patterns (checked as substrings, lowercase). */ + private static final List BLOCKED_WINDOWS_PATTERNS = List.of( + "/appdata/", + "/.ssh/", + "/.aws/", + "/.azure/" + ); + + private FileSystemDenylist() { + // singleton + } + + /** + * Returns the singleton instance. + * + * @return the file system denylist instance + */ + @NonNull + public static FileSystemDenylist getInstance() { + return INSTANCE; + } + + /** + * Checks if the given URI's path is on the denylist. + * + * @param uri + * the URI to check (should be file:// scheme) + * @return {@code true} if the path is denied + */ + public boolean isDenied(@NonNull URI uri) { + return getDenialReason(uri) != null; + } + + /** + * Returns the reason why the URI's path is denied, or null if allowed. + * + * @param uri + * the URI to check + * @return denial reason or null + */ + @Nullable + public String getDenialReason(@NonNull URI uri) { + String scheme = uri.getScheme(); + if (scheme == null || !"file".equalsIgnoreCase(scheme)) { + return null; // Not a file URI + } + + String path = uri.getPath(); + if (path == null) { + return null; + } + + // Normalize the path for comparison + String normalizedPath = path.toLowerCase(Locale.ROOT).replace('\\', '/'); + + // Check OS-specific rules + if (IS_WINDOWS) { + return checkWindowsPath(normalizedPath, path); + } + return checkUnixPath(normalizedPath, path); + } + + @Nullable + private String checkUnixPath(String normalizedPath, String originalPath) { + for (String prefix : BLOCKED_UNIX_PREFIXES) { + if (normalizedPath.startsWith(prefix) || normalizedPath.equals(prefix.substring(0, prefix.length() - 1))) { + return "Built-in denylist: sensitive system path '" + originalPath + "'"; + } + } + + for (String pattern : BLOCKED_UNIX_PATTERNS) { + if (normalizedPath.contains(pattern)) { + return "Built-in denylist: credential directory '" + originalPath + "'"; + } + } + + return null; + } + + @Nullable + private String checkWindowsPath(String normalizedPath, String originalPath) { + for (String prefix : BLOCKED_WINDOWS_PREFIXES) { + if (normalizedPath.startsWith(prefix)) { + return "Built-in denylist: sensitive system path '" + originalPath + "'"; + } + } + + for (String pattern : BLOCKED_WINDOWS_PATTERNS) { + if (normalizedPath.contains(pattern)) { + return "Built-in denylist: sensitive directory '" + originalPath + "'"; + } + } + + // Also check Unix patterns on Windows (for consistency) + for (String pattern : BLOCKED_UNIX_PATTERNS) { + if (normalizedPath.contains(pattern)) { + return "Built-in denylist: credential directory '" + originalPath + "'"; + } + } + + return null; + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +mvn -pl core test -Dtest=FileSystemDenylistTest +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylist.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylistTest.java +git commit -m "feat(resolver): add FileSystemDenylist for blocking sensitive paths" +``` + +--- + +### Task 2.3: Create BuiltInDenylist Facade + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylist.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylistTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.net.URI; + +class BuiltInDenylistTest { + + private final BuiltInDenylist denylist = BuiltInDenylist.getInstance(); + + @Test + void testCombinesNetworkAndFileSystemDenylists() { + // Network denial + URI localhost = URI.create("http://localhost/admin"); + assertTrue(denylist.isDenied(localhost)); + assertNotNull(denylist.getDenialReason(localhost)); + + // File system denial (platform-dependent path) + URI etcPasswd = URI.create("file:///etc/passwd"); + // This test only validates on Unix-like systems + if (!System.getProperty("os.name").toLowerCase().contains("windows")) { + assertTrue(denylist.isDenied(etcPasswd)); + } + } + + @Test + void testAllowedUri() { + URI allowed = URI.create("https://pages.nist.gov/schema.xml"); + assertFalse(denylist.isDenied(allowed)); + assertNull(denylist.getDenialReason(allowed)); + } + + @Test + void testAsRule() { + var rule = denylist.asRule(); + + URI blocked = URI.create("http://127.0.0.1/secret"); + assertEquals( + gov.nist.secauto.metaschema.core.model.resolver.IUriAccessRule.RuleResult.DENY, + rule.evaluate(blocked)); + + URI allowed = URI.create("https://example.com/api"); + assertEquals( + gov.nist.secauto.metaschema.core.model.resolver.IUriAccessRule.RuleResult.NO_MATCH, + rule.evaluate(allowed)); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +mvn -pl core test -Dtest=BuiltInDenylistTest -DfailIfNoTests=false +``` + +**Step 3: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import gov.nist.secauto.metaschema.core.model.resolver.IUriAccessRule; + +import java.net.URI; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Combined built-in denylist that aggregates all security denylists. + *

+ * This facade provides a single entry point for checking URIs against all + * built-in security rules. These rules cannot be disabled and are always + * enforced before user-defined allowlists. + */ +public final class BuiltInDenylist { + + private static final BuiltInDenylist INSTANCE = new BuiltInDenylist(); + + private final NetworkDenylist networkDenylist; + private final FileSystemDenylist fileSystemDenylist; + + private BuiltInDenylist() { + this.networkDenylist = NetworkDenylist.getInstance(); + this.fileSystemDenylist = FileSystemDenylist.getInstance(); + } + + /** + * Returns the singleton instance. + * + * @return the built-in denylist instance + */ + @NonNull + public static BuiltInDenylist getInstance() { + return INSTANCE; + } + + /** + * Checks if the given URI is denied by any built-in denylist. + * + * @param uri + * the URI to check + * @return {@code true} if the URI is denied + */ + public boolean isDenied(@NonNull URI uri) { + return getDenialReason(uri) != null; + } + + /** + * Returns the reason why the URI is denied, or null if allowed. + * + * @param uri + * the URI to check + * @return denial reason or null + */ + @Nullable + public String getDenialReason(@NonNull URI uri) { + // Check network denylist first (for http/https URIs) + String reason = networkDenylist.getDenialReason(uri); + if (reason != null) { + return reason; + } + + // Check file system denylist (for file:// URIs) + return fileSystemDenylist.getDenialReason(uri); + } + + /** + * Returns this denylist as an {@link IUriAccessRule}. + *

+ * The returned rule returns {@link IUriAccessRule.RuleResult#DENY} for + * blocked URIs and {@link IUriAccessRule.RuleResult#NO_MATCH} for others + * (allowing subsequent rules to decide). + * + * @return this denylist as a rule + */ + @NonNull + public IUriAccessRule asRule() { + return new IUriAccessRule() { + @Override + @NonNull + public RuleResult evaluate(@NonNull URI uri) { + return isDenied(uri) ? RuleResult.DENY : RuleResult.NO_MATCH; + } + + @Override + @NonNull + public String getDescription() { + return "Built-in security denylist"; + } + }; + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +mvn -pl core test -Dtest=BuiltInDenylistTest +``` + +**Step 5: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylist.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylistTest.java +git commit -m "feat(resolver): add BuiltInDenylist facade combining all security denylists" +``` + +--- + +### Task 2.4: Add package-info.java + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/package-info.java` +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/package-info.java` + +**Step 1: Write implementations** + +```java +// package-info.java for denylist package +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +/** + * Built-in security denylists that are always enforced. + *

+ * These denylists protect against common attack vectors such as: + *

    + *
  • Server-side request forgery (SSRF) to internal services
  • + *
  • Local file inclusion attacks
  • + *
  • Access to cloud metadata endpoints
  • + *
  • Exposure of credential files
  • + *
+ *

+ * The denylists in this package cannot be disabled by user configuration. + */ +@DefaultAnnotationForParameters(NonNull.class) +@DefaultAnnotationForFields(NonNull.class) +package gov.nist.secauto.metaschema.core.model.resolver.denylist; + +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; +import edu.umd.cs.findbugs.annotations.NonNull; +``` + +```java +// package-info.java for resolver package +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +/** + * Allowlist-based URI resolver for secure resource access. + *

+ * This package provides a security layer that validates URIs before allowing + * resource access. It supports: + *

    + *
  • Scheme-based policies (allow/deny by URI scheme)
  • + *
  • File system access rules (directory boundaries)
  • + *
  • HTTP access rules (domain and path restrictions)
  • + *
  • Built-in denylists for common attack vectors
  • + *
+ * + * @see gov.nist.secauto.metaschema.core.model.resolver.IAllowlistUriResolver + * @see gov.nist.secauto.metaschema.core.model.resolver.AllowlistUriResolver + */ +@DefaultAnnotationForParameters(NonNull.class) +@DefaultAnnotationForFields(NonNull.class) +package gov.nist.secauto.metaschema.core.model.resolver; + +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; +import edu.umd.cs.findbugs.annotations.NonNull; +``` + +**Step 2: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/package-info.java +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/package-info.java +git commit -m "docs(resolver): add package-info.java for resolver packages" +``` + +--- + +### Task 2.5: Verify PR2 Build + +```bash +mvn -pl core clean install +mvn -pl core checkstyle:check +``` + +--- + +## PR3: Rule Implementations + +**Goal:** Implement file system, HTTP, and JAR resource rules. + +*(Tasks 3.1-3.4 follow same TDD pattern - FileSystemRule, HttpRule, JarRule, and tests)* + +--- + +## PR4: Main Resolver and Builder + +**Goal:** Implement AllowlistUriResolver and its builder. + +*(Tasks 4.1-4.3 follow same TDD pattern)* + +--- + +## PR5: Configuration System Infrastructure + +**Goal:** Implement layered configuration system with directory discovery and merge support. + +**Package:** `gov.nist.secauto.metaschema.cli.processor.config` + +### Task 5.1: Create IConfigurationService Interface + +**Files:** +- Create: `cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/IConfigurationService.java` + +**Implementation:** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.cli.processor.config; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Service for discovering and loading layered configuration files. + *

+ * Configurations are loaded from multiple locations in precedence order: + *

    + *
  1. Install directory ({@code /config/})
  2. + *
  3. System-wide ({@code /etc/metaschema-cli/} or {@code %ProgramData%\metaschema-cli\})
  4. + *
  5. User home ({@code ~/.metaschema-cli/})
  6. + *
  7. Project local ({@code ./.metaschema/})
  8. + *
  9. CLI argument or environment variable override
  10. + *
+ *

+ * Configurations from all sources are merged according to type-specific rules. + */ +public interface IConfigurationService { + + /** + * Get the merged configuration for a specific config file. + * + * @param configName + * the config file name (e.g., "allowlist.yaml") + * @return the merged configuration as a map, or empty if no configs found + */ + @NonNull + Optional> getConfiguration(@NonNull String configName); + + /** + * Get all discovered config directory paths in precedence order. + * + * @return list of paths (lowest to highest precedence) + */ + @NonNull + List getConfigDirectories(); + + /** + * Reload all configurations from disk. + */ + void reload(); + + /** + * Set an explicit configuration directory override. + *

+ * When set, this takes highest precedence over all other sources. + * + * @param path + * the override directory path, or null to clear + */ + void setOverrideDirectory(Path path); +} +``` + +**Step 2: Commit** + +```bash +git add cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/IConfigurationService.java +git commit -m "feat(config): add IConfigurationService interface for layered configuration" +``` + +--- + +### Task 5.2: Create ConfigurationService Implementation + +**Files:** +- Create: `cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationService.java` +- Test: `cli-processor/src/test/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationServiceTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.cli.processor.config; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +class ConfigurationServiceTest { + + @TempDir + Path tempDir; + + private Path installConfig; + private Path userConfig; + private Path projectConfig; + + @BeforeEach + void setUp() throws IOException { + installConfig = tempDir.resolve("install/config"); + userConfig = tempDir.resolve("user/.metaschema-cli"); + projectConfig = tempDir.resolve("project/.metaschema"); + + Files.createDirectories(installConfig); + Files.createDirectories(userConfig); + Files.createDirectories(projectConfig); + } + + @Test + void testConfigDirectoryDiscovery() { + ConfigurationService service = ConfigurationService.builder() + .withInstallDirectory(installConfig) + .withUserDirectory(userConfig) + .withProjectDirectory(projectConfig) + .build(); + + List dirs = service.getConfigDirectories(); + assertEquals(3, dirs.size()); + assertEquals(installConfig, dirs.get(0)); // Lowest precedence + assertEquals(projectConfig, dirs.get(2)); // Highest precedence + } + + @Test + void testConfigurationLoading() throws IOException { + // Create config file in install directory + Files.writeString(installConfig.resolve("test.yaml"), + "key1: value1\nkey2: value2"); + + ConfigurationService service = ConfigurationService.builder() + .withInstallDirectory(installConfig) + .build(); + + Optional> config = service.getConfiguration("test.yaml"); + assertTrue(config.isPresent()); + assertEquals("value1", config.get().get("key1")); + assertEquals("value2", config.get().get("key2")); + } + + @Test + void testOverrideDirectoryTakesPrecedence() throws IOException { + Path overrideDir = tempDir.resolve("override"); + Files.createDirectories(overrideDir); + + Files.writeString(installConfig.resolve("test.yaml"), "source: install"); + Files.writeString(overrideDir.resolve("test.yaml"), "source: override"); + + ConfigurationService service = ConfigurationService.builder() + .withInstallDirectory(installConfig) + .build(); + + service.setOverrideDirectory(overrideDir); + + Optional> config = service.getConfiguration("test.yaml"); + assertTrue(config.isPresent()); + assertEquals("override", config.get().get("source")); + } + + @Test + void testMissingConfigReturnsEmpty() { + ConfigurationService service = ConfigurationService.builder() + .withInstallDirectory(installConfig) + .build(); + + Optional> config = service.getConfiguration("nonexistent.yaml"); + assertTrue(config.isEmpty()); + } +} +``` + +**Step 2: Run test to verify it fails** + +```bash +mvn -pl cli-processor test -Dtest=ConfigurationServiceTest -DfailIfNoTests=false +``` + +**Step 3: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.cli.processor.config; + +import gov.nist.secauto.metaschema.core.util.CollectionUtil; +import gov.nist.secauto.metaschema.core.util.ObjectUtils; + +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Default implementation of {@link IConfigurationService}. + *

+ * Discovers and loads configuration files from multiple directories, + * merging them according to precedence rules. + */ +public final class ConfigurationService implements IConfigurationService { + + private static final boolean IS_WINDOWS = System.getProperty("os.name", "") + .toLowerCase(Locale.ROOT).contains("windows"); + + @NonNull + private final List configDirectories; + @NonNull + private final Map> configCache; + @Nullable + private Path overrideDirectory; + + private ConfigurationService(@NonNull List directories) { + this.configDirectories = CollectionUtil.unmodifiableList(new ArrayList<>(directories)); + this.configCache = new ConcurrentHashMap<>(); + } + + /** + * Creates a new builder for ConfigurationService. + * + * @return a new builder + */ + @NonNull + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a ConfigurationService with default directory detection. + * + * @return a new ConfigurationService with standard directories + */ + @NonNull + public static ConfigurationService createDefault() { + return builder() + .withDefaultDirectories() + .build(); + } + + @Override + @NonNull + public Optional> getConfiguration(@NonNull String configName) { + // Check cache first + if (configCache.containsKey(configName)) { + Map cached = configCache.get(configName); + return cached.isEmpty() ? Optional.empty() : Optional.of(cached); + } + + // Load and merge from all sources + Map merged = loadAndMerge(configName); + configCache.put(configName, merged); + + return merged.isEmpty() ? Optional.empty() : Optional.of(merged); + } + + @NonNull + private Map loadAndMerge(@NonNull String configName) { + Map result = new LinkedHashMap<>(); + Yaml yaml = new Yaml(); + + // Load from directories in precedence order (lowest to highest) + for (Path dir : getEffectiveDirectories()) { + Path configFile = dir.resolve(configName); + if (Files.exists(configFile) && Files.isRegularFile(configFile)) { + try (InputStream is = Files.newInputStream(configFile)) { + Map loaded = yaml.load(is); + if (loaded != null) { + // Simple shallow merge - higher precedence overwrites + result.putAll(loaded); + } + } catch (IOException e) { + // Log warning and continue + // In production, use SLF4J logging + } + } + } + + return result; + } + + @NonNull + private List getEffectiveDirectories() { + if (overrideDirectory != null) { + // Override takes highest precedence + List dirs = new ArrayList<>(configDirectories); + dirs.add(overrideDirectory); + return dirs; + } + return configDirectories; + } + + @Override + @NonNull + public List getConfigDirectories() { + return configDirectories; + } + + @Override + public void reload() { + configCache.clear(); + } + + @Override + public void setOverrideDirectory(@Nullable Path path) { + this.overrideDirectory = path; + reload(); // Clear cache when override changes + } + + /** + * Builder for ConfigurationService. + */ + public static final class Builder { + private final List directories = new ArrayList<>(); + + private Builder() { + } + + /** + * Add a configuration directory. + * + * @param dir the directory to add + * @return this builder + */ + @NonNull + public Builder withDirectory(@NonNull Path dir) { + if (Files.isDirectory(dir)) { + directories.add(dir); + } + return this; + } + + /** + * Set the install directory. + * + * @param dir the install config directory + * @return this builder + */ + @NonNull + public Builder withInstallDirectory(@NonNull Path dir) { + return withDirectory(dir); + } + + /** + * Set the user directory. + * + * @param dir the user config directory + * @return this builder + */ + @NonNull + public Builder withUserDirectory(@NonNull Path dir) { + return withDirectory(dir); + } + + /** + * Set the project directory. + * + * @param dir the project config directory + * @return this builder + */ + @NonNull + public Builder withProjectDirectory(@NonNull Path dir) { + return withDirectory(dir); + } + + /** + * Configure with default directory detection. + * + * @return this builder + */ + @NonNull + public Builder withDefaultDirectories() { + // 1. Install directory (detect from JAR location) + detectInstallDirectory().ifPresent(this::withDirectory); + + // 2. System-wide + Path systemDir = IS_WINDOWS + ? Path.of(System.getenv("ProgramData"), "metaschema-cli") + : Path.of("/etc/metaschema-cli"); + withDirectory(systemDir); + + // 3. User home + String userHome = System.getProperty("user.home"); + if (userHome != null) { + withDirectory(Path.of(userHome, ".metaschema-cli")); + } + + // 4. Project local (current working directory) + withDirectory(Path.of(".metaschema")); + + return this; + } + + @NonNull + private Optional detectInstallDirectory() { + try { + // Get the JAR location + Path jarPath = Path.of(ConfigurationService.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + + // Look for sibling config directory + Path configDir = jarPath.getParent().getParent().resolve("config"); + if (Files.isDirectory(configDir)) { + return Optional.of(configDir); + } + } catch (Exception e) { + // Ignore - running from IDE or other non-standard location + } + return Optional.empty(); + } + + /** + * Build the ConfigurationService. + * + * @return a new ConfigurationService + */ + @NonNull + public ConfigurationService build() { + return new ConfigurationService(directories); + } + } +} +``` + +**Step 4: Run test to verify it passes** + +```bash +mvn -pl cli-processor test -Dtest=ConfigurationServiceTest +``` + +**Step 5: Commit** + +```bash +git add cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationService.java +git add cli-processor/src/test/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationServiceTest.java +git commit -m "feat(config): add ConfigurationService with directory discovery" +``` + +--- + +### Task 5.3: Add package-info.java + +**Files:** +- Create: `cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/package-info.java` + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +/** + * Layered configuration system for CLI tools. + *

+ * This package provides infrastructure for loading and merging configuration + * files from multiple locations with precedence rules: + *

    + *
  1. Install directory - shipped defaults
  2. + *
  3. System-wide - administrator settings
  4. + *
  5. User home - user preferences
  6. + *
  7. Project local - project-specific overrides
  8. + *
  9. CLI/environment override - explicit override
  10. + *
+ * + * @see gov.nist.secauto.metaschema.cli.processor.config.IConfigurationService + */ +@DefaultAnnotationForParameters(NonNull.class) +@DefaultAnnotationForFields(NonNull.class) +package gov.nist.secauto.metaschema.cli.processor.config; + +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; +import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; +import edu.umd.cs.findbugs.annotations.NonNull; +``` + +--- + +### Task 5.4: Verify PR5 Build + +```bash +mvn -pl cli-processor clean install +mvn -pl cli-processor checkstyle:check +``` + +--- + +## PR6: Allowlist YAML Configuration and Merge + +**Goal:** Implement allowlist-specific YAML configuration with scheme-deep/domain-shallow merge semantics. + +**Package:** `gov.nist.secauto.metaschema.core.model.resolver.config` + +### Task 6.1: Create AllowlistConfigurationMerger + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfigurationMerger.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfigurationMergerTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.core.model.resolver.config; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +class AllowlistConfigurationMergerTest { + + private final AllowlistConfigurationMerger merger = new AllowlistConfigurationMerger(); + + @Test + void testDeepMergeOnScheme() { + // Install config has https rules + Map install = Map.of( + "schemes", Map.of( + "https", Map.of( + "enabled", true, + "rules", List.of( + Map.of("domain", "nist.gov", "paths", List.of("/schemas/")))))); + + // User config adds more https rules + Map user = Map.of( + "schemes", Map.of( + "https", Map.of( + "rules", List.of( + Map.of("domain", "github.com", "paths", List.of("/repos/")))))); + + Map merged = merger.merge(install, user); + + // Both domains should be present (deep merge on scheme) + @SuppressWarnings("unchecked") + Map schemes = (Map) merged.get("schemes"); + @SuppressWarnings("unchecked") + Map https = (Map) schemes.get("https"); + @SuppressWarnings("unchecked") + List> rules = (List>) https.get("rules"); + + assertEquals(2, rules.size()); + assertTrue(rules.stream().anyMatch(r -> "nist.gov".equals(r.get("domain")))); + assertTrue(rules.stream().anyMatch(r -> "github.com".equals(r.get("domain")))); + } + + @Test + void testShallowMergeOnDomain() { + // Install config has nist.gov with /schemas/ + Map install = Map.of( + "schemes", Map.of( + "https", Map.of( + "rules", List.of( + Map.of("domain", "nist.gov", "paths", List.of("/schemas/")))))); + + // User config redefines nist.gov with different paths + Map user = Map.of( + "schemes", Map.of( + "https", Map.of( + "rules", List.of( + Map.of("domain", "nist.gov", "paths", List.of("/docs/", "/api/")))))); + + Map merged = merger.merge(install, user); + + @SuppressWarnings("unchecked") + Map schemes = (Map) merged.get("schemes"); + @SuppressWarnings("unchecked") + Map https = (Map) schemes.get("https"); + @SuppressWarnings("unchecked") + List> rules = (List>) https.get("rules"); + + // Only one nist.gov entry (user's version replaces install's) + List> nistRules = rules.stream() + .filter(r -> "nist.gov".equals(r.get("domain"))) + .toList(); + assertEquals(1, nistRules.size()); + + // User's paths should be present, not install's + @SuppressWarnings("unchecked") + List paths = (List) nistRules.get(0).get("paths"); + assertTrue(paths.contains("/docs/")); + assertTrue(paths.contains("/api/")); + assertFalse(paths.contains("/schemas/")); + } + + @Test + void testEnabledFlagOverride() { + Map install = Map.of( + "schemes", Map.of( + "file", Map.of("enabled", false))); + + Map user = Map.of( + "schemes", Map.of( + "file", Map.of("enabled", true))); + + Map merged = merger.merge(install, user); + + @SuppressWarnings("unchecked") + Map schemes = (Map) merged.get("schemes"); + @SuppressWarnings("unchecked") + Map file = (Map) schemes.get("file"); + + // User's enabled=true should override install's enabled=false + assertEquals(true, file.get("enabled")); + } +} +``` + +*(Implementation follows TDD pattern)* + +--- + +### Task 6.2: Create AllowlistConfiguration POJO + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfiguration.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfigurationTest.java` + +*(Implementation loads YAML into typed configuration objects)* + +--- + +### Task 6.3: Verify PR6 Build + +```bash +mvn -pl core clean install +mvn -pl core checkstyle:check +``` + +--- + +## PR7: Loader Integration + +**Goal:** Integrate allowlist resolver with all loading paths. + +### Task 6.1: Update AbstractModuleLoader + +**Files:** +- Modify: `core/src/main/java/gov/nist/secauto/metaschema/core/model/AbstractModuleLoader.java:88-92` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/AbstractModuleLoaderTest.java` + +**Current code (line ~90):** +```java +URI resolvedResource = ObjectUtils.notNull(resource.resolve(importedResource)); +``` + +**Updated code:** +```java +URI resolvedResource = ObjectUtils.notNull(resource.resolve(importedResource)); +// Apply URI resolver if configured +IUriResolver uriResolver = getUriResolver(); +if (uriResolver != null) { + resolvedResource = uriResolver.resolve(resolvedResource); +} +``` + +### Task 6.2: Update BindingConstraintLoader + +**Files:** +- Modify: `databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoader.java:104,135` + +**Similar pattern to AbstractModuleLoader** + +### Task 6.3: Update DefaultXmlDeserializer + +**Files:** +- Modify: `databind/src/main/java/gov/nist/secauto/metaschema/databind/io/xml/DefaultXmlDeserializer.java:103-118` + +**Updated XMLResolver to use IUriResolver** + +--- + +## PR7: Documentation + +**Goal:** Update documentation and add examples. + +### Task 7.1: Add Usage Documentation + +**Files:** +- Create: `docs/allowlist-resolver.md` + +### Task 7.2: Update README + +**Files:** +- Modify: `README.md` - Add security section + +--- + +## Completion Checklist + +**Phase 1: Core Resolver** +- [ ] Core interfaces and exceptions +- [ ] SchemePolicy implementation +- [ ] Built-in denylist +- [ ] Rule implementations (FileSystem, Http, Jar) +- [ ] AllowlistUriResolver and builder + +**Phase 2: Configuration System** +- [ ] IConfigurationService interface +- [ ] ConfigurationService with directory discovery +- [ ] AllowlistConfigurationMerger +- [ ] AllowlistConfiguration POJO + +**Phase 3: Integration** +- [ ] Loader integration (AbstractModuleLoader, BindingConstraintLoader, DefaultXmlDeserializer) +- [ ] CLI integration (--config-dir, METASCHEMA_CONFIG_DIR) +- [ ] Documentation + +**Final Verification:** +```bash +mvn clean install -PCI -Prelease +``` From b48d7668e7d6130f6ec5f09202450ed5b2080c49 Mon Sep 17 00:00:00 2001 From: David Waltermire Date: Wed, 17 Dec 2025 13:03:16 -0500 Subject: [PATCH 2/4] Update PRD and implementation plan for Metaschema-based config - Define allowlist configuration schema using Metaschema YAML format - Place configuration model in databind-metaschema module for reusability - Add tasks for Maven code generation configuration - Add AllowlistConfigurationLoader using IBoundLoader for multi-format support - Update package structure to gov.nist.secauto.metaschema.databind.metaschema.config --- PRDs/20251217-allowlist-resolver/PRD.md | 270 +++++++++-- .../implementation-plan.md | 424 +++++++++++++++++- 2 files changed, 629 insertions(+), 65 deletions(-) diff --git a/PRDs/20251217-allowlist-resolver/PRD.md b/PRDs/20251217-allowlist-resolver/PRD.md index 2fc4af920c..664487e8da 100644 --- a/PRDs/20251217-allowlist-resolver/PRD.md +++ b/PRDs/20251217-allowlist-resolver/PRD.md @@ -230,65 +230,243 @@ public final class Schemes { } ``` -### YAML Configuration (Scheme-First Hierarchy) +### Metaschema-Based Configuration Model -```yaml -# allowlist-config.yaml -default: deny # deny unlisted schemes +The allowlist configuration uses a Metaschema-defined model, enabling: +- **Type-safe configuration** via generated Java classes +- **Multi-format support** - XML, JSON, or YAML +- **Schema validation** - configs validated against the Metaschema model +- **Dogfooding** - using Metaschema for its own tooling -schemes: - https: - enabled: true - rules: - - domain: pages.nist.gov - paths: any - - domain: raw.githubusercontent.com - paths: - - prefix: /metaschema-framework/ - - prefix: /usnistgov/OSCAL/ - - domain: "*.nist.gov" - paths: - - prefix: /schemas/ - - http: - enabled: false +**Metaschema Module Definition** (`allowlist-config_metaschema.yaml`): - file: - enabled: true # set to false for server mode - rules: - - path: /data/schemas - scope: recursive - - path: /app/config - scope: single-level +```yaml +metaschema: + schema-name: Allowlist Configuration + schema-version: 1.0.0 + short-name: allowlist-config + namespace: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 + json-base-uri: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 + + definitions: + - define-assembly: + name: allowlist-config + formal-name: Allowlist Configuration + description: Configuration for the allowlist URI resolver. + root-name: allowlist-config + flags: + - define-flag: + name: default-policy + as-type: token + formal-name: Default Policy + description: Default policy for unlisted schemes. + constraint: + allowed-values: + - enum: + value: allow + description: Allow unlisted schemes + - enum: + value: deny + description: Deny unlisted schemes + model: + - assembly: + ref: scheme-config + max-occurs: unbounded + group-as: + name: schemes + in-json: BY_KEY + - assembly: + ref: logging-config + min-occurs: 0 + + - define-assembly: + name: scheme-config + formal-name: Scheme Configuration + description: Configuration for a specific URI scheme. + json-key: + flag-ref: scheme + flags: + - define-flag: + name: scheme + as-type: token + required: yes + formal-name: URI Scheme + description: The URI scheme (e.g., https, http, file, jar). + - define-flag: + name: enabled + as-type: boolean + formal-name: Enabled + description: Whether this scheme is enabled. + model: + - choice: + - assembly: + ref: http-rule + max-occurs: unbounded + group-as: + name: http-rules + in-json: ARRAY + - assembly: + ref: file-rule + max-occurs: unbounded + group-as: + name: file-rules + in-json: ARRAY + - assembly: + ref: jar-rule + max-occurs: unbounded + group-as: + name: jar-rules + in-json: ARRAY + + - define-assembly: + name: http-rule + formal-name: HTTP Rule + description: Access rule for HTTP/HTTPS URIs. + flags: + - define-flag: + name: domain + as-type: string + required: yes + formal-name: Domain + description: Domain pattern (e.g., "example.com", "*.nist.gov"). + model: + - field: + ref: path-prefix + max-occurs: unbounded + group-as: + name: paths + in-json: ARRAY + + - define-assembly: + name: file-rule + formal-name: File Rule + description: Access rule for file:// URIs. + flags: + - define-flag: + name: path + as-type: string + required: yes + formal-name: Path + description: Base directory path. + - define-flag: + name: scope + as-type: token + formal-name: Scope + description: Access scope for the directory. + constraint: + allowed-values: + - enum: + value: recursive + description: Allow recursive access + - enum: + value: single-level + description: Allow single level only + + - define-assembly: + name: jar-rule + formal-name: JAR Rule + description: Access rule for jar: URIs. + flags: + - define-flag: + name: path + as-type: string + required: yes + formal-name: Path + description: Resource path pattern within JAR. + + - define-field: + name: path-prefix + as-type: string + formal-name: Path Prefix + description: Allowed path prefix. + + - define-assembly: + name: logging-config + formal-name: Logging Configuration + description: Audit logging settings. + flags: + - define-flag: + name: level + as-type: token + formal-name: Log Level + description: Minimum log level for access attempts. + - define-flag: + name: include-allowed + as-type: boolean + formal-name: Include Allowed + description: Whether to log allowed access attempts. +``` - jar: - enabled: true - rules: - - path: /schema/ - - path: /META-INF/metaschema/ +**Example Configuration Files:** -# Audit logging configuration -logging: - level: WARN - include_allowed: false +YAML format (`allowlist.yaml`): +```yaml +allowlist-config: + default-policy: deny + schemes: + - scheme: https + enabled: true + http-rules: + - domain: pages.nist.gov + paths: [/schemas/, /examples/] + - domain: raw.githubusercontent.com + paths: [/metaschema-framework/, /usnistgov/OSCAL/] + - scheme: http + enabled: false + - scheme: file + enabled: true + file-rules: + - path: /data/schemas + scope: recursive + - scheme: jar + enabled: true + jar-rules: + - path: /schema/ + - path: /META-INF/metaschema/ + logging-config: + level: WARN + include-allowed: false +``` + +JSON format (`allowlist.json`): +```json +{ + "allowlist-config": { + "default-policy": "deny", + "schemes": { + "https": { + "enabled": true, + "http-rules": [ + { "domain": "pages.nist.gov", "paths": ["/schemas/"] } + ] + }, + "file": { + "enabled": false + } + } + } +} ``` ### Loading Configuration ```java -// From file -AllowlistUriResolver resolver = AllowlistUriResolver - .fromYaml(Path.of("/etc/metaschema/allowlist.yaml")); +// Using databind to load configuration +IBindingContext bindingContext = IBindingContext.instance(); +IBoundLoader loader = bindingContext.newBoundLoader(); -// From classpath resource -AllowlistUriResolver resolver = AllowlistUriResolver - .fromYaml(getClass().getResourceAsStream("/allowlist-config.yaml")); +// From file (auto-detects format: XML, JSON, or YAML) +AllowlistConfig config = loader.load(AllowlistConfig.class, + Path.of("/etc/metaschema-cli/allowlist.yaml")); -// From environment variable -String configPath = System.getenv("METASCHEMA_ALLOWLIST_CONFIG"); -if (configPath != null) { +// Create resolver from loaded config +AllowlistUriResolver resolver = AllowlistUriResolver.fromConfiguration(config); + +// From classpath resource +try (InputStream is = getClass().getResourceAsStream("/allowlist.yaml")) { + AllowlistConfig config = loader.load(AllowlistConfig.class, is); AllowlistUriResolver.setGlobalDefaults( - AllowlistUriResolver.fromYaml(Path.of(configPath))); + AllowlistUriResolver.fromConfiguration(config)); } ``` diff --git a/PRDs/20251217-allowlist-resolver/implementation-plan.md b/PRDs/20251217-allowlist-resolver/implementation-plan.md index f81e6e838e..6702f2c8e6 100644 --- a/PRDs/20251217-allowlist-resolver/implementation-plan.md +++ b/PRDs/20251217-allowlist-resolver/implementation-plan.md @@ -28,17 +28,19 @@ Single PR with multiple commits organized by feature area. 7. AllowlistUriResolver and builder **Phase 2: Configuration System** -8. IConfigurationService interface -9. ConfigurationService implementation with directory discovery -10. AllowlistConfigurationMerger (deep merge on scheme, shallow on domain) -11. AllowlistConfiguration POJO and YAML loading +8. Create Metaschema module definition (`allowlist-config_metaschema.yaml`) +9. Configure metaschema-maven-plugin for code generation in cli-processor pom.xml +10. IConfigurationService interface +11. ConfigurationService implementation with directory discovery +12. AllowlistConfigurationMerger (deep merge on scheme, shallow on domain) +13. AllowlistConfiguration binding class integration with IBoundLoader **Phase 3: Integration** -12. Update AbstractModuleLoader to use IUriResolver -13. Update BindingConstraintLoader to use IUriResolver -14. Update DefaultXmlDeserializer to use IUriResolver -15. CLI integration (--config-dir option, METASCHEMA_CONFIG_DIR env var) -16. Documentation and examples +14. Update AbstractModuleLoader to use IUriResolver +15. Update BindingConstraintLoader to use IUriResolver +16. Update DefaultXmlDeserializer to use IUriResolver +17. CLI integration (--config-dir option, METASCHEMA_CONFIG_DIR env var) +18. Documentation and examples --- @@ -1461,7 +1463,241 @@ mvn -pl core checkstyle:check ## PR5: Configuration System Infrastructure -**Goal:** Implement layered configuration system with directory discovery and merge support. +**Goal:** Implement layered configuration system with directory discovery, Metaschema-based configuration model, and merge support. + +**Module:** `databind-metaschema` (for config model and loading), `cli-processor` (for directory discovery service) + +### Task 5.0: Create Metaschema Configuration Model + +**Files:** +- Create: `databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml` + +**Step 1: Create the Metaschema module file** + +Create the directory structure and Metaschema module definition: + +```bash +mkdir -p databind-metaschema/src/main/metaschema +``` + +```yaml +# databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml +metaschema: + schema-name: Allowlist Configuration + schema-version: 1.0.0 + short-name: allowlist-config + namespace: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 + json-base-uri: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 + + definitions: + - define-assembly: + name: allowlist-config + formal-name: Allowlist Configuration + description: Configuration for the allowlist URI resolver. + root-name: allowlist-config + flags: + - define-flag: + name: default-policy + as-type: token + formal-name: Default Policy + description: Default policy for unlisted schemes. + constraint: + allowed-values: + - enum: + value: allow + description: Allow unlisted schemes + - enum: + value: deny + description: Deny unlisted schemes + model: + - assembly: + ref: scheme-config + max-occurs: unbounded + group-as: + name: schemes + in-json: BY_KEY + - assembly: + ref: logging-config + min-occurs: 0 + + - define-assembly: + name: scheme-config + formal-name: Scheme Configuration + description: Configuration for a specific URI scheme. + json-key: + flag-ref: scheme + flags: + - define-flag: + name: scheme + as-type: token + required: yes + formal-name: URI Scheme + description: The URI scheme (e.g., https, http, file, jar). + - define-flag: + name: enabled + as-type: boolean + formal-name: Enabled + description: Whether this scheme is enabled. + model: + - choice: + - assembly: + ref: http-rule + max-occurs: unbounded + group-as: + name: http-rules + in-json: ARRAY + - assembly: + ref: file-rule + max-occurs: unbounded + group-as: + name: file-rules + in-json: ARRAY + - assembly: + ref: jar-rule + max-occurs: unbounded + group-as: + name: jar-rules + in-json: ARRAY + + - define-assembly: + name: http-rule + formal-name: HTTP Rule + description: Access rule for HTTP/HTTPS URIs. + flags: + - define-flag: + name: domain + as-type: string + required: yes + formal-name: Domain + description: Domain pattern (e.g., "example.com", "*.nist.gov"). + model: + - field: + ref: path-prefix + max-occurs: unbounded + group-as: + name: paths + in-json: ARRAY + + - define-assembly: + name: file-rule + formal-name: File Rule + description: Access rule for file:// URIs. + flags: + - define-flag: + name: path + as-type: string + required: yes + formal-name: Path + description: Base directory path. + - define-flag: + name: scope + as-type: token + formal-name: Scope + description: Access scope for the directory. + constraint: + allowed-values: + - enum: + value: recursive + description: Allow recursive access + - enum: + value: single-level + description: Allow single level only + + - define-assembly: + name: jar-rule + formal-name: JAR Rule + description: Access rule for jar: URIs. + flags: + - define-flag: + name: path + as-type: string + required: yes + formal-name: Path + description: Resource path pattern within JAR. + + - define-field: + name: path-prefix + as-type: string + formal-name: Path Prefix + description: Allowed path prefix. + + - define-assembly: + name: logging-config + formal-name: Logging Configuration + description: Audit logging settings. + flags: + - define-flag: + name: level + as-type: token + formal-name: Log Level + description: Minimum log level for access attempts. + - define-flag: + name: include-allowed + as-type: boolean + formal-name: Include Allowed + description: Whether to log allowed access attempts. +``` + +**Step 2: Commit** + +```bash +git add databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml +git commit -m "feat(config): add Metaschema module for allowlist configuration" +``` + +--- + +### Task 5.0b: Configure Maven Code Generation + +**Files:** +- Modify: `databind-metaschema/pom.xml` + +**Step 1: Add metaschema-maven-plugin configuration** + +The `databind-metaschema` module already has metaschema-maven-plugin configured. Add the allowlist config module to the existing configuration by ensuring the `metaschemaDir` includes our new module: + +```xml + + + gov.nist.secauto.metaschema + metaschema-maven-plugin + + + generate-sources + + generate-sources + + + ${project.basedir}/src/main/metaschema + + + + + +``` + +**Step 2: Verify code generation** + +```bash +mvn -pl databind-metaschema generate-sources +``` + +Expected: Generated Java classes in `databind-metaschema/target/generated-sources/metaschema/` including: +- `AllowlistConfig.java` +- `SchemeConfig.java` +- `HttpRule.java` +- `FileRule.java` +- `JarRule.java` +- `LoggingConfig.java` + +**Step 3: Commit (if pom.xml changes needed)** + +```bash +git add databind-metaschema/pom.xml +git commit -m "build(databind): configure code generation for allowlist config module" +``` + +--- **Package:** `gov.nist.secauto.metaschema.cli.processor.config` @@ -1982,17 +2218,165 @@ mvn -pl cli-processor checkstyle:check --- -## PR6: Allowlist YAML Configuration and Merge +## PR6: Allowlist Configuration Loading and Merge + +**Goal:** Implement allowlist-specific configuration loading using Metaschema-generated binding classes with scheme-deep/domain-shallow merge semantics. + +**Module:** `databind-metaschema` + +**Package:** `gov.nist.secauto.metaschema.databind.metaschema.config` + +### Task 6.0: Create AllowlistConfigurationLoader + +**Files:** +- Create: `databind-metaschema/src/main/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoader.java` +- Test: `databind-metaschema/src/test/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoaderTest.java` + +**Step 1: Write the failing test** + +```java +package gov.nist.secauto.metaschema.databind.metaschema.config; + +import static org.junit.jupiter.api.Assertions.*; + +import gov.nist.secauto.metaschema.databind.IBindingContext; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +class AllowlistConfigurationLoaderTest { + + private static IBindingContext bindingContext; -**Goal:** Implement allowlist-specific YAML configuration with scheme-deep/domain-shallow merge semantics. + @TempDir + Path tempDir; + + @BeforeAll + static void setUp() throws Exception { + bindingContext = IBindingContext.newInstance(); + } + + @Test + void testLoadYamlConfiguration() throws IOException { + Path configFile = tempDir.resolve("allowlist.yaml"); + Files.writeString(configFile, """ + default-policy: deny + schemes: + https: + enabled: true + http-rules: + - domain: nist.gov + paths: + - /schemas/ + """); + + AllowlistConfigurationLoader loader = new AllowlistConfigurationLoader(bindingContext); + AllowlistConfig config = loader.load(configFile); + + assertNotNull(config); + assertEquals("deny", config.getDefaultPolicy()); + assertNotNull(config.getSchemes()); + } + + @Test + void testLoadJsonConfiguration() throws IOException { + Path configFile = tempDir.resolve("allowlist.json"); + Files.writeString(configFile, """ + { + "default-policy": "allow", + "schemes": { + "file": { + "enabled": false + } + } + } + """); + + AllowlistConfigurationLoader loader = new AllowlistConfigurationLoader(bindingContext); + AllowlistConfig config = loader.load(configFile); + + assertNotNull(config); + assertEquals("allow", config.getDefaultPolicy()); + } +} +``` + +**Step 2: Write implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.databind.metaschema.config; + +import gov.nist.secauto.metaschema.databind.IBindingContext; +import gov.nist.secauto.metaschema.databind.io.IBoundLoader; + +import java.io.IOException; +import java.nio.file.Path; -**Package:** `gov.nist.secauto.metaschema.core.model.resolver.config` +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Loads allowlist configuration files using Metaschema databind. + *

+ * Supports loading from YAML, JSON, or XML formats based on file extension. + */ +public class AllowlistConfigurationLoader { + + @NonNull + private final IBoundLoader loader; + + /** + * Creates a new configuration loader. + * + * @param bindingContext + * the binding context for deserialization + */ + public AllowlistConfigurationLoader(@NonNull IBindingContext bindingContext) { + this.loader = bindingContext.newBoundLoader(); + } + + /** + * Loads an allowlist configuration from a file. + *

+ * The format is auto-detected from the file extension. + * + * @param configFile + * the path to the configuration file + * @return the loaded configuration + * @throws IOException + * if an error occurs reading the file + */ + @NonNull + public AllowlistConfig load(@NonNull Path configFile) throws IOException { + return loader.load(AllowlistConfig.class, configFile); + } +} +``` + +**Step 3: Commit** + +```bash +git add databind-metaschema/src/main/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoader.java +git add databind-metaschema/src/test/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoaderTest.java +git commit -m "feat(config): add AllowlistConfigurationLoader using databind" +``` + +--- ### Task 6.1: Create AllowlistConfigurationMerger **Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfigurationMerger.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfigurationMergerTest.java` +- Create: `databind-metaschema/src/main/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationMerger.java` +- Test: `databind-metaschema/src/test/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationMergerTest.java` **Step 1: Write the failing test** @@ -2194,10 +2578,12 @@ if (uriResolver != null) { - [ ] AllowlistUriResolver and builder **Phase 2: Configuration System** -- [ ] IConfigurationService interface -- [ ] ConfigurationService with directory discovery -- [ ] AllowlistConfigurationMerger -- [ ] AllowlistConfiguration POJO +- [ ] Metaschema module definition (`databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml`) +- [ ] Maven code generation verification +- [ ] IConfigurationService interface (`cli-processor`) +- [ ] ConfigurationService with directory discovery (`cli-processor`) +- [ ] AllowlistConfigurationLoader (`databind-metaschema`) +- [ ] AllowlistConfigurationMerger (`databind-metaschema`) **Phase 3: Integration** - [ ] Loader integration (AbstractModuleLoader, BindingConstraintLoader, DefaultXmlDeserializer) From 7e14f92bc1a8eddeb536e42508ce6f0903c2468b Mon Sep 17 00:00:00 2001 From: David Waltermire Date: Sun, 8 Feb 2026 15:50:25 -0500 Subject: [PATCH 3/4] refactor: redesign as Resource Access Policy with glob patterns and audit mode Redesign based on PR review feedback: - Replace dual allowlist+denylist with .gitignore-style glob patterns using ! negation, organized by URI scheme - Add graduated enforcement modes (DISABLED/AUDIT/ENFORCE) with AUDIT as default for backwards-compatible rollout - Add FileProtections allow-list gate for file: scheme providing defense-in-depth (defaults: CWD + home minus sensitive dot-dirs) - FileProtections API supports customization: includeDefaults(), allow(), remove(), none() - Policy set on loader interfaces (IModuleLoader.setResourceAccessPolicy) - All policy engine code in core module - Metaschema-based configuration model for type-safe config files - Ship bundled restrictive defaults in audit mode - Rename from AllowlistUriResolver to ResourceAccessPolicy --- PRDs/20251217-allowlist-resolver/PRD.md | 1111 ++++--- .../implementation-plan.md | 2886 ++++------------- 2 files changed, 1226 insertions(+), 2771 deletions(-) diff --git a/PRDs/20251217-allowlist-resolver/PRD.md b/PRDs/20251217-allowlist-resolver/PRD.md index 664487e8da..d71d49703b 100644 --- a/PRDs/20251217-allowlist-resolver/PRD.md +++ b/PRDs/20251217-allowlist-resolver/PRD.md @@ -1,12 +1,12 @@ -# Allowlist URI Resolver PRD +# Resource Access Policy PRD **Issue:** [#183 - Add new allowlist-only resolver for loading models, instances, and dynamic model generation](https://github.com/metaschema-framework/metaschema-java/issues/183) -**Goal:** Provide a secure-by-default URI resolver that restricts resource access to explicitly allowed directories, domains, and URI schemes - preventing local file inclusion, SSRF, and other resource access attacks. +**Goal:** Provide a policy-based URI resolver that controls resource access using glob patterns, with graduated enforcement modes (disabled, audit, enforce) for low-impact, backwards-compatible deployment. -**Architecture:** Implement `IAllowlistUriResolver` extending `IUriResolver` with hierarchical rules (scheme → file/HTTP-specific policies). Integrate at all resolution points: module loading, document loading, constraint loading, and XML entity resolution. Defense-in-depth via user-defined allowlist plus always-enforced built-in denylist. +**Architecture:** Implement a `ResourceAccessPolicy` in the `core` module using `.gitignore`-style glob patterns grouped by URI scheme. Integrate at loader level (`IModuleLoader`, `IBoundLoader`) with configurable enforcement modes. Default: restrictive rules in audit mode (log violations but allow all requests). -**Tech Stack:** Java 11, existing Metaschema core interfaces, YAML (SnakeYAML) for configuration files, SLF4J for audit logging. +**Tech Stack:** Java 11, existing Metaschema core interfaces, Metaschema-based configuration model, SLF4J for audit logging. --- @@ -17,62 +17,291 @@ As a developer of Metaschema-based tooling deploying services, I need a resolver 2. Restricts access to an allowlist of remote HTTP services 3. Prevents SSRF attacks to internal services (localhost, cloud metadata endpoints) 4. Prevents local file inclusion attacks (directory traversal, sensitive system files) +5. Can be deployed gradually (audit first, enforce later) without breaking existing workflows +6. Works identically in CLI and library-based deployments ### Security Threats Addressed | Threat | Attack Vector | Mitigation | |--------|--------------|------------| -| Local File Inclusion | `../../../etc/passwd` in imports | File path normalization + base directory validation | -| SSRF to Internal Services | `http://localhost:8080/admin` | Built-in denylist for localhost, private IPs | -| Cloud Metadata Access | `http://169.254.169.254/` | Built-in denylist for link-local addresses | -| XXE Attacks | XML entity resolution to arbitrary URLs | Route entity resolution through allowlist | -| Scheme Injection | `file://`, `ftp://`, `gopher://` | Scheme allowlist (default: https only) | +| Local File Inclusion | `../../../etc/passwd` in imports | Path normalization + pattern-based access control | +| SSRF to Internal Services | `http://localhost:8080/admin` | Default-deny for `http` scheme patterns | +| Cloud Metadata Access | `http://169.254.169.254/` | Default-deny for private/link-local patterns | +| XXE Attacks | XML entity resolution to arbitrary URLs | Route entity resolution through policy | +| Scheme Injection | `file://`, `ftp://`, `gopher://` | Scheme-level allow/deny with glob patterns | --- ## Design Decisions -### 1. Primary Use Cases -- **Server/API deployment**: Untrusted users submitting URIs for validation -- **Library security**: Secure defaults for developers integrating the library -- **CLI hardening**: Command-line tools processing user-provided files - -### 2. Configuration Model -- **Programmatic API**: Builder pattern for library integrations -- **File-based**: YAML configuration for deployments -- **Hierarchical**: Global defaults with per-loader overrides -- **Secure defaults**: Deny all schemes except https; require explicit allowlist - -### 3. Rule Granularity -- **Scheme policies**: Allow/deny by URI scheme (file, http, https, jar) -- **File system rules**: Base directory + recursive/single-level scope -- **HTTP rules**: Domain allowlist + optional path prefix restrictions -- **JAR resources**: Path patterns within JAR files - -### 4. Defense in Depth -- **User allowlist**: Explicit permissions required -- **Built-in denylist**: Always enforced, cannot be disabled - - Localhost and loopback addresses - - Private IP ranges (10.x, 172.16-31.x, 192.168.x) - - Link-local addresses (169.254.x.x - cloud metadata) - - Sensitive system paths (/etc/, /proc/, /sys/, C:\Windows\) - -### 5. Access Denied Behavior -- **Default**: Throw `AccessDeniedException` with clear message -- **Configurable**: Custom handler for alternative behavior -- **Audit logging**: Always log blocked attempts via SLF4J - -### 6. Integration Points -All resolution paths route through the allowlist resolver: +### 1. Glob Pattern Model (`.gitignore`-style) + +Patterns use familiar `.gitignore` glob syntax with `!` negation: + +- **Allow patterns** define what resources are accessible +- **`!` patterns** create exceptions (deny previously allowed resources) +- Patterns are evaluated **in order**, last match wins +- Patterns are organized **by scheme** (file, https, http, jar) + +This replaces the previous allowlist+denylist dual model with a single, unified pattern list per scheme. Users familiar with `.gitignore` can immediately understand and author policies. + +### 2. Enforcement Modes + +Three graduated enforcement levels for safe rollout: + +| Mode | Behavior | Use Case | +|------|----------|----------| +| `DISABLED` | No policy checking; all URIs allowed | Legacy behavior, maximum compatibility | +| `AUDIT` | Check policy, **log violations**, but **allow** all requests | Migration period, discovering needed rules | +| `ENFORCE` | Check policy, **block** violations with exception | Production hardened | + +**Default mode:** `AUDIT` — provides visibility into what would be blocked without breaking existing workflows. + +The mode is configurable via: +- **API:** `ResourceAccessPolicy.builder().mode(PolicyMode.AUDIT)` +- **Config file:** `mode: audit` in the policy configuration +- **CLI flag:** `--resource-policy-mode=enforce` + +### 3. Scheme-Based Pattern Organization + +Patterns are grouped by URI scheme for clarity and to avoid ambiguity: + +```yaml +resource-access-policy: + mode: audit + schemes: + - scheme: https + patterns: + - "pages.nist.gov/**" + - "raw.githubusercontent.com/metaschema-framework/**" + - "!*.internal/**" + - scheme: http + enabled: false + - scheme: file + patterns: + - "/workspace/**" + - "/data/schemas/**" + - "!**/.ssh/**" + - "!**/.aws/**" + - scheme: jar + patterns: + - "/schema/**" + - "/META-INF/metaschema/**" +``` + +Within each scheme section: +- `enabled: false` disables the entire scheme (deny all) +- `enabled: true` (default) enables pattern matching +- If patterns are present, only matching URIs are allowed +- If no patterns are present and enabled is true, all URIs for that scheme are allowed +- `!` patterns create exceptions within the allowed set + +### 4. File System Protections (Defense-in-Depth) + +The `file` scheme ships with a **default allow-list of safe path patterns**, providing defense-in-depth against misconfiguration. File protections are checked **before** user-defined scheme patterns — a path must be allowed by file protections before scheme patterns are evaluated. This can be adjusted via the API. + +**Model:** File protections use an allow-list approach. Only paths matching an allow pattern are permitted; everything else is denied. This is the inverse of the scheme-level glob patterns — protections define a **floor** of safe paths. + +**Default allow patterns (shipped with the library):** + +All platforms: +- `/**` — current working directory subtree (resolved at policy creation time) +- `/**` — user's home directory subtree +- `!/.ssh/**` — except SSH keys +- `!/.aws/**` — except AWS credentials +- `!/.gnupg/**` — except GPG keys +- `!**/Library/Keychains/**` — except macOS keychains +- `!**/Library/Application Support/com.apple.TCC/**` — except macOS privacy DB +- `!**/AppData/**` — except Windows AppData + +Note: `` and `` are resolved to absolute paths at policy creation time, not treated as literal patterns. + +**What the defaults block (by omission):** + +Since only the CWD and home directory subtrees are allowed, paths like these are denied automatically: +- `/etc/**`, `/proc/**`, `/sys/**`, `/dev/**` — system directories +- `/root/**` — root home (unless CWD is there) +- `C:/Windows/**` — Windows system directory +- Any path outside CWD and home + +**Behavior by mode:** + +| Mode | File protection behavior | +|------|--------------------------| +| `DISABLED` | Not checked (policy is fully off) | +| `AUDIT` | Checked, violations logged as WARN, request **allowed** | +| `ENFORCE` | Checked, violations **blocked** with `AccessViolationException` | + +**API for adjusting the protection list:** + +```java +// Default behavior: CWD subtree + home (minus sensitive dot-dirs) +// are allowed. Everything else denied. +ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file") + .allow("/workspace/**") + .build(); +// file:///workspace/schema.xml → ALLOWED +// file:///etc/passwd → DENIED (not in file protections allow-list) + +// Inspect the default allow patterns +List defaults = FileProtections.defaultAllowPatterns(); + +// Customize: start from defaults and allow additional paths +ResourceAccessPolicy custom = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .fileProtections(FileProtections.builder() + .includeDefaults() // CWD + home minus sensitive dirs + .allow("/opt/metaschema/**") // add another safe area + .allow("/data/schemas/**") // add another safe area + .build()) + .forScheme("file") + .allow("/workspace/**") + .build(); + +// Customize: start from defaults but narrow scope +ResourceAccessPolicy tighter = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .fileProtections(FileProtections.builder() + .includeDefaults() + .remove("/**") // remove home dir access + .build()) + .forScheme("file") + .allow("/workspace/**") + .build(); + +// Completely replace: no defaults, fully custom safe list +ResourceAccessPolicy fullyCustom = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .fileProtections(FileProtections.builder() + .allow("/opt/app/**") // only this directory tree + .build()) // no defaults included + .forScheme("file") + .allow("/opt/app/schemas/**") + .build(); + +// Disable file protections entirely (not recommended) +ResourceAccessPolicy noProtections = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .fileProtections(FileProtections.none()) + .forScheme("file") + .allow("/workspace/**") + .build(); +``` + +**Evaluation order:** File protections are checked **before** user-defined scheme patterns. The flow for a `file:` URI is: +1. Check file protections allow-list (is the path in a safe area?) +2. If denied by file protections → apply mode behavior (log/block), stop +3. If allowed by file protections → check user's scheme patterns (last match wins) +4. If no scheme pattern matches → use `default-scheme-policy` + +This means file protections act as a gate — a path must be in a safe area before user scheme patterns are even considered. + +### 5. Default Bundled Policy + +The library ships with a **restrictive default policy in audit mode**: + +```yaml +resource-access-policy: + mode: audit + default-scheme-policy: deny + schemes: + - scheme: https + patterns: + - "**" + - "!localhost/**" + - "!127.*/**" + - "!10.*/**" + - "!172.16.*/**" + - "!172.17.*/**" + - "!172.18.*/**" + - "!172.19.*/**" + - "!172.2?.*/**" + - "!172.30.*/**" + - "!172.31.*/**" + - "!192.168.*/**" + - "!169.254.*/**" + - "![::1]/**" + - "!metadata.google.internal/**" + - scheme: http + enabled: false + - scheme: file + patterns: + - "**" + - "!/etc/**" + - "!/proc/**" + - "!/sys/**" + - "!/dev/**" + - "!/root/**" + - "!**/.ssh/**" + - "!**/.aws/**" + - "!**/.gnupg/**" + - "!/var/run/**" + - "!C:/Windows/**" + - "!**/AppData/**" + - scheme: jar + patterns: + - "**" +``` + +In `AUDIT` mode (the default), this logs every URI access that would fail these rules but allows the request to proceed. Users can review logs to understand their access patterns before switching to `ENFORCE`. + +### 6. Policy on Loader (Library API) + +For library users, policy is set on the loader: + +```java +// Create a policy +ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.AUDIT) + .forScheme("https") + .allow("pages.nist.gov/**") + .allow("raw.githubusercontent.com/metaschema-framework/**") + .forScheme("file") + .allow("/workspace/schemas/**") + .deny("**/.ssh/**") + .forScheme("jar") + .allowAll() + .defaultDeny() + .build(); + +// Set on loader +IModuleLoader loader = ...; +loader.setResourceAccessPolicy(policy); + +// Or use bundled defaults +loader.setResourceAccessPolicy(ResourceAccessPolicy.bundledDefaults()); + +// Override mode +loader.setResourceAccessPolicy( + ResourceAccessPolicy.bundledDefaults() + .withMode(PolicyMode.ENFORCE)); +``` + +This keeps the API on the loader itself, making it discoverable and natural for library users. The `IUriResolver` interface remains unchanged for backwards compatibility. + +### 7. All in Core Module + +The entire policy engine lives in `core`: +- Policy model, pattern matching, enforcement +- Metaschema configuration model and loading +- Default bundled policy + +CLI-specific concerns (CLI flags, environment variables) are handled in `cli-processor`/`metaschema-cli` but delegate to the core API. + +### 8. Integration Points + +All resolution paths check the policy: | Component | Current Behavior | Change Required | |-----------|-----------------|-----------------| -| `DefaultBoundLoader` | Uses `IUriResolver` | None - already integrated | -| `AbstractModuleLoader` | Raw `URI.resolve()` for imports | Route through `IUriResolver` | -| `BindingConstraintLoader` | Raw `URI.resolve()` for imports | Route through `IUriResolver` | -| `DefaultXmlDeserializer` | Custom `XMLResolver` for entities | Use `IUriResolver` | -| `DefaultJsonDeserializer` | Reads from provided `Reader` | None - uses loader with allowlist | -| `DefaultYamlDeserializer` | Reads from provided `Reader` | None - uses loader with allowlist | +| `DefaultBoundLoader` | Uses `IUriResolver` | Add policy check before resolution | +| `AbstractModuleLoader` | Raw `URI.resolve()` for imports | Add policy check | +| `BindingConstraintLoader` | Raw `URI.resolve()` for imports | Add policy check | +| `DefaultXmlDeserializer` | Custom `XMLResolver` for entities | Route through policy | +| `DefaultJsonDeserializer` | Reads from provided `Reader` | None - uses loader with policy | +| `DefaultYamlDeserializer` | Reads from provided `Reader` | None - uses loader with policy | --- @@ -82,35 +311,28 @@ All resolution paths route through the allowlist resolver: ```text ┌─────────────────────────────────────────────────────────────────┐ -│ IAllowlistUriResolver │ -│ (extends IUriResolver) │ +│ ResourceAccessPolicy │ ├─────────────────────────────────────────────────────────────────┤ │ ┌─────────────────┐ ┌──────────────────────────────────────┐ │ -│ │ SchemePolicy │ │ ResourceRules │ │ +│ │ PolicyMode │ │ SchemePatterns │ │ │ │ ───────────── │ │ ──────────────────────────────── │ │ -│ │ file: DENY │ │ FileSystemRules: │ │ -│ │ http: DENY │ │ - baseDirs with scope │ │ -│ │ https: ALLOW │ │ - path patterns │ │ -│ │ jar: ALLOW │ │ HttpRules: │ │ -│ │ │ │ - domain allowlist │ │ -│ │ │ │ - path prefix restrictions │ │ -│ │ │ │ JarRules: │ │ -│ │ │ │ - allowed resource paths │ │ +│ │ DISABLED │ │ file: │ │ +│ │ AUDIT │ │ allow: /workspace/** │ │ +│ │ ENFORCE │ │ deny: **/.ssh/** │ │ +│ │ │ │ https: │ │ +│ │ │ │ allow: pages.nist.gov/** │ │ +│ │ │ │ deny: localhost/** │ │ +│ │ │ │ http: │ │ +│ │ │ │ (disabled) │ │ +│ │ │ │ jar: │ │ +│ │ │ │ allow: ** │ │ │ └─────────────────┘ └──────────────────────────────────────┘ │ │ ┌───────────────────────────────────────────────────────────┐ │ -│ │ BuiltInDenylist (always enforced) │ │ -│ │ ─────────────────────────────────────────────────────── │ │ -│ │ Network: localhost, 127.*, 10.*, 172.16-31.*, 192.168.* │ │ -│ │ 169.254.* (cloud metadata), [::1], etc. │ │ -│ │ Filesystem: /etc/, /proc/, /sys/, /dev/, ~/.ssh/ │ │ -│ │ C:\Windows\, C:\Users\*\AppData\ │ │ -│ └───────────────────────────────────────────────────────────┘ │ -│ ┌───────────────────────────────────────────────────────────┐ │ -│ │ AccessDeniedHandler (configurable) │ │ +│ │ Audit Logger (SLF4J) │ │ │ │ ─────────────────────────────────────────────────────── │ │ -│ │ Default: throw AccessDeniedException │ │ -│ │ Custom: user-provided handler │ │ -│ │ Logging: always audit via SLF4J │ │ +│ │ AUDIT mode: log WARN for violations, allow request │ │ +│ │ ENFORCE mode: log ERROR for violations, throw exception │ │ +│ │ All modes: log DEBUG for allowed requests (optional) │ │ │ └───────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -118,55 +340,46 @@ All resolution paths route through the allowlist resolver: ### Class Hierarchy ```text -gov.nist.secauto.metaschema.core.model.resolver/ -├── IAllowlistUriResolver.java # Main interface -├── AllowlistUriResolver.java # Default implementation -├── AllowlistUriResolverBuilder.java # Fluent builder -├── AccessDeniedException.java # Exception for blocked URIs -├── IAccessDeniedHandler.java # Custom handler interface -├── config/ -│ ├── AllowlistConfiguration.java # Configuration POJO -│ ├── AllowlistConfigurationLoader.java # YAML loader -│ ├── SchemePolicy.java # Enum: ALLOW, DENY -│ ├── FileSystemRule.java # File path rules -│ ├── HttpRule.java # Domain/path rules -│ └── JarRule.java # JAR resource rules -└── denylist/ - ├── BuiltInDenylist.java # Immutable security rules - ├── NetworkDenylist.java # IP/hostname patterns - └── FileSystemDenylist.java # Sensitive path patterns +dev.metaschema.core.model.policy/ +├── ResourceAccessPolicy.java # Main policy implementation +├── ResourceAccessPolicyBuilder.java # Fluent builder +├── PolicyMode.java # Enum: DISABLED, AUDIT, ENFORCE +├── AccessViolationException.java # Exception for ENFORCE mode +├── IResourceAccessPolicy.java # Interface for policy checking +├── SchemePatternSet.java # Glob patterns for one scheme +├── GlobMatcher.java # .gitignore-style glob matching +├── FileProtections.java # Adjustable file system deny patterns (ships with defaults) +└── package-info.java ``` ### Integration Flow ```text -User Request (URI) +Loader receives URI │ ▼ -┌──────────────────┐ -│ IModuleLoader │──────┐ -│ IDocumentLoader │ │ -│ IConstraintLoader │ -│ XMLResolver │ │ -└──────────────────┘ │ - │ │ - ▼ ▼ -┌──────────────────────────────────────┐ -│ IAllowlistUriResolver │ -│ ┌────────────────────────────────┐ │ -│ │ 1. Check built-in denylist │ │ -│ │ 2. Check scheme policy │ │ -│ │ 3. Check resource-specific │ │ -│ │ rules (file/http/jar) │ │ -│ │ 4. Log attempt │ │ -│ │ 5. Return URI or throw │ │ -│ └────────────────────────────────┘ │ -└──────────────────────────────────────┘ +┌──────────────────────────────────┐ +│ IResourceAccessPolicy.check() │ +│ ┌────────────────────────────┐ │ +│ │ 1. If DISABLED → return │ │ +│ │ 2. Get scheme from URI │ │ +│ │ 3. If file: scheme, check │ │ +│ │ FileProtections first │ │ +│ │ (allow-list gate) │ │ +│ │ 4. Find SchemePatternSet │ │ +│ │ 5. Match against patterns │ │ +│ │ (last match wins) │ │ +│ │ 6. Apply mode behavior │ │ +│ └────────────────────────────┘ │ +└──────────────────────────────────┘ │ - ▼ - Allowed URI → Resource Access - or - AccessDeniedException → Blocked + ├── DISABLED → allow silently + │ + ├── AUDIT + violation → log WARN, allow + ├── AUDIT + allowed → (optionally log DEBUG), allow + │ + ├── ENFORCE + violation → log ERROR, throw AccessViolationException + └── ENFORCE + allowed → allow ``` --- @@ -176,90 +389,96 @@ User Request (URI) ### Programmatic Configuration (Fluent API) ```java -// Strict server mode - HTTPS only -AllowlistUriResolver serverResolver = AllowlistUriResolver.builder() +// Restrictive server mode +ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) .forScheme("https") - .allowDomain("pages.nist.gov") - .allowDomain("raw.githubusercontent.com") - .restrictToPath("/metaschema-framework/") + .allow("pages.nist.gov/**") + .allow("raw.githubusercontent.com/metaschema-framework/**") + .deny("*.internal/**") .forScheme("http") .denyAll() .forScheme("file") - .denyAll() + .allow("/data/schemas/**") + .deny("**/.ssh/**") + .deny("**/.aws/**") .forScheme("jar") - .allowPath("/schema/") - .allowPath("/META-INF/metaschema/") + .allowAll() .defaultDeny() // deny unlisted schemes - .onAccessDenied((uri, reason) -> { - auditLog.warn("Blocked resource access: {} - {}", uri, reason); - throw new AccessDeniedException(uri, reason); - }) .build(); -// Development mode - allow local files -AllowlistUriResolver devResolver = AllowlistUriResolver.builder() - .forScheme("https") - .allowDomain("pages.nist.gov") - .forScheme("file") - .allowDirectory("/workspace/schemas").recursive() - .allowDirectory("/workspace/examples").recursive() - .forScheme("jar") - .allowPath("/schema/") +// Development mode - permissive with audit +ResourceAccessPolicy devPolicy = ResourceAccessPolicy.builder() + .mode(PolicyMode.AUDIT) + .forScheme("https").allowAll() + .forScheme("http").allow("localhost/**") // allow local dev servers + .forScheme("file").allowAll() + .forScheme("jar").allowAll() .defaultDeny() .build(); -// Hierarchical - inherit global with overrides -AllowlistUriResolver.setGlobalDefaults(serverResolver); +// Use bundled defaults +ResourceAccessPolicy defaults = ResourceAccessPolicy.bundledDefaults(); -IModuleLoader loader = context.newModuleLoader(); -loader.setUriResolver(AllowlistUriResolver.builder() - .inheritGlobalDefaults() - .forScheme("file") // Override for this loader - .allowDirectory(trustedSchemaPath).recursive() - .build()); -``` +// Override mode on bundled defaults +ResourceAccessPolicy enforced = ResourceAccessPolicy.bundledDefaults() + .withMode(PolicyMode.ENFORCE); -**Convenience constants (optional):** -```java -public final class Schemes { - public static final String HTTPS = "https"; - public static final String HTTP = "http"; - public static final String FILE = "file"; - public static final String JAR = "jar"; - // Users can use any string: forScheme("custom-protocol") -} +// Set on loader +IModuleLoader loader = ...; +loader.setResourceAccessPolicy(policy); ``` ### Metaschema-Based Configuration Model -The allowlist configuration uses a Metaschema-defined model, enabling: +The policy configuration uses a Metaschema-defined model, enabling: - **Type-safe configuration** via generated Java classes - **Multi-format support** - XML, JSON, or YAML - **Schema validation** - configs validated against the Metaschema model - **Dogfooding** - using Metaschema for its own tooling -**Metaschema Module Definition** (`allowlist-config_metaschema.yaml`): +**Metaschema Module Definition** (`resource-access-policy_metaschema.yaml`): ```yaml metaschema: - schema-name: Allowlist Configuration + schema-name: Resource Access Policy schema-version: 1.0.0 - short-name: allowlist-config - namespace: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 - json-base-uri: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 + short-name: resource-access-policy + namespace: http://csrc.nist.gov/ns/metaschema/resource-access-policy/1.0 + json-base-uri: http://csrc.nist.gov/ns/metaschema/resource-access-policy/1.0 definitions: - define-assembly: - name: allowlist-config - formal-name: Allowlist Configuration - description: Configuration for the allowlist URI resolver. - root-name: allowlist-config + name: resource-access-policy + formal-name: Resource Access Policy + description: >- + Policy controlling which URIs can be accessed during resource loading. + Uses glob patterns grouped by URI scheme. + root-name: resource-access-policy flags: - define-flag: - name: default-policy + name: mode as-type: token - formal-name: Default Policy - description: Default policy for unlisted schemes. + formal-name: Enforcement Mode + description: >- + How policy violations are handled. + constraint: + allowed-values: + - enum: + value: disabled + description: No policy checking + - enum: + value: audit + description: Log violations but allow requests + - enum: + value: enforce + description: Block violating requests + - define-flag: + name: default-scheme-policy + as-type: token + formal-name: Default Scheme Policy + description: >- + Policy for schemes not explicitly configured. constraint: allowed-values: - enum: @@ -274,17 +493,14 @@ metaschema: max-occurs: unbounded group-as: name: schemes - in-json: BY_KEY - - assembly: - ref: logging-config - min-occurs: 0 + in-json: ARRAY - define-assembly: name: scheme-config formal-name: Scheme Configuration - description: Configuration for a specific URI scheme. - json-key: - flag-ref: scheme + description: >- + Configuration for a specific URI scheme, containing glob patterns + that control access. flags: - define-flag: name: scheme @@ -296,154 +512,81 @@ metaschema: name: enabled as-type: boolean formal-name: Enabled - description: Whether this scheme is enabled. - model: - - choice: - - assembly: - ref: http-rule - max-occurs: unbounded - group-as: - name: http-rules - in-json: ARRAY - - assembly: - ref: file-rule - max-occurs: unbounded - group-as: - name: file-rules - in-json: ARRAY - - assembly: - ref: jar-rule - max-occurs: unbounded - group-as: - name: jar-rules - in-json: ARRAY - - - define-assembly: - name: http-rule - formal-name: HTTP Rule - description: Access rule for HTTP/HTTPS URIs. - flags: - - define-flag: - name: domain - as-type: string - required: yes - formal-name: Domain - description: Domain pattern (e.g., "example.com", "*.nist.gov"). + description: >- + Whether this scheme is enabled. When false, all URIs with this + scheme are denied. model: - field: - ref: path-prefix + ref: pattern max-occurs: unbounded group-as: - name: paths + name: patterns in-json: ARRAY - - define-assembly: - name: file-rule - formal-name: File Rule - description: Access rule for file:// URIs. - flags: - - define-flag: - name: path - as-type: string - required: yes - formal-name: Path - description: Base directory path. - - define-flag: - name: scope - as-type: token - formal-name: Scope - description: Access scope for the directory. - constraint: - allowed-values: - - enum: - value: recursive - description: Allow recursive access - - enum: - value: single-level - description: Allow single level only - - - define-assembly: - name: jar-rule - formal-name: JAR Rule - description: Access rule for jar: URIs. - flags: - - define-flag: - name: path - as-type: string - required: yes - formal-name: Path - description: Resource path pattern within JAR. - - define-field: - name: path-prefix + name: pattern as-type: string - formal-name: Path Prefix - description: Allowed path prefix. - - - define-assembly: - name: logging-config - formal-name: Logging Configuration - description: Audit logging settings. - flags: - - define-flag: - name: level - as-type: token - formal-name: Log Level - description: Minimum log level for access attempts. - - define-flag: - name: include-allowed - as-type: boolean - formal-name: Include Allowed - description: Whether to log allowed access attempts. + formal-name: Access Pattern + description: >- + A glob pattern controlling access. Patterns without a prefix are + allow patterns. Patterns starting with ! are deny patterns + (exceptions). Patterns are evaluated in order; last match wins. ``` -**Example Configuration Files:** +### Example Configuration Files + +**YAML format** (`resource-access-policy.yaml`): -YAML format (`allowlist.yaml`): ```yaml -allowlist-config: - default-policy: deny +resource-access-policy: + mode: audit + default-scheme-policy: deny schemes: - scheme: https - enabled: true - http-rules: - - domain: pages.nist.gov - paths: [/schemas/, /examples/] - - domain: raw.githubusercontent.com - paths: [/metaschema-framework/, /usnistgov/OSCAL/] + patterns: + - "pages.nist.gov/**" + - "raw.githubusercontent.com/metaschema-framework/**" + - "!*.internal/**" - scheme: http enabled: false - scheme: file - enabled: true - file-rules: - - path: /data/schemas - scope: recursive + patterns: + - "/data/schemas/**" + - "/workspace/**" + - "!**/.ssh/**" + - "!**/.aws/**" - scheme: jar - enabled: true - jar-rules: - - path: /schema/ - - path: /META-INF/metaschema/ - logging-config: - level: WARN - include-allowed: false + patterns: + - "**" ``` -JSON format (`allowlist.json`): +**JSON format** (`resource-access-policy.json`): + ```json { - "allowlist-config": { - "default-policy": "deny", - "schemes": { - "https": { - "enabled": true, - "http-rules": [ - { "domain": "pages.nist.gov", "paths": ["/schemas/"] } + "resource-access-policy": { + "mode": "audit", + "default-scheme-policy": "deny", + "schemes": [ + { + "scheme": "https", + "patterns": [ + "pages.nist.gov/**", + "raw.githubusercontent.com/metaschema-framework/**" ] }, - "file": { + { + "scheme": "http", "enabled": false + }, + { + "scheme": "file", + "patterns": [ + "/data/schemas/**", + "!**/.ssh/**" + ] } - } + ] } } ``` @@ -455,260 +598,78 @@ JSON format (`allowlist.json`): IBindingContext bindingContext = IBindingContext.instance(); IBoundLoader loader = bindingContext.newBoundLoader(); -// From file (auto-detects format: XML, JSON, or YAML) -AllowlistConfig config = loader.load(AllowlistConfig.class, - Path.of("/etc/metaschema-cli/allowlist.yaml")); +// From file (auto-detects format) +ResourceAccessPolicyConfig config = loader.load( + ResourceAccessPolicyConfig.class, + Path.of("resource-access-policy.yaml")); -// Create resolver from loaded config -AllowlistUriResolver resolver = AllowlistUriResolver.fromConfiguration(config); +// Create policy from config +ResourceAccessPolicy policy = ResourceAccessPolicy.fromConfiguration(config); -// From classpath resource -try (InputStream is = getClass().getResourceAsStream("/allowlist.yaml")) { - AllowlistConfig config = loader.load(AllowlistConfig.class, is); - AllowlistUriResolver.setGlobalDefaults( - AllowlistUriResolver.fromConfiguration(config)); -} +// Set on module loader +IModuleLoader moduleLoader = ...; +moduleLoader.setResourceAccessPolicy(policy); ``` --- -## Configuration System +## Configuration Layering -The allowlist configuration uses a layered configuration system that loads and merges configs from multiple locations, providing flexibility for different deployment scenarios. - -### Configuration Directory Locations - -Configurations are loaded from the following locations in precedence order (lowest to highest): +Configurations are loaded from multiple locations, merged with higher-precedence layers overriding lower ones: | Priority | Location | Platform | Purpose | |----------|----------|----------|---------| -| 1 (lowest) | `/config/` | All | Shipped defaults bundled with distribution | -| 2 | `/etc/metaschema-cli/` | Unix | System-wide administrator settings | -| 2 | `%ProgramData%\metaschema-cli\` | Windows | System-wide administrator settings | -| 3 | `~/.metaschema-cli/` | All | User-specific preferences | -| 4 | `./.metaschema/` | All | Project-specific overrides | -| 5 (highest) | `--config-dir=` | All | CLI argument override | -| 5 (highest) | `METASCHEMA_CONFIG_DIR` | All | Environment variable override | - -**Install Directory Structure:** -```text -metaschema-cli/ -├── bin/ -│ └── metaschema-cli # launcher script -├── lib/ -│ └── metaschema-cli.jar # main JAR -└── config/ # install-level configs - └── allowlist.yaml -``` - -**Config Files:** -Each directory can contain: -- `allowlist.yaml` - URI resolver security rules -- `logging.yaml` - Log level configuration (future) -- Other feature-specific configs as needed +| 1 (lowest) | Bundled in JAR | All | Restrictive defaults in audit mode | +| 2 | `/config/` | All | Distribution-specific overrides | +| 3 | `/etc/metaschema/` | Unix | System-wide administrator settings | +| 3 | `%ProgramData%\metaschema\` | Windows | System-wide administrator settings | +| 4 | `~/.metaschema/` | All | User-specific preferences | +| 5 | `./.metaschema/` | All | Project-specific overrides | +| 6 (highest) | CLI `--resource-policy` | All | CLI argument override | ### Merge Semantics -Configurations from all discovered locations are merged using the following rules: - -- **Deep merge on scheme**: When multiple config files define rules for the same scheme (e.g., `https`), all domain rules are combined from all layers -- **Shallow merge on domain**: When the same domain appears in multiple layers, the higher-precedence layer's rules completely replace the lower one - -**Merge Example:** - -```yaml -# Install config (priority 1) - /config/allowlist.yaml -default: deny - -schemes: - https: - enabled: true - rules: - - domain: pages.nist.gov - paths: [/schemas/] - - domain: raw.githubusercontent.com - paths: [/metaschema-framework/] - file: - enabled: false -``` - -```yaml -# User config (priority 3) - ~/.metaschema-cli/allowlist.yaml -schemes: - https: - rules: - - domain: pages.nist.gov # Same domain - REPLACES install's rules - paths: [/schemas/, /docs/] - - domain: internal.example.com # New domain - ADDED - paths: any - file: - enabled: true # Overrides install's file policy - rules: - - path: /home/user/schemas - scope: recursive -``` - -**Merged Result:** -```yaml -default: deny # From install (not overridden) - -schemes: - https: - enabled: true # From install - rules: - - domain: pages.nist.gov # User's version (shallow merge on domain) - paths: [/schemas/, /docs/] - - domain: raw.githubusercontent.com # From install (kept) - paths: [/metaschema-framework/] - - domain: internal.example.com # From user (added) - paths: any - file: - enabled: true # User override - rules: - - path: /home/user/schemas - scope: recursive -``` - -### Configuration Service API - -```java -public interface IConfigurationService { - /** - * Get the merged configuration for a specific config file. - * - * @param configName the config file name (e.g., "allowlist.yaml") - * @return the merged configuration, or empty if no configs found - */ - Optional getConfiguration(String configName); - - /** - * Get all discovered config directory paths in precedence order. - * - * @return list of paths (lowest to highest precedence) - */ - List getConfigDirectories(); - - /** - * Reload all configurations from disk. - */ - void reload(); -} -``` - -**Integration with CLI:** - -```java -// In CLI.java or CLIProcessor initialization -IConfigurationService configService = ConfigurationService.getInstance(); - -// Get allowlist config and create resolver -Optional allowlistConfig = configService - .getConfiguration("allowlist.yaml") - .map(AllowlistConfiguration::fromYaml); - -if (allowlistConfig.isPresent()) { - AllowlistUriResolver.setGlobalDefaults( - AllowlistUriResolver.fromConfiguration(allowlistConfig.get())); -} -``` - -### Configuration Loading Process - -1. **Discovery Phase**: Scan all config locations in order, collect paths that exist -2. **Load Phase**: Parse each discovered config file (YAML via SnakeYAML) -3. **Merge Phase**: Apply merge rules to produce final configuration -4. **Validation Phase**: Validate merged config against expected schema - -**Caching Behavior:** -- Configs loaded once at startup -- `reload()` available for long-running processes -- No file watching (explicit reload only) - -### Performance Analysis - -| Operation | Expected Time | Notes | -|-----------|---------------|-------| -| Directory existence checks (5-6 paths) | ~1-5ms | Filesystem stat calls | -| YAML parsing (per file, ~1-5KB) | ~5-15ms | SnakeYAML parsing | -| Merge operation | <1ms | In-memory, small data structures | -| **Total (typical: 1-2 configs)** | **~10-30ms** | Negligible for CLI startup | -| **Total (worst case: all 5 locations)** | **~50-100ms** | Still acceptable | +- **Mode**: Higher-precedence layer's mode wins +- **Scheme configs**: Merged by scheme name; higher-precedence replaces entire scheme config +- **Default scheme policy**: Higher-precedence layer's value wins -**Context:** -- JVM startup itself takes 50-200ms -- Current CLI startup (loading modules, initializing databind) takes 200-500ms -- Config loading adds ~5-10% overhead in typical case +--- -**Built-in Optimizations:** -- Short-circuit on CLI `--config-dir` override (skip other locations) -- Lazy loading option for configs not needed by every command -- No file watching or polling overhead +## Glob Pattern Matching -**Future Optimizations (if needed):** -- Cache merged config to temp file with checksum validation -- Parallel directory scanning -- Native YAML parser +### Syntax ---- +Patterns follow `.gitignore` glob syntax applied to the scheme-specific part of URIs: -## Built-In Denylist +| Pattern | Matches | Example | +|---------|---------|---------| +| `**` | Everything | All URIs for the scheme | +| `*.nist.gov/**` | Subdomain wildcard | `pages.nist.gov/schemas/foo.xml` | +| `example.com/path/**` | Path prefix | `example.com/path/to/resource` | +| `/workspace/**` | Directory tree (file scheme) | `/workspace/project/schema.xml` | +| `/workspace/*` | Single level (file scheme) | `/workspace/schema.xml` but not `/workspace/sub/schema.xml` | +| `!pattern` | Deny (exception) | Negates a previous allow | -These patterns are **blocked by default** but can be explicitly overridden when necessary (e.g., for local testing): +### Pattern Evaluation -### Network Addresses -```java -// IPv4 -"127.*.*.*" // Loopback -"10.*.*.*" // Private Class A -"172.16-31.*.*" // Private Class B -"192.168.*.*" // Private Class C -"169.254.*.*" // Link-local (AWS/GCP/Azure metadata) -"0.0.0.0" // All interfaces - -// IPv6 -"::1" // Loopback -"fe80::*" // Link-local -"fc00::*" // Unique local - -// Hostnames -"localhost" -"*.localhost" -"*.local" -"metadata.google.internal" -"instance-data" // EC2 metadata hostname -``` +For a given URI: +1. Extract the scheme +2. Find the matching `SchemePatternSet` +3. If scheme is `enabled: false`, result is **deny** +4. If no patterns defined and `enabled: true`, result is **allow** +5. Evaluate patterns in order; **last matching pattern wins** +6. If no pattern matches, use `default-scheme-policy` (default: deny) -**Overriding for local testing:** -```java -AllowlistUriResolver.builder() - .forScheme("http") - .allowHost("localhost") // explicitly override denylist - .allowHost("127.0.0.1") - .restrictToPort(8080) // optional: restrict to specific port - .build(); -``` +### What Patterns Match Against -### File System Paths (Unix) -```java -"/etc/" -"/proc/" -"/sys/" -"/dev/" -"/root/" -"/home/*/.*" // All hidden files/directories in home -"/var/run/" -"/tmp/" // Optional - may be needed for some use cases -``` +For each scheme, patterns match against the scheme-specific part: -### File System Paths (Windows) -```java -"C:\\Windows\\" -"C:\\Users\\*\\AppData\\" -"C:\\ProgramData\\" -"C:\\$Recycle.Bin\\" -"*\\.ssh\\" -"*\\.aws\\" -``` +| Scheme | Pattern matches against | Example URI → match target | +|--------|------------------------|---------------------------| +| `file` | Path component | `file:///workspace/foo.xml` → `/workspace/foo.xml` | +| `https` | Host + path | `https://nist.gov/schemas/x.xml` → `nist.gov/schemas/x.xml` | +| `http` | Host + path | `http://localhost:8080/api` → `localhost:8080/api` | +| `jar` | Path within JAR | `jar:file:///lib.jar!/schema/x.xsd` → `/schema/x.xsd` | --- @@ -722,57 +683,65 @@ From Issue #183: ### Additional Acceptance Criteria **Functional:** -- [ ] Module loading respects allowlist for imports -- [ ] Document loading respects allowlist -- [ ] Constraint loading respects allowlist for imports -- [ ] XML entity resolution respects allowlist -- [ ] Built-in denylist blocks all defined patterns -- [ ] Scheme policies correctly allow/deny by scheme -- [ ] File system rules enforce directory boundaries -- [ ] HTTP rules enforce domain and path restrictions -- [ ] JAR rules enforce resource path restrictions -- [ ] Hierarchical configuration (global + per-loader) works correctly -- [ ] YAML configuration loading works correctly +- [ ] Module loading checks policy for imports +- [ ] Document loading checks policy +- [ ] Constraint loading checks policy for imports +- [ ] XML entity resolution checks policy +- [ ] Glob pattern matching works correctly for all schemes +- [ ] `!` negation patterns create proper exceptions +- [ ] DISABLED mode allows all URIs without logging +- [ ] AUDIT mode logs violations but allows all URIs +- [ ] ENFORCE mode blocks violations with `AccessViolationException` +- [ ] Bundled defaults are restrictive (deny localhost, private IPs, sensitive paths) +- [ ] Configuration loading works from YAML, JSON, and XML +- [ ] Configuration layering merges correctly **Security:** -- [ ] Path traversal attacks are blocked (../../../etc/passwd) -- [ ] SSRF to localhost is blocked -- [ ] SSRF to private IP ranges is blocked -- [ ] Cloud metadata endpoints are blocked (169.254.169.254) -- [ ] Sensitive system paths are blocked +- [ ] Path traversal attacks are caught (../../../etc/passwd) +- [ ] SSRF to localhost is caught in default policy +- [ ] SSRF to private IP ranges is caught in default policy +- [ ] Cloud metadata endpoints are caught in default policy +- [ ] Sensitive system paths are caught in default policy + +**Backwards Compatibility:** +- [ ] Default mode (AUDIT) does not break any existing workflows +- [ ] Existing code without policy configuration works unchanged +- [ ] Library users can opt-in without changing their URI resolvers +- [ ] CLI users can override mode via flags **Non-Functional:** -- [ ] Clear error messages when access is denied -- [ ] Audit logging for all blocked attempts -- [ ] Minimal performance overhead for resolution -- [ ] 80%+ test coverage for resolver code +- [ ] Clear log messages identifying policy violations +- [ ] Minimal performance overhead for URI resolution +- [ ] 80%+ test coverage for policy code --- ## Testing Strategy ### Unit Tests -- SchemePolicy allow/deny behavior -- FileSystemRule path matching and boundary validation -- HttpRule domain and path matching -- JarRule resource path matching -- BuiltInDenylist pattern matching -- AllowlistUriResolverBuilder configuration -- YAML configuration parsing + +- `GlobMatcher` - Pattern matching for all glob syntax variants +- `SchemePatternSet` - Pattern evaluation with `!` negation and ordering +- `ResourceAccessPolicy` - Policy checking across modes +- `PolicyMode` - Mode behavior (disabled/audit/enforce) +- Metaschema configuration loading and validation ### Integration Tests -- Module loading with allowlist enabled -- Document loading with allowlist enabled -- Constraint loading with allowlist enabled -- XML entity resolution with allowlist enabled -- Hierarchical configuration inheritance + +- Module loading with policy in each mode +- Document loading with policy +- Constraint loading with policy +- XML entity resolution with policy +- Configuration layering from multiple sources ### Security Tests + - Path traversal attack vectors - SSRF attack vectors (localhost, private IPs, metadata endpoints) - Scheme injection attacks - Unicode/encoding bypass attempts - Case sensitivity handling (Windows paths) +- `!` pattern bypass attempts --- @@ -780,17 +749,41 @@ From Issue #183: | Risk | Impact | Mitigation | |------|--------|------------| -| Breaking existing applications | High | Opt-in by default; document migration path | -| Performance overhead | Medium | Efficient pattern matching; caching | -| Incomplete denylist | High | Research common attack vectors; allow updates | -| Configuration complexity | Medium | Sensible defaults; clear documentation | +| Breaking existing applications | High | AUDIT mode as default; DISABLED available | +| Performance overhead | Medium | Efficient glob matching; pattern compilation | +| Incomplete default policy | Medium | Community feedback during audit phase | +| Configuration complexity | Medium | `.gitignore`-style syntax is widely known | | Platform-specific path issues | Medium | Test on Windows/Linux/Mac; normalize paths | --- +## Migration Path + +### Phase 1: Deployment (AUDIT mode) + +1. Deploy with default policy (restrictive rules, audit mode) +2. Monitor logs for `WARN` entries showing policy violations +3. Adjust policy patterns to match actual access needs +4. Share policy configs across team/org + +### Phase 2: Enforcement + +1. Once policy accurately reflects needed access, switch to `ENFORCE` +2. `ResourceAccessPolicy.bundledDefaults().withMode(PolicyMode.ENFORCE)` +3. Or in config: `mode: enforce` + +### Phase 3: Customization + +1. Create project-specific `.metaschema/resource-access-policy.yaml` +2. Override bundled defaults for organization-specific needs +3. Use per-loader policies for fine-grained control + +--- + ## Out of Scope - Authentication/authorization for HTTP resources (use existing HTTP client config) - Rate limiting or request throttling - Content inspection (only URI-based filtering) - Certificate validation (use JVM truststore config) +- Real-time file watching for config changes (explicit reload only) diff --git a/PRDs/20251217-allowlist-resolver/implementation-plan.md b/PRDs/20251217-allowlist-resolver/implementation-plan.md index 6702f2c8e6..70d4370823 100644 --- a/PRDs/20251217-allowlist-resolver/implementation-plan.md +++ b/PRDs/20251217-allowlist-resolver/implementation-plan.md @@ -1,2594 +1,1056 @@ -# Allowlist URI Resolver - Implementation Plan +# Resource Access Policy - Implementation Plan -**Goal:** Implement secure-by-default URI resolver with allowlist/denylist controls for all resource loading paths. +**Goal:** Implement policy-based URI access control with glob patterns, graduated enforcement modes, and bundled defaults. -**Architecture:** Layered approach - core interfaces first, then rules engine, then integration points. +**Architecture:** All policy engine code in `core` module. CLI integration in `cli-processor`/`metaschema-cli`. -**Tech Stack:** Java 11, JUnit 5, Mockito, SnakeYAML, SLF4J +**Tech Stack:** Java 11, JUnit 5, SLF4J, Metaschema databind for configuration model. --- ## PR Breakdown -Single PR with multiple commits organized by feature area. - -| PR | Scope | Commits | Files | Estimated Size | -|----|-------|---------|-------|----------------| -| PR1 | Complete allowlist resolver implementation | ~15 commits | ~40 files | Large | - -### Commit Sequence - -**Phase 1: Core Resolver** -1. Core interfaces and exceptions (AccessDeniedException, IAccessDeniedHandler, IUriAccessRule, IAllowlistUriResolver) -2. SchemePolicy implementation -3. Built-in denylist (NetworkDenylist, FileSystemDenylist, BuiltInDenylist) -4. FileSystemRule implementation -5. HttpRule implementation -6. JarRule implementation -7. AllowlistUriResolver and builder - -**Phase 2: Configuration System** -8. Create Metaschema module definition (`allowlist-config_metaschema.yaml`) -9. Configure metaschema-maven-plugin for code generation in cli-processor pom.xml -10. IConfigurationService interface -11. ConfigurationService implementation with directory discovery -12. AllowlistConfigurationMerger (deep merge on scheme, shallow on domain) -13. AllowlistConfiguration binding class integration with IBoundLoader - -**Phase 3: Integration** -14. Update AbstractModuleLoader to use IUriResolver -15. Update BindingConstraintLoader to use IUriResolver -16. Update DefaultXmlDeserializer to use IUriResolver -17. CLI integration (--config-dir option, METASCHEMA_CONFIG_DIR env var) -18. Documentation and examples +Implementation is organized into 4 PRs, each building on the previous: ---- - -## PR1: Core Interfaces and Exceptions - -**Goal:** Establish foundational interfaces and exception types. - -**Package:** `gov.nist.secauto.metaschema.core.model.resolver` - -### Task 1.1: Create AccessDeniedException - -**Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedException.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedExceptionTest.java` - -**Step 1: Write the failing test** - -```java -package gov.nist.secauto.metaschema.core.model.resolver; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.Test; - -import java.net.URI; - -class AccessDeniedExceptionTest { - - @Test - void testExceptionContainsUriAndReason() { - URI uri = URI.create("file:///etc/passwd"); - String reason = "File system access denied by allowlist policy"; - - AccessDeniedException ex = new AccessDeniedException(uri, reason); - - assertEquals(uri, ex.getUri()); - assertEquals(reason, ex.getReason()); - assertTrue(ex.getMessage().contains(uri.toString())); - assertTrue(ex.getMessage().contains(reason)); - } - - @Test - void testExceptionWithCause() { - URI uri = URI.create("http://localhost:8080/admin"); - String reason = "Built-in denylist: localhost"; - Throwable cause = new SecurityException("Blocked"); - - AccessDeniedException ex = new AccessDeniedException(uri, reason, cause); - - assertEquals(uri, ex.getUri()); - assertEquals(reason, ex.getReason()); - assertEquals(cause, ex.getCause()); - } -} -``` - -**Step 2: Run test to verify it fails** - -```bash -mvn -pl core test -Dtest=AccessDeniedExceptionTest -DfailIfNoTests=false -``` - -Expected: Compilation error - class does not exist - -**Step 3: Write implementation** - -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver; - -import java.net.URI; - -import edu.umd.cs.findbugs.annotations.NonNull; - -/** - * Exception thrown when URI access is denied by the allowlist resolver. - *

- * This exception provides details about which URI was blocked and why, - * supporting both security auditing and debugging. - */ -public class AccessDeniedException extends SecurityException { - private static final long serialVersionUID = 1L; - - @NonNull - private final URI uri; - @NonNull - private final String reason; - - /** - * Constructs a new access denied exception. - * - * @param uri - * the URI that was denied access - * @param reason - * human-readable explanation of why access was denied - */ - public AccessDeniedException(@NonNull URI uri, @NonNull String reason) { - super(formatMessage(uri, reason)); - this.uri = uri; - this.reason = reason; - } - - /** - * Constructs a new access denied exception with a cause. - * - * @param uri - * the URI that was denied access - * @param reason - * human-readable explanation of why access was denied - * @param cause - * the underlying cause of the denial - */ - public AccessDeniedException(@NonNull URI uri, @NonNull String reason, @NonNull Throwable cause) { - super(formatMessage(uri, reason), cause); - this.uri = uri; - this.reason = reason; - } - - private static String formatMessage(URI uri, String reason) { - return String.format("Access denied to URI '%s': %s", uri, reason); - } - - /** - * Returns the URI that was denied access. - * - * @return the blocked URI - */ - @NonNull - public URI getUri() { - return uri; - } - - /** - * Returns the reason access was denied. - * - * @return human-readable denial reason - */ - @NonNull - public String getReason() { - return reason; - } -} -``` - -**Step 4: Run test to verify it passes** - -```bash -mvn -pl core test -Dtest=AccessDeniedExceptionTest -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedException.java -git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/AccessDeniedExceptionTest.java -git commit -m "feat(resolver): add AccessDeniedException for blocked URI access" -``` - ---- - -### Task 1.2: Create IAccessDeniedHandler Interface - -**Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAccessDeniedHandler.java` - -**Step 1: Write implementation** (interface-only, no test needed) - -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver; - -import java.net.URI; - -import edu.umd.cs.findbugs.annotations.NonNull; - -/** - * Handler invoked when URI access is denied by the allowlist resolver. - *

- * Implementations can customize behavior when access is blocked, such as - * logging, throwing custom exceptions, or returning alternative resources. - */ -@FunctionalInterface -public interface IAccessDeniedHandler { - - /** - * Default handler that throws {@link AccessDeniedException}. - */ - IAccessDeniedHandler THROW_EXCEPTION = (uri, reason) -> { - throw new AccessDeniedException(uri, reason); - }; - - /** - * Called when access to a URI is denied. - * - * @param uri - * the URI that was denied access - * @param reason - * human-readable explanation of why access was denied - * @throws AccessDeniedException - * if the handler chooses to throw (default behavior) - */ - void handleAccessDenied(@NonNull URI uri, @NonNull String reason); -} -``` - -**Step 2: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAccessDeniedHandler.java -git commit -m "feat(resolver): add IAccessDeniedHandler interface for custom denial handling" -``` - ---- - -### Task 1.3: Create IUriAccessRule Interface - -**Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IUriAccessRule.java` - -**Step 1: Write implementation** - -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver; - -import java.net.URI; - -import edu.umd.cs.findbugs.annotations.NonNull; - -/** - * A rule that determines whether access to a URI should be allowed or denied. - *

- * Rules are evaluated in order, and the first matching rule determines the - * access decision. If no rule matches, access is denied by default. - */ -public interface IUriAccessRule { - - /** - * Result of evaluating a URI against this rule. - */ - enum RuleResult { - /** The rule allows access to the URI. */ - ALLOW, - /** The rule denies access to the URI. */ - DENY, - /** The rule does not apply to this URI; check next rule. */ - NO_MATCH - } - - /** - * Evaluates whether this rule applies to the given URI and what the access - * decision is. - * - * @param uri - * the URI to evaluate - * @return the rule result indicating allow, deny, or no match - */ - @NonNull - RuleResult evaluate(@NonNull URI uri); - - /** - * Returns a human-readable description of this rule for logging and debugging. - * - * @return rule description - */ - @NonNull - String getDescription(); -} -``` - -**Step 2: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IUriAccessRule.java -git commit -m "feat(resolver): add IUriAccessRule interface for access decisions" -``` +| PR | Scope | Estimated Files | Key Deliverables | +|----|-------|-----------------|------------------| +| PR1 | Policy engine core | ~15 files | `GlobMatcher`, `SchemePatternSet`, `PolicyMode`, `ResourceAccessPolicy`, builder | +| PR2 | Configuration model | ~10 files | Metaschema module, config loading, bundled defaults, layering | +| PR3 | Loader integration | ~10 files | `IModuleLoader`/`IBoundLoader` integration, XML entity policy | +| PR4 | CLI integration + docs | ~8 files | CLI flags, env vars, documentation | --- -### Task 1.4: Create IAllowlistUriResolver Interface - -**Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAllowlistUriResolver.java` - -**Step 1: Write implementation** - -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver; - -import gov.nist.secauto.metaschema.core.model.IUriResolver; - -import java.net.URI; - -import edu.umd.cs.findbugs.annotations.NonNull; - -/** - * A URI resolver that enforces allowlist-based access control. - *

- * This resolver validates URIs against configured rules before allowing access, - * providing defense against local file inclusion, SSRF, and other URI-based - * attacks. - *

- * The resolver enforces: - *

    - *
  • Scheme policies (allow/deny by URI scheme)
  • - *
  • File system rules (allowed directories and paths)
  • - *
  • HTTP rules (allowed domains and path prefixes)
  • - *
  • JAR resource rules (allowed paths within JARs)
  • - *
  • Built-in denylist (always blocks dangerous patterns)
  • - *
- * - * @see AllowlistUriResolver - */ -public interface IAllowlistUriResolver extends IUriResolver { - - /** - * Checks whether access to the given URI would be allowed without actually - * resolving it. - *

- * This method is useful for pre-validation or UI feedback without triggering - * the access denied handler. - * - * @param uri - * the URI to check - * @return {@code true} if the URI would be allowed, {@code false} otherwise - */ - boolean isAllowed(@NonNull URI uri); - - /** - * Returns the reason why a URI would be denied, or empty if allowed. - * - * @param uri - * the URI to check - * @return denial reason, or {@code null} if the URI is allowed - */ - String getDenialReason(@NonNull URI uri); -} -``` - -**Step 2: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/IAllowlistUriResolver.java -git commit -m "feat(resolver): add IAllowlistUriResolver interface extending IUriResolver" -``` - ---- - -### Task 1.5: Create SchemePolicy Enum - -**Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicy.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicyTest.java` - -**Step 1: Write the failing test** - -```java -package gov.nist.secauto.metaschema.core.model.resolver; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -class SchemePolicyTest { - - @Test - void testDefaultPolicyDeniesAll() { - SchemePolicy policy = SchemePolicy.denyAll(); - - assertFalse(policy.isAllowed("file")); - assertFalse(policy.isAllowed("http")); - assertFalse(policy.isAllowed("https")); - assertFalse(policy.isAllowed("jar")); - } - - @Test - void testAllowSpecificSchemes() { - SchemePolicy policy = SchemePolicy.denyAll() - .withAllowed("https") - .withAllowed("jar"); - - assertFalse(policy.isAllowed("file")); - assertFalse(policy.isAllowed("http")); - assertTrue(policy.isAllowed("https")); - assertTrue(policy.isAllowed("jar")); - } - - @Test - void testDenySpecificSchemes() { - SchemePolicy policy = SchemePolicy.allowAll() - .withDenied("file") - .withDenied("ftp"); - - assertFalse(policy.isAllowed("file")); - assertFalse(policy.isAllowed("ftp")); - assertTrue(policy.isAllowed("http")); - assertTrue(policy.isAllowed("https")); - } - - @ParameterizedTest - @CsvSource({ - "FILE, file", - "HTTPS, https", - "HTTP, http" - }) - void testSchemeNormalization(String input, String expected) { - SchemePolicy policy = SchemePolicy.denyAll().withAllowed(input); - assertTrue(policy.isAllowed(expected)); - assertTrue(policy.isAllowed(input.toUpperCase())); - } -} -``` - -**Step 2: Run test to verify it fails** - -```bash -mvn -pl core test -Dtest=SchemePolicyTest -DfailIfNoTests=false -``` - -Expected: Compilation error +## PR1: Policy Engine Core -**Step 3: Write implementation** - -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver; - -import gov.nist.secauto.metaschema.core.util.CollectionUtil; - -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; - -import edu.umd.cs.findbugs.annotations.NonNull; - -/** - * Policy for allowing or denying URI schemes. - *

- * Schemes are normalized to lowercase for comparison. - */ -public final class SchemePolicy { - - private final boolean defaultAllow; - @NonNull - private final Set allowedSchemes; - @NonNull - private final Set deniedSchemes; - - private SchemePolicy(boolean defaultAllow, Set allowed, Set denied) { - this.defaultAllow = defaultAllow; - this.allowedSchemes = CollectionUtil.unmodifiableSet(new HashSet<>(allowed)); - this.deniedSchemes = CollectionUtil.unmodifiableSet(new HashSet<>(denied)); - } - - /** - * Creates a policy that denies all schemes by default. - * - * @return a deny-all policy - */ - @NonNull - public static SchemePolicy denyAll() { - return new SchemePolicy(false, Set.of(), Set.of()); - } - - /** - * Creates a policy that allows all schemes by default. - * - * @return an allow-all policy - */ - @NonNull - public static SchemePolicy allowAll() { - return new SchemePolicy(true, Set.of(), Set.of()); - } - - /** - * Returns a new policy with the specified scheme allowed. - * - * @param scheme - * the scheme to allow - * @return new policy with scheme allowed - */ - @NonNull - public SchemePolicy withAllowed(@NonNull String scheme) { - Set newAllowed = new HashSet<>(allowedSchemes); - newAllowed.add(normalizeScheme(scheme)); - return new SchemePolicy(defaultAllow, newAllowed, deniedSchemes); - } - - /** - * Returns a new policy with the specified scheme denied. - * - * @param scheme - * the scheme to deny - * @return new policy with scheme denied - */ - @NonNull - public SchemePolicy withDenied(@NonNull String scheme) { - Set newDenied = new HashSet<>(deniedSchemes); - newDenied.add(normalizeScheme(scheme)); - return new SchemePolicy(defaultAllow, allowedSchemes, newDenied); - } - - /** - * Checks if the given scheme is allowed by this policy. - * - * @param scheme - * the scheme to check - * @return {@code true} if allowed, {@code false} if denied - */ - public boolean isAllowed(@NonNull String scheme) { - String normalized = normalizeScheme(scheme); - - // Explicit deny takes precedence - if (deniedSchemes.contains(normalized)) { - return false; - } - - // Explicit allow - if (allowedSchemes.contains(normalized)) { - return true; - } - - // Fall back to default - return defaultAllow; - } - - private static String normalizeScheme(String scheme) { - return scheme.toLowerCase(Locale.ROOT); - } -} -``` - -**Step 4: Run test to verify it passes** - -```bash -mvn -pl core test -Dtest=SchemePolicyTest -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicy.java -git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/SchemePolicyTest.java -git commit -m "feat(resolver): add SchemePolicy for URI scheme allow/deny decisions" -``` - ---- - -### Task 1.6: Verify PR1 Build - -**Step 1: Run full build for core module** - -```bash -mvn -pl core clean install -``` - -Expected: BUILD SUCCESS - -**Step 2: Run checkstyle** - -```bash -mvn -pl core checkstyle:check -``` - -Expected: No violations - -**Step 3: Commit and prepare PR** - -```bash -git push -u me feature/183-allowlist-resolver -``` - ---- +**Goal:** Implement the glob-based pattern matching engine, enforcement modes, and the `ResourceAccessPolicy` builder. -## PR2: Built-In Denylist +**Module:** `core` -**Goal:** Implement always-enforced security rules that cannot be disabled. +**Package:** `dev.metaschema.core.model.policy` -### Task 2.1: Create NetworkDenylist +### Task 1.1: Create PolicyMode Enum **Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylist.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylistTest.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/PolicyMode.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/PolicyModeTest.java` -**Step 1: Write the failing test** +**Test first:** ```java -package gov.nist.secauto.metaschema.core.model.resolver.denylist; +package dev.metaschema.core.model.policy; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import java.net.URI; +import org.junit.jupiter.params.provider.CsvSource; -class NetworkDenylistTest { +class PolicyModeTest { - private final NetworkDenylist denylist = NetworkDenylist.getInstance(); + @Test + void testDefaultModeIsAudit() { + assertEquals(PolicyMode.AUDIT, PolicyMode.defaultMode()); + } @ParameterizedTest - @ValueSource(strings = { - "http://localhost/admin", - "http://localhost:8080/api", - "http://127.0.0.1/secret", - "http://127.0.0.2:9000/data", - "http://[::1]/internal", - "http://169.254.169.254/latest/meta-data/", // AWS metadata - "http://metadata.google.internal/", // GCP metadata - "http://10.0.0.1/internal", // Private Class A - "http://172.16.0.1/internal", // Private Class B - "http://172.31.255.255/internal", // Private Class B upper - "http://192.168.1.1/router", // Private Class C - "http://0.0.0.0/", // All interfaces + @CsvSource({ + "DISABLED, false, false", + "AUDIT, true, false", + "ENFORCE, true, true" }) - void testBlockedAddresses(String uriString) { - URI uri = URI.create(uriString); - assertTrue(denylist.isDenied(uri), "Should block: " + uriString); - assertNotNull(denylist.getDenialReason(uri)); + void testModeCharacteristics(PolicyMode mode, boolean checks, boolean blocks) { + assertEquals(checks, mode.isCheckEnabled()); + assertEquals(blocks, mode.isBlockEnabled()); } @ParameterizedTest - @ValueSource(strings = { - "https://example.com/api", - "https://pages.nist.gov/schema", - "https://raw.githubusercontent.com/file.xml", - "http://8.8.8.8/dns", // Public IP - "https://172.217.0.1/google", // Public IP in 172.x range but not private + @CsvSource({ + "disabled, DISABLED", + "audit, AUDIT", + "enforce, ENFORCE", + "DISABLED, DISABLED", + "Audit, AUDIT" }) - void testAllowedAddresses(String uriString) { - URI uri = URI.create(uriString); - assertFalse(denylist.isDenied(uri), "Should allow: " + uriString); - } - - @Test - void testNonHttpSchemesNotChecked() { - // File URIs don't have network hosts - handled by FileSystemDenylist - URI fileUri = URI.create("file:///etc/passwd"); - assertFalse(denylist.isDenied(fileUri)); + void testFromString(String input, PolicyMode expected) { + assertEquals(expected, PolicyMode.fromString(input)); } } ``` -**Step 2: Run test to verify it fails** - -```bash -mvn -pl core test -Dtest=NetworkDenylistTest -DfailIfNoTests=false -``` - -Expected: Compilation error - -**Step 3: Write implementation** +**Implementation:** ```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver.denylist; +package dev.metaschema.core.model.policy; -import java.net.InetAddress; -import java.net.URI; -import java.net.UnknownHostException; -import java.util.List; import java.util.Locale; -import java.util.regex.Pattern; import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; /** - * Built-in denylist for network addresses that should never be accessed. - *

- * This includes: - *

    - *
  • Localhost and loopback addresses
  • - *
  • Private IP ranges (RFC 1918)
  • - *
  • Link-local addresses (cloud metadata endpoints)
  • - *
  • Known metadata service hostnames
  • - *
- *

- * This denylist cannot be disabled and is always enforced. + * Enforcement mode for resource access policies. */ -public final class NetworkDenylist { - - private static final NetworkDenylist INSTANCE = new NetworkDenylist(); - - /** Hostnames that are always blocked. */ - private static final List BLOCKED_HOSTNAMES = List.of( - "localhost", - "metadata.google.internal", - "instance-data", - "kubernetes.default.svc" - ); - - /** Hostname patterns that are always blocked. */ - private static final List BLOCKED_HOSTNAME_PATTERNS = List.of( - Pattern.compile(".*\\.localhost$", Pattern.CASE_INSENSITIVE), - Pattern.compile(".*\\.local$", Pattern.CASE_INSENSITIVE) - ); - - private NetworkDenylist() { - // singleton +public enum PolicyMode { + /** No policy checking; all URIs allowed silently. */ + DISABLED(false, false), + /** Check policy and log violations, but allow all requests. */ + AUDIT(true, false), + /** Check policy and block violations with an exception. */ + ENFORCE(true, true); + + private final boolean checkEnabled; + private final boolean blockEnabled; + + PolicyMode(boolean checkEnabled, boolean blockEnabled) { + this.checkEnabled = checkEnabled; + this.blockEnabled = blockEnabled; } /** - * Returns the singleton instance. + * Whether this mode performs policy checks. * - * @return the network denylist instance + * @return {@code true} if policy rules are evaluated */ - @NonNull - public static NetworkDenylist getInstance() { - return INSTANCE; + public boolean isCheckEnabled() { + return checkEnabled; } /** - * Checks if the given URI's host is on the denylist. + * Whether this mode blocks violating requests. * - * @param uri - * the URI to check - * @return {@code true} if the host is denied + * @return {@code true} if violations throw exceptions */ - public boolean isDenied(@NonNull URI uri) { - return getDenialReason(uri) != null; + public boolean isBlockEnabled() { + return blockEnabled; } /** - * Returns the reason why the URI's host is denied, or null if allowed. + * Returns the default enforcement mode ({@link #AUDIT}). * - * @param uri - * the URI to check - * @return denial reason or null + * @return the default mode */ - @Nullable - public String getDenialReason(@NonNull URI uri) { - String host = uri.getHost(); - if (host == null) { - return null; // No host component (e.g., file:// URIs) - } - - String lowerHost = host.toLowerCase(Locale.ROOT); - - // Check exact hostname matches - if (BLOCKED_HOSTNAMES.contains(lowerHost)) { - return "Built-in denylist: blocked hostname '" + host + "'"; - } - - // Check hostname patterns - for (Pattern pattern : BLOCKED_HOSTNAME_PATTERNS) { - if (pattern.matcher(lowerHost).matches()) { - return "Built-in denylist: blocked hostname pattern '" + host + "'"; - } - } - - // Check IP addresses - return checkIpAddress(host); + @NonNull + public static PolicyMode defaultMode() { + return AUDIT; } - @Nullable - private String checkIpAddress(String host) { - // Handle IPv6 addresses in brackets - String ipString = host; - if (host.startsWith("[") && host.endsWith("]")) { - ipString = host.substring(1, host.length() - 1); - } - - try { - InetAddress addr = InetAddress.getByName(ipString); - - if (addr.isLoopbackAddress()) { - return "Built-in denylist: loopback address"; - } - - if (addr.isLinkLocalAddress()) { - return "Built-in denylist: link-local address (potential cloud metadata endpoint)"; - } - - if (addr.isSiteLocalAddress()) { - return "Built-in denylist: private network address"; - } - - if (addr.isAnyLocalAddress()) { - return "Built-in denylist: wildcard address"; - } - - // Check for 0.0.0.0 explicitly - byte[] bytes = addr.getAddress(); - if (bytes.length == 4 && bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0) { - return "Built-in denylist: all-interfaces address"; - } - - } catch (UnknownHostException e) { - // Not a valid IP address, treat as hostname (already checked above) - } - - return null; + /** + * Parses a mode from a string value (case-insensitive). + * + * @param value + * the string to parse + * @return the matching mode + * @throws IllegalArgumentException + * if the value does not match any mode + */ + @NonNull + public static PolicyMode fromString(@NonNull String value) { + return valueOf(value.toUpperCase(Locale.ROOT)); } } ``` -**Step 4: Run test to verify it passes** - -```bash -mvn -pl core test -Dtest=NetworkDenylistTest -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylist.java -git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/NetworkDenylistTest.java -git commit -m "feat(resolver): add NetworkDenylist for blocking internal network access" -``` - --- -### Task 2.2: Create FileSystemDenylist +### Task 1.2: Create AccessViolationException **Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylist.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylistTest.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/AccessViolationException.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/AccessViolationExceptionTest.java` -**Step 1: Write the failing test** +**Test first:** ```java -package gov.nist.secauto.metaschema.core.model.resolver.denylist; +package dev.metaschema.core.model.policy; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledOnOs; -import org.junit.jupiter.api.condition.OS; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import java.net.URI; -class FileSystemDenylistTest { - - private final FileSystemDenylist denylist = FileSystemDenylist.getInstance(); - - @ParameterizedTest - @EnabledOnOs(OS.LINUX) - @ValueSource(strings = { - "file:///etc/passwd", - "file:///etc/shadow", - "file:///proc/self/environ", - "file:///sys/kernel/debug", - "file:///dev/null", - "file:///root/.ssh/id_rsa", - "file:///home/user/.ssh/known_hosts", - "file:///home/user/.aws/credentials", - "file:///home/user/.gnupg/private-keys-v1.d/key", - "file:///var/run/secrets/kubernetes.io/token", - }) - void testBlockedUnixPaths(String uriString) { - URI uri = URI.create(uriString); - assertTrue(denylist.isDenied(uri), "Should block: " + uriString); - assertNotNull(denylist.getDenialReason(uri)); - } +class AccessViolationExceptionTest { - @ParameterizedTest - @EnabledOnOs(OS.WINDOWS) - @ValueSource(strings = { - "file:///C:/Windows/System32/config/SAM", - "file:///C:/Users/admin/AppData/Local/secret", - "file:///C:/ProgramData/sensitive", - "file:///C:/Users/admin/.ssh/id_rsa", - "file:///C:/Users/admin/.aws/credentials", - }) - void testBlockedWindowsPaths(String uriString) { - URI uri = URI.create(uriString); - assertTrue(denylist.isDenied(uri), "Should block: " + uriString); - } + @Test + void testExceptionContainsUriAndReason() { + URI uri = URI.create("file:///etc/passwd"); + String reason = "Denied by pattern: !/etc/**"; - @ParameterizedTest - @ValueSource(strings = { - "file:///data/schemas/module.xml", - "file:///app/config/settings.yaml", - "file:///workspace/project/src/main.java", - }) - void testAllowedPaths(String uriString) { - URI uri = URI.create(uriString); - assertFalse(denylist.isDenied(uri), "Should allow: " + uriString); - } + AccessViolationException ex = new AccessViolationException(uri, reason); - @Test - void testPathTraversalNormalization() { - // These should be blocked after normalization - URI traversal1 = URI.create("file:///data/../etc/passwd"); - URI traversal2 = URI.create("file:///app/config/../../etc/shadow"); - - // Note: URI normalization happens before denylist check - // The denylist checks the normalized path - assertEquals("/etc/passwd", traversal1.normalize().getPath()); - assertTrue(denylist.isDenied(traversal1.normalize())); + assertEquals(uri, ex.getUri()); + assertEquals(reason, ex.getReason()); + assertTrue(ex.getMessage().contains(uri.toString())); + assertTrue(ex.getMessage().contains(reason)); } @Test - void testNonFileSchemesNotChecked() { - URI httpUri = URI.create("http://example.com/etc/passwd"); - assertFalse(denylist.isDenied(httpUri)); + void testExtendsSecurityException() { + AccessViolationException ex = new AccessViolationException( + URI.create("http://localhost"), "denied"); + assertInstanceOf(SecurityException.class, ex); } } ``` -**Step 2: Run test to verify it fails** - -```bash -mvn -pl core test -Dtest=FileSystemDenylistTest -DfailIfNoTests=false -``` - -Expected: Compilation error - -**Step 3: Write implementation** +**Implementation:** ```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver.denylist; +package dev.metaschema.core.model.policy; import java.net.URI; -import java.util.List; -import java.util.Locale; import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; /** - * Built-in denylist for sensitive file system paths. - *

- * This includes: - *

    - *
  • System configuration directories (/etc, C:\Windows)
  • - *
  • Process and kernel interfaces (/proc, /sys)
  • - *
  • User credential directories (.ssh, .aws, .gnupg)
  • - *
  • Temporary and runtime directories
  • - *
- *

- * This denylist cannot be disabled and is always enforced. + * Exception thrown when a URI access violates the resource access policy + * in {@link PolicyMode#ENFORCE} mode. */ -public final class FileSystemDenylist { - - private static final FileSystemDenylist INSTANCE = new FileSystemDenylist(); - private static final boolean IS_WINDOWS = System.getProperty("os.name", "") - .toLowerCase(Locale.ROOT).contains("windows"); - - /** Unix paths that are always blocked. */ - private static final List BLOCKED_UNIX_PREFIXES = List.of( - "/etc/", - "/proc/", - "/sys/", - "/dev/", - "/root/", - "/var/run/", - "/run/" - ); - - /** Unix path patterns (checked as substrings). */ - private static final List BLOCKED_UNIX_PATTERNS = List.of( - "/.ssh/", - "/.gnupg/", - "/.aws/", - "/.azure/", - "/.config/gcloud/" - ); - - /** Windows paths that are always blocked (lowercase for comparison). */ - private static final List BLOCKED_WINDOWS_PREFIXES = List.of( - "/c:/windows/", - "/c:/programdata/", - "/c:/$recycle.bin/" - ); - - /** Windows patterns (checked as substrings, lowercase). */ - private static final List BLOCKED_WINDOWS_PATTERNS = List.of( - "/appdata/", - "/.ssh/", - "/.aws/", - "/.azure/" - ); - - private FileSystemDenylist() { - // singleton - } +public class AccessViolationException extends SecurityException { + private static final long serialVersionUID = 1L; - /** - * Returns the singleton instance. - * - * @return the file system denylist instance - */ @NonNull - public static FileSystemDenylist getInstance() { - return INSTANCE; - } + private final URI uri; + @NonNull + private final String reason; /** - * Checks if the given URI's path is on the denylist. + * Constructs a new access violation exception. * * @param uri - * the URI to check (should be file:// scheme) - * @return {@code true} if the path is denied + * the URI that violated the policy + * @param reason + * human-readable explanation of the violation */ - public boolean isDenied(@NonNull URI uri) { - return getDenialReason(uri) != null; + public AccessViolationException(@NonNull URI uri, @NonNull String reason) { + super(String.format("Resource access policy violation for '%s': %s", uri, reason)); + this.uri = uri; + this.reason = reason; } /** - * Returns the reason why the URI's path is denied, or null if allowed. + * Returns the URI that violated the policy. * - * @param uri - * the URI to check - * @return denial reason or null + * @return the violating URI */ - @Nullable - public String getDenialReason(@NonNull URI uri) { - String scheme = uri.getScheme(); - if (scheme == null || !"file".equalsIgnoreCase(scheme)) { - return null; // Not a file URI - } - - String path = uri.getPath(); - if (path == null) { - return null; - } - - // Normalize the path for comparison - String normalizedPath = path.toLowerCase(Locale.ROOT).replace('\\', '/'); - - // Check OS-specific rules - if (IS_WINDOWS) { - return checkWindowsPath(normalizedPath, path); - } - return checkUnixPath(normalizedPath, path); - } - - @Nullable - private String checkUnixPath(String normalizedPath, String originalPath) { - for (String prefix : BLOCKED_UNIX_PREFIXES) { - if (normalizedPath.startsWith(prefix) || normalizedPath.equals(prefix.substring(0, prefix.length() - 1))) { - return "Built-in denylist: sensitive system path '" + originalPath + "'"; - } - } - - for (String pattern : BLOCKED_UNIX_PATTERNS) { - if (normalizedPath.contains(pattern)) { - return "Built-in denylist: credential directory '" + originalPath + "'"; - } - } - - return null; + @NonNull + public URI getUri() { + return uri; } - @Nullable - private String checkWindowsPath(String normalizedPath, String originalPath) { - for (String prefix : BLOCKED_WINDOWS_PREFIXES) { - if (normalizedPath.startsWith(prefix)) { - return "Built-in denylist: sensitive system path '" + originalPath + "'"; - } - } - - for (String pattern : BLOCKED_WINDOWS_PATTERNS) { - if (normalizedPath.contains(pattern)) { - return "Built-in denylist: sensitive directory '" + originalPath + "'"; - } - } - - // Also check Unix patterns on Windows (for consistency) - for (String pattern : BLOCKED_UNIX_PATTERNS) { - if (normalizedPath.contains(pattern)) { - return "Built-in denylist: credential directory '" + originalPath + "'"; - } - } - - return null; + /** + * Returns the reason for the violation. + * + * @return the violation reason + */ + @NonNull + public String getReason() { + return reason; } } ``` -**Step 4: Run test to verify it passes** - -```bash -mvn -pl core test -Dtest=FileSystemDenylistTest -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylist.java -git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/FileSystemDenylistTest.java -git commit -m "feat(resolver): add FileSystemDenylist for blocking sensitive paths" -``` - --- -### Task 2.3: Create BuiltInDenylist Facade +### Task 1.3: Create GlobMatcher **Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylist.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylistTest.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/GlobMatcher.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/GlobMatcherTest.java` -**Step 1: Write the failing test** +**Test first:** ```java -package gov.nist.secauto.metaschema.core.model.resolver.denylist; +package dev.metaschema.core.model.policy; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; -import java.net.URI; - -class BuiltInDenylistTest { - - private final BuiltInDenylist denylist = BuiltInDenylist.getInstance(); +class GlobMatcherTest { - @Test - void testCombinesNetworkAndFileSystemDenylists() { - // Network denial - URI localhost = URI.create("http://localhost/admin"); - assertTrue(denylist.isDenied(localhost)); - assertNotNull(denylist.getDenialReason(localhost)); - - // File system denial (platform-dependent path) - URI etcPasswd = URI.create("file:///etc/passwd"); - // This test only validates on Unix-like systems - if (!System.getProperty("os.name").toLowerCase().contains("windows")) { - assertTrue(denylist.isDenied(etcPasswd)); - } + @ParameterizedTest + @CsvSource({ + "'**', 'anything/at/all', true", + "'**', '', true", + "'*.nist.gov/**', 'pages.nist.gov/schemas/foo.xml', true", + "'*.nist.gov/**', 'evil.com/nist.gov/attack', false", + "'/workspace/**', '/workspace/project/schema.xml', true", + "'/workspace/*', '/workspace/schema.xml', true", + "'/workspace/*', '/workspace/sub/schema.xml', false", + "'example.com/path/**', 'example.com/path/to/resource', true", + "'example.com/path/**', 'example.com/other/resource', false", + "'**/.ssh/**', '/home/user/.ssh/id_rsa', true", + "'**/.ssh/**', '/home/user/projects/ssh-keys', false", + "'localhost/**', 'localhost:8080/api', true", + "'127.*/**', '127.0.0.1/secret', true", + "'127.*/**', '128.0.0.1/public', false", + }) + void testPatternMatching(String pattern, String target, boolean expected) { + GlobMatcher matcher = GlobMatcher.compile(pattern); + assertEquals(expected, matcher.matches(target), + () -> String.format("Pattern '%s' vs '%s'", pattern, target)); } @Test - void testAllowedUri() { - URI allowed = URI.create("https://pages.nist.gov/schema.xml"); - assertFalse(denylist.isDenied(allowed)); - assertNull(denylist.getDenialReason(allowed)); + void testNullSafety() { + GlobMatcher matcher = GlobMatcher.compile("**"); + assertThrows(NullPointerException.class, () -> matcher.matches(null)); } @Test - void testAsRule() { - var rule = denylist.asRule(); - - URI blocked = URI.create("http://127.0.0.1/secret"); - assertEquals( - gov.nist.secauto.metaschema.core.model.resolver.IUriAccessRule.RuleResult.DENY, - rule.evaluate(blocked)); - - URI allowed = URI.create("https://example.com/api"); - assertEquals( - gov.nist.secauto.metaschema.core.model.resolver.IUriAccessRule.RuleResult.NO_MATCH, - rule.evaluate(allowed)); - } -} -``` - -**Step 2: Run test to verify it fails** - -```bash -mvn -pl core test -Dtest=BuiltInDenylistTest -DfailIfNoTests=false -``` - -**Step 3: Write implementation** - -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.core.model.resolver.denylist; - -import gov.nist.secauto.metaschema.core.model.resolver.IUriAccessRule; - -import java.net.URI; - -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; - -/** - * Combined built-in denylist that aggregates all security denylists. - *

- * This facade provides a single entry point for checking URIs against all - * built-in security rules. These rules cannot be disabled and are always - * enforced before user-defined allowlists. - */ -public final class BuiltInDenylist { - - private static final BuiltInDenylist INSTANCE = new BuiltInDenylist(); - - private final NetworkDenylist networkDenylist; - private final FileSystemDenylist fileSystemDenylist; - - private BuiltInDenylist() { - this.networkDenylist = NetworkDenylist.getInstance(); - this.fileSystemDenylist = FileSystemDenylist.getInstance(); - } - - /** - * Returns the singleton instance. - * - * @return the built-in denylist instance - */ - @NonNull - public static BuiltInDenylist getInstance() { - return INSTANCE; - } - - /** - * Checks if the given URI is denied by any built-in denylist. - * - * @param uri - * the URI to check - * @return {@code true} if the URI is denied - */ - public boolean isDenied(@NonNull URI uri) { - return getDenialReason(uri) != null; - } - - /** - * Returns the reason why the URI is denied, or null if allowed. - * - * @param uri - * the URI to check - * @return denial reason or null - */ - @Nullable - public String getDenialReason(@NonNull URI uri) { - // Check network denylist first (for http/https URIs) - String reason = networkDenylist.getDenialReason(uri); - if (reason != null) { - return reason; - } - - // Check file system denylist (for file:// URIs) - return fileSystemDenylist.getDenialReason(uri); - } - - /** - * Returns this denylist as an {@link IUriAccessRule}. - *

- * The returned rule returns {@link IUriAccessRule.RuleResult#DENY} for - * blocked URIs and {@link IUriAccessRule.RuleResult#NO_MATCH} for others - * (allowing subsequent rules to decide). - * - * @return this denylist as a rule - */ - @NonNull - public IUriAccessRule asRule() { - return new IUriAccessRule() { - @Override - @NonNull - public RuleResult evaluate(@NonNull URI uri) { - return isDenied(uri) ? RuleResult.DENY : RuleResult.NO_MATCH; - } - - @Override - @NonNull - public String getDescription() { - return "Built-in security denylist"; - } - }; + void testEmptyPattern() { + GlobMatcher matcher = GlobMatcher.compile(""); + assertTrue(matcher.matches("")); + assertFalse(matcher.matches("anything")); } } ``` -**Step 4: Run test to verify it passes** - -```bash -mvn -pl core test -Dtest=BuiltInDenylistTest -``` - -**Step 5: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylist.java -git add core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/BuiltInDenylistTest.java -git commit -m "feat(resolver): add BuiltInDenylist facade combining all security denylists" -``` +**Implementation:** Compile glob patterns to `java.util.regex.Pattern`: +- `*` → matches any characters except `/` +- `**` → matches any characters including `/` +- `?` → matches single character except `/` +- Escape regex special characters +- Case-insensitive matching on Windows for file paths --- -### Task 2.4: Add package-info.java +### Task 1.4: Create SchemePatternSet **Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/package-info.java` -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/package-info.java` - -**Step 1: Write implementations** - -```java -// package-info.java for denylist package -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -/** - * Built-in security denylists that are always enforced. - *

- * These denylists protect against common attack vectors such as: - *

    - *
  • Server-side request forgery (SSRF) to internal services
  • - *
  • Local file inclusion attacks
  • - *
  • Access to cloud metadata endpoints
  • - *
  • Exposure of credential files
  • - *
- *

- * The denylists in this package cannot be disabled by user configuration. - */ -@DefaultAnnotationForParameters(NonNull.class) -@DefaultAnnotationForFields(NonNull.class) -package gov.nist.secauto.metaschema.core.model.resolver.denylist; +- Create: `core/src/main/java/dev/metaschema/core/model/policy/SchemePatternSet.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/SchemePatternSetTest.java` -import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; -import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; -import edu.umd.cs.findbugs.annotations.NonNull; -``` +**Test first:** ```java -// package-info.java for resolver package -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -/** - * Allowlist-based URI resolver for secure resource access. - *

- * This package provides a security layer that validates URIs before allowing - * resource access. It supports: - *

    - *
  • Scheme-based policies (allow/deny by URI scheme)
  • - *
  • File system access rules (directory boundaries)
  • - *
  • HTTP access rules (domain and path restrictions)
  • - *
  • Built-in denylists for common attack vectors
  • - *
- * - * @see gov.nist.secauto.metaschema.core.model.resolver.IAllowlistUriResolver - * @see gov.nist.secauto.metaschema.core.model.resolver.AllowlistUriResolver - */ -@DefaultAnnotationForParameters(NonNull.class) -@DefaultAnnotationForFields(NonNull.class) -package gov.nist.secauto.metaschema.core.model.resolver; - -import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; -import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; -import edu.umd.cs.findbugs.annotations.NonNull; -``` - -**Step 2: Commit** - -```bash -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/package-info.java -git add core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/denylist/package-info.java -git commit -m "docs(resolver): add package-info.java for resolver packages" -``` - ---- - -### Task 2.5: Verify PR2 Build - -```bash -mvn -pl core clean install -mvn -pl core checkstyle:check -``` - ---- - -## PR3: Rule Implementations - -**Goal:** Implement file system, HTTP, and JAR resource rules. - -*(Tasks 3.1-3.4 follow same TDD pattern - FileSystemRule, HttpRule, JarRule, and tests)* - ---- - -## PR4: Main Resolver and Builder - -**Goal:** Implement AllowlistUriResolver and its builder. - -*(Tasks 4.1-4.3 follow same TDD pattern)* - ---- - -## PR5: Configuration System Infrastructure - -**Goal:** Implement layered configuration system with directory discovery, Metaschema-based configuration model, and merge support. +package dev.metaschema.core.model.policy; -**Module:** `databind-metaschema` (for config model and loading), `cli-processor` (for directory discovery service) - -### Task 5.0: Create Metaschema Configuration Model - -**Files:** -- Create: `databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml` - -**Step 1: Create the Metaschema module file** - -Create the directory structure and Metaschema module definition: +import static org.junit.jupiter.api.Assertions.*; -```bash -mkdir -p databind-metaschema/src/main/metaschema -``` +import org.junit.jupiter.api.Test; -```yaml -# databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml -metaschema: - schema-name: Allowlist Configuration - schema-version: 1.0.0 - short-name: allowlist-config - namespace: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 - json-base-uri: http://csrc.nist.gov/ns/metaschema/allowlist-config/1.0 - - definitions: - - define-assembly: - name: allowlist-config - formal-name: Allowlist Configuration - description: Configuration for the allowlist URI resolver. - root-name: allowlist-config - flags: - - define-flag: - name: default-policy - as-type: token - formal-name: Default Policy - description: Default policy for unlisted schemes. - constraint: - allowed-values: - - enum: - value: allow - description: Allow unlisted schemes - - enum: - value: deny - description: Deny unlisted schemes - model: - - assembly: - ref: scheme-config - max-occurs: unbounded - group-as: - name: schemes - in-json: BY_KEY - - assembly: - ref: logging-config - min-occurs: 0 - - - define-assembly: - name: scheme-config - formal-name: Scheme Configuration - description: Configuration for a specific URI scheme. - json-key: - flag-ref: scheme - flags: - - define-flag: - name: scheme - as-type: token - required: yes - formal-name: URI Scheme - description: The URI scheme (e.g., https, http, file, jar). - - define-flag: - name: enabled - as-type: boolean - formal-name: Enabled - description: Whether this scheme is enabled. - model: - - choice: - - assembly: - ref: http-rule - max-occurs: unbounded - group-as: - name: http-rules - in-json: ARRAY - - assembly: - ref: file-rule - max-occurs: unbounded - group-as: - name: file-rules - in-json: ARRAY - - assembly: - ref: jar-rule - max-occurs: unbounded - group-as: - name: jar-rules - in-json: ARRAY - - - define-assembly: - name: http-rule - formal-name: HTTP Rule - description: Access rule for HTTP/HTTPS URIs. - flags: - - define-flag: - name: domain - as-type: string - required: yes - formal-name: Domain - description: Domain pattern (e.g., "example.com", "*.nist.gov"). - model: - - field: - ref: path-prefix - max-occurs: unbounded - group-as: - name: paths - in-json: ARRAY - - - define-assembly: - name: file-rule - formal-name: File Rule - description: Access rule for file:// URIs. - flags: - - define-flag: - name: path - as-type: string - required: yes - formal-name: Path - description: Base directory path. - - define-flag: - name: scope - as-type: token - formal-name: Scope - description: Access scope for the directory. - constraint: - allowed-values: - - enum: - value: recursive - description: Allow recursive access - - enum: - value: single-level - description: Allow single level only - - - define-assembly: - name: jar-rule - formal-name: JAR Rule - description: Access rule for jar: URIs. - flags: - - define-flag: - name: path - as-type: string - required: yes - formal-name: Path - description: Resource path pattern within JAR. - - - define-field: - name: path-prefix - as-type: string - formal-name: Path Prefix - description: Allowed path prefix. - - - define-assembly: - name: logging-config - formal-name: Logging Configuration - description: Audit logging settings. - flags: - - define-flag: - name: level - as-type: token - formal-name: Log Level - description: Minimum log level for access attempts. - - define-flag: - name: include-allowed - as-type: boolean - formal-name: Include Allowed - description: Whether to log allowed access attempts. -``` +class SchemePatternSetTest { -**Step 2: Commit** + @Test + void testDisabledSchemeDeniesAll() { + SchemePatternSet set = SchemePatternSet.disabled("http"); + assertFalse(set.isAllowed("example.com/api")); + assertEquals("scheme 'http' is disabled", set.getDenialReason("example.com/api")); + } -```bash -git add databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml -git commit -m "feat(config): add Metaschema module for allowlist configuration" -``` + @Test + void testNoPatternsAllowsAll() { + SchemePatternSet set = SchemePatternSet.enabled("https"); + assertTrue(set.isAllowed("example.com/anything")); + } ---- + @Test + void testAllowPattern() { + SchemePatternSet set = SchemePatternSet.builder("file") + .allow("/workspace/**") + .build(); -### Task 5.0b: Configure Maven Code Generation + assertTrue(set.isAllowed("/workspace/project/schema.xml")); + assertFalse(set.isAllowed("/etc/passwd")); + } -**Files:** -- Modify: `databind-metaschema/pom.xml` - -**Step 1: Add metaschema-maven-plugin configuration** - -The `databind-metaschema` module already has metaschema-maven-plugin configured. Add the allowlist config module to the existing configuration by ensuring the `metaschemaDir` includes our new module: - -```xml - - - gov.nist.secauto.metaschema - metaschema-maven-plugin - - - generate-sources - - generate-sources - - - ${project.basedir}/src/main/metaschema - - - - - -``` + @Test + void testDenyPatternOverridesAllow() { + SchemePatternSet set = SchemePatternSet.builder("file") + .allow("**") + .deny("**/.ssh/**") + .build(); -**Step 2: Verify code generation** + assertTrue(set.isAllowed("/workspace/schema.xml")); + assertFalse(set.isAllowed("/home/user/.ssh/id_rsa")); + } -```bash -mvn -pl databind-metaschema generate-sources -``` + @Test + void testLastMatchWins() { + SchemePatternSet set = SchemePatternSet.builder("file") + .allow("**") // allow everything + .deny("/etc/**") // except /etc + .allow("/etc/motd") // but re-allow /etc/motd + .build(); -Expected: Generated Java classes in `databind-metaschema/target/generated-sources/metaschema/` including: -- `AllowlistConfig.java` -- `SchemeConfig.java` -- `HttpRule.java` -- `FileRule.java` -- `JarRule.java` -- `LoggingConfig.java` + assertTrue(set.isAllowed("/workspace/file.xml")); + assertFalse(set.isAllowed("/etc/passwd")); + assertTrue(set.isAllowed("/etc/motd")); + } -**Step 3: Commit (if pom.xml changes needed)** + @Test + void testNoMatchUsesDefault() { + // With default deny (no patterns match) + SchemePatternSet set = SchemePatternSet.builder("https") + .allow("nist.gov/**") + .build(); -```bash -git add databind-metaschema/pom.xml -git commit -m "build(databind): configure code generation for allowlist config module" + assertTrue(set.isAllowed("nist.gov/schemas/x.xml")); + assertFalse(set.isAllowed("evil.com/attack")); + } +} ``` ---- +**Implementation:** Holds an ordered list of `(GlobMatcher, boolean isAllow)` entries. Evaluates last-match-wins. -**Package:** `gov.nist.secauto.metaschema.cli.processor.config` +--- -### Task 5.1: Create IConfigurationService Interface +### Task 1.5: Create IResourceAccessPolicy Interface **Files:** -- Create: `cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/IConfigurationService.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/IResourceAccessPolicy.java` **Implementation:** ```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ +package dev.metaschema.core.model.policy; -package gov.nist.secauto.metaschema.cli.processor.config; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.net.URI; import edu.umd.cs.findbugs.annotations.NonNull; /** - * Service for discovering and loading layered configuration files. - *

- * Configurations are loaded from multiple locations in precedence order: - *

    - *
  1. Install directory ({@code /config/})
  2. - *
  3. System-wide ({@code /etc/metaschema-cli/} or {@code %ProgramData%\metaschema-cli\})
  4. - *
  5. User home ({@code ~/.metaschema-cli/})
  6. - *
  7. Project local ({@code ./.metaschema/})
  8. - *
  9. CLI argument or environment variable override
  10. - *
+ * Policy that controls which URIs can be accessed during resource loading. *

- * Configurations from all sources are merged according to type-specific rules. + * Implementations evaluate URIs against configured rules and take action + * based on the {@link PolicyMode}: log violations (audit), block violations + * (enforce), or skip checking entirely (disabled). + * + * @see ResourceAccessPolicy */ -public interface IConfigurationService { - - /** - * Get the merged configuration for a specific config file. - * - * @param configName - * the config file name (e.g., "allowlist.yaml") - * @return the merged configuration as a map, or empty if no configs found - */ - @NonNull - Optional> getConfiguration(@NonNull String configName); +public interface IResourceAccessPolicy { /** - * Get all discovered config directory paths in precedence order. - * - * @return list of paths (lowest to highest precedence) - */ - @NonNull - List getConfigDirectories(); - - /** - * Reload all configurations from disk. + * A policy that allows all access without checking. */ - void reload(); + IResourceAccessPolicy ALLOW_ALL = uri -> { /* no-op */ }; /** - * Set an explicit configuration directory override. + * Checks whether the given URI is allowed by this policy. *

- * When set, this takes highest precedence over all other sources. + * Depending on the {@link PolicyMode}: + *

    + *
  • {@code DISABLED}: No checking, always returns
  • + *
  • {@code AUDIT}: Checks and logs violations, always returns
  • + *
  • {@code ENFORCE}: Checks and throws + * {@link AccessViolationException} on violation
  • + *
* - * @param path - * the override directory path, or null to clear + * @param uri + * the URI to check + * @throws AccessViolationException + * if the policy is in ENFORCE mode and the URI is denied */ - void setOverrideDirectory(Path path); + void checkAccess(@NonNull URI uri); } ``` -**Step 2: Commit** - -```bash -git add cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/IConfigurationService.java -git commit -m "feat(config): add IConfigurationService interface for layered configuration" -``` - --- -### Task 5.2: Create ConfigurationService Implementation +### Task 1.6: Create ResourceAccessPolicy and Builder **Files:** -- Create: `cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationService.java` -- Test: `cli-processor/src/test/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationServiceTest.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/ResourceAccessPolicy.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/ResourceAccessPolicyBuilder.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/ResourceAccessPolicyTest.java` -**Step 1: Write the failing test** +**Test first:** ```java -package gov.nist.secauto.metaschema.cli.processor.config; +package dev.metaschema.core.model.policy; import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.net.URI; -class ConfigurationServiceTest { +class ResourceAccessPolicyTest { - @TempDir - Path tempDir; + @Test + void testDisabledModeAllowsEverything() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.DISABLED) + .forScheme("file").denyAll() + .build(); + + // Should not throw even though file scheme is denied + assertDoesNotThrow(() -> policy.checkAccess(URI.create("file:///etc/passwd"))); + } + + @Test + void testAuditModeLogsButAllows() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.AUDIT) + .forScheme("http").denyAll() + .defaultDeny() + .build(); - private Path installConfig; - private Path userConfig; - private Path projectConfig; + // Should not throw even though http is denied + assertDoesNotThrow(() -> policy.checkAccess(URI.create("http://localhost/admin"))); + } - @BeforeEach - void setUp() throws IOException { - installConfig = tempDir.resolve("install/config"); - userConfig = tempDir.resolve("user/.metaschema-cli"); - projectConfig = tempDir.resolve("project/.metaschema"); + @Test + void testEnforceModeBlocks() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("http").denyAll() + .defaultDeny() + .build(); - Files.createDirectories(installConfig); - Files.createDirectories(userConfig); - Files.createDirectories(projectConfig); + assertThrows(AccessViolationException.class, + () -> policy.checkAccess(URI.create("http://localhost/admin"))); } @Test - void testConfigDirectoryDiscovery() { - ConfigurationService service = ConfigurationService.builder() - .withInstallDirectory(installConfig) - .withUserDirectory(userConfig) - .withProjectDirectory(projectConfig) + void testEnforceModeAllowsMatching() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("https") + .allow("nist.gov/**") + .forScheme("file") + .allow("/workspace/**") + .defaultDeny() .build(); - List dirs = service.getConfigDirectories(); - assertEquals(3, dirs.size()); - assertEquals(installConfig, dirs.get(0)); // Lowest precedence - assertEquals(projectConfig, dirs.get(2)); // Highest precedence + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("https://nist.gov/schemas/x.xml"))); + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("file:///workspace/project/module.xml"))); } @Test - void testConfigurationLoading() throws IOException { - // Create config file in install directory - Files.writeString(installConfig.resolve("test.yaml"), - "key1: value1\nkey2: value2"); + void testDenyPatternExceptions() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file") + .allow("**") + .deny("**/.ssh/**") + .defaultDeny() + .build(); + + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("file:///workspace/schema.xml"))); + assertThrows(AccessViolationException.class, + () -> policy.checkAccess( + URI.create("file:///home/user/.ssh/id_rsa"))); + } - ConfigurationService service = ConfigurationService.builder() - .withInstallDirectory(installConfig) + @Test + void testDefaultDenyBlocksUnknownSchemes() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("https").allowAll() + .defaultDeny() .build(); - Optional> config = service.getConfiguration("test.yaml"); - assertTrue(config.isPresent()); - assertEquals("value1", config.get().get("key1")); - assertEquals("value2", config.get().get("key2")); + assertThrows(AccessViolationException.class, + () -> policy.checkAccess(URI.create("ftp://evil.com/file"))); } @Test - void testOverrideDirectoryTakesPrecedence() throws IOException { - Path overrideDir = tempDir.resolve("override"); - Files.createDirectories(overrideDir); + void testWithModeCreatesNewPolicy() { + ResourceAccessPolicy audit = ResourceAccessPolicy.builder() + .mode(PolicyMode.AUDIT) + .forScheme("http").denyAll() + .defaultDeny() + .build(); - Files.writeString(installConfig.resolve("test.yaml"), "source: install"); - Files.writeString(overrideDir.resolve("test.yaml"), "source: override"); + // Audit mode allows + assertDoesNotThrow(() -> audit.checkAccess( + URI.create("http://localhost/admin"))); + + // Enforce mode blocks + ResourceAccessPolicy enforced = audit.withMode(PolicyMode.ENFORCE); + assertThrows(AccessViolationException.class, + () -> enforced.checkAccess(URI.create("http://localhost/admin"))); + } - ConfigurationService service = ConfigurationService.builder() - .withInstallDirectory(installConfig) + @Test + void testUriSchemeExtraction() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file") + .allow("/workspace/**") + .defaultDeny() .build(); - service.setOverrideDirectory(overrideDir); + // file:///workspace/x → matches file scheme, path /workspace/x + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("file:///workspace/x.xml"))); - Optional> config = service.getConfiguration("test.yaml"); - assertTrue(config.isPresent()); - assertEquals("override", config.get().get("source")); + // https not configured, default deny + assertThrows(AccessViolationException.class, + () -> policy.checkAccess(URI.create("https://example.com"))); } @Test - void testMissingConfigReturnsEmpty() { - ConfigurationService service = ConfigurationService.builder() - .withInstallDirectory(installConfig) + void testJarSchemeExtraction() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("jar") + .allow("/schema/**") + .defaultDeny() .build(); - Optional> config = service.getConfiguration("nonexistent.yaml"); - assertTrue(config.isEmpty()); + // jar:file:///lib.jar!/schema/x.xsd → matches jar scheme, path /schema/x.xsd + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("jar:file:///lib.jar!/schema/x.xsd"))); } } ``` -**Step 2: Run test to verify it fails** +**Implementation:** Main policy class that: +1. Extracts scheme from URI +2. Looks up `SchemePatternSet` for that scheme +3. Extracts scheme-specific match target from URI +4. Evaluates patterns +5. Applies mode behavior (log/block/ignore) -```bash -mvn -pl cli-processor test -Dtest=ConfigurationServiceTest -DfailIfNoTests=false -``` +--- -**Step 3: Write implementation** +### Task 1.7: Create FileProtections -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/FileProtections.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/FileProtectionsTest.java` -package gov.nist.secauto.metaschema.cli.processor.config; +**Test first:** -import gov.nist.secauto.metaschema.core.util.CollectionUtil; -import gov.nist.secauto.metaschema.core.util.ObjectUtils; +```java +package dev.metaschema.core.model.policy; -import org.yaml.snakeyaml.Yaml; +import static org.junit.jupiter.api.Assertions.*; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; -import edu.umd.cs.findbugs.annotations.NonNull; -import edu.umd.cs.findbugs.annotations.Nullable; +import java.nio.file.Path; -/** - * Default implementation of {@link IConfigurationService}. - *

- * Discovers and loads configuration files from multiple directories, - * merging them according to precedence rules. - */ -public final class ConfigurationService implements IConfigurationService { +class FileProtectionsTest { - private static final boolean IS_WINDOWS = System.getProperty("os.name", "") - .toLowerCase(Locale.ROOT).contains("windows"); + @TempDir + Path cwd; - @NonNull - private final List configDirectories; - @NonNull - private final Map> configCache; - @Nullable - private Path overrideDirectory; + @ParameterizedTest + @ValueSource(strings = { + "/etc/passwd", + "/proc/self/environ", + "/sys/kernel/debug", + "/dev/null", + "/root/.bashrc", + "/var/run/secrets/kubernetes.io/token", + "C:/Windows/System32/config/SAM", + }) + void testDefaultDeniesPathsOutsideSafeAreas(String path) { + FileProtections protections = FileProtections.withDefaults(cwd); + assertFalse(protections.isAllowed(path), + "Should deny (outside safe areas): " + path); + } - private ConfigurationService(@NonNull List directories) { - this.configDirectories = CollectionUtil.unmodifiableList(new ArrayList<>(directories)); - this.configCache = new ConcurrentHashMap<>(); + @Test + void testDefaultAllowsCwdSubtree() { + FileProtections protections = FileProtections.withDefaults(cwd); + String cwdPath = cwd.resolve("project/schema.xml").toString(); + assertTrue(protections.isAllowed(cwdPath), + "Should allow CWD subtree"); } - /** - * Creates a new builder for ConfigurationService. - * - * @return a new builder - */ - @NonNull - public static Builder builder() { - return new Builder(); + @Test + void testDefaultDeniesSensitiveDotDirsInHome() { + // Home dir subtree is allowed, but sensitive dot-dirs are excluded + Path home = Path.of(System.getProperty("user.home")); + FileProtections protections = FileProtections.withDefaults(cwd); + + String sshKey = home.resolve(".ssh/id_rsa").toString(); + assertFalse(protections.isAllowed(sshKey), + "Should deny ~/.ssh even though home is allowed"); + + String awsCreds = home.resolve(".aws/credentials").toString(); + assertFalse(protections.isAllowed(awsCreds), + "Should deny ~/.aws even though home is allowed"); + + String normalFile = home.resolve("projects/schema.xml").toString(); + assertTrue(protections.isAllowed(normalFile), + "Should allow normal files in home"); } - /** - * Creates a ConfigurationService with default directory detection. - * - * @return a new ConfigurationService with standard directories - */ - @NonNull - public static ConfigurationService createDefault() { - return builder() - .withDefaultDirectories() + @Test + void testBuilderIncludeDefaults() { + FileProtections protections = FileProtections.builder(cwd) + .includeDefaults() + .allow("/opt/metaschema/**") .build(); - } - @Override - @NonNull - public Optional> getConfiguration(@NonNull String configName) { - // Check cache first - if (configCache.containsKey(configName)) { - Map cached = configCache.get(configName); - return cached.isEmpty() ? Optional.empty() : Optional.of(cached); - } - - // Load and merge from all sources - Map merged = loadAndMerge(configName); - configCache.put(configName, merged); - - return merged.isEmpty() ? Optional.empty() : Optional.of(merged); + String cwdFile = cwd.resolve("schema.xml").toString(); + assertTrue(protections.isAllowed(cwdFile)); // from defaults + assertTrue(protections.isAllowed("/opt/metaschema/x")); // custom addition + assertFalse(protections.isAllowed("/etc/passwd")); // not allowed } - @NonNull - private Map loadAndMerge(@NonNull String configName) { - Map result = new LinkedHashMap<>(); - Yaml yaml = new Yaml(); - - // Load from directories in precedence order (lowest to highest) - for (Path dir : getEffectiveDirectories()) { - Path configFile = dir.resolve(configName); - if (Files.exists(configFile) && Files.isRegularFile(configFile)) { - try (InputStream is = Files.newInputStream(configFile)) { - Map loaded = yaml.load(is); - if (loaded != null) { - // Simple shallow merge - higher precedence overwrites - result.putAll(loaded); - } - } catch (IOException e) { - // Log warning and continue - // In production, use SLF4J logging - } - } - } - - return result; - } + @Test + void testBuilderRemoveDefault() { + FileProtections protections = FileProtections.builder(cwd) + .includeDefaults() + .remove("/**") // remove home dir access + .build(); - @NonNull - private List getEffectiveDirectories() { - if (overrideDirectory != null) { - // Override takes highest precedence - List dirs = new ArrayList<>(configDirectories); - dirs.add(overrideDirectory); - return dirs; - } - return configDirectories; - } + Path home = Path.of(System.getProperty("user.home")); + String homeFile = home.resolve("file.txt").toString(); + assertFalse(protections.isAllowed(homeFile)); // removed - @Override - @NonNull - public List getConfigDirectories() { - return configDirectories; + String cwdFile = cwd.resolve("file.txt").toString(); + assertTrue(protections.isAllowed(cwdFile)); // CWD still allowed } - @Override - public void reload() { - configCache.clear(); + @Test + void testBuilderFullyCustom() { + FileProtections protections = FileProtections.builder(cwd) + .allow("/opt/app/**") + .build(); + + assertTrue(protections.isAllowed("/opt/app/schema.xml")); + assertFalse(protections.isAllowed("/etc/passwd")); + // CWD not included since we didn't call includeDefaults() + String cwdFile = cwd.resolve("file.txt").toString(); + assertFalse(protections.isAllowed(cwdFile)); } - @Override - public void setOverrideDirectory(@Nullable Path path) { - this.overrideDirectory = path; - reload(); // Clear cache when override changes + @Test + void testNoneAllowsEverything() { + FileProtections protections = FileProtections.none(); + assertTrue(protections.isAllowed("/etc/passwd")); + assertTrue(protections.isAllowed("/home/user/.ssh/key")); } - /** - * Builder for ConfigurationService. - */ - public static final class Builder { - private final List directories = new ArrayList<>(); - - private Builder() { - } - - /** - * Add a configuration directory. - * - * @param dir the directory to add - * @return this builder - */ - @NonNull - public Builder withDirectory(@NonNull Path dir) { - if (Files.isDirectory(dir)) { - directories.add(dir); - } - return this; - } - - /** - * Set the install directory. - * - * @param dir the install config directory - * @return this builder - */ - @NonNull - public Builder withInstallDirectory(@NonNull Path dir) { - return withDirectory(dir); - } - - /** - * Set the user directory. - * - * @param dir the user config directory - * @return this builder - */ - @NonNull - public Builder withUserDirectory(@NonNull Path dir) { - return withDirectory(dir); - } - - /** - * Set the project directory. - * - * @param dir the project config directory - * @return this builder - */ - @NonNull - public Builder withProjectDirectory(@NonNull Path dir) { - return withDirectory(dir); - } - - /** - * Configure with default directory detection. - * - * @return this builder - */ - @NonNull - public Builder withDefaultDirectories() { - // 1. Install directory (detect from JAR location) - detectInstallDirectory().ifPresent(this::withDirectory); - - // 2. System-wide - Path systemDir = IS_WINDOWS - ? Path.of(System.getenv("ProgramData"), "metaschema-cli") - : Path.of("/etc/metaschema-cli"); - withDirectory(systemDir); - - // 3. User home - String userHome = System.getProperty("user.home"); - if (userHome != null) { - withDirectory(Path.of(userHome, ".metaschema-cli")); - } - - // 4. Project local (current working directory) - withDirectory(Path.of(".metaschema")); - - return this; - } - - @NonNull - private Optional detectInstallDirectory() { - try { - // Get the JAR location - Path jarPath = Path.of(ConfigurationService.class - .getProtectionDomain() - .getCodeSource() - .getLocation() - .toURI()); - - // Look for sibling config directory - Path configDir = jarPath.getParent().getParent().resolve("config"); - if (Files.isDirectory(configDir)) { - return Optional.of(configDir); - } - } catch (Exception e) { - // Ignore - running from IDE or other non-standard location - } - return Optional.empty(); - } - - /** - * Build the ConfigurationService. - * - * @return a new ConfigurationService - */ - @NonNull - public ConfigurationService build() { - return new ConfigurationService(directories); - } + @Test + void testDefaultPatternsAreInspectable() { + assertFalse(FileProtections.defaultAllowPatterns().isEmpty()); } } ``` -**Step 4: Run test to verify it passes** - -```bash -mvn -pl cli-processor test -Dtest=ConfigurationServiceTest -``` - -**Step 5: Commit** - -```bash -git add cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationService.java -git add cli-processor/src/test/java/gov/nist/secauto/metaschema/cli/processor/config/ConfigurationServiceTest.java -git commit -m "feat(config): add ConfigurationService with directory discovery" -``` +**Implementation:** `FileProtections` holds an ordered list of allow/deny patterns (with `!` negation) checked against file paths. Provides: +- `withDefaults(Path cwd)` — shipped allow patterns (CWD + home minus sensitive dot-dirs) +- `none()` — no protections (allows everything) +- `builder(Path cwd)` — customizable with `includeDefaults()`, `allow()`, `remove()` +- `defaultAllowPatterns()` — static method to inspect defaults +- `isAllowed(String path)` — check if a path is allowed --- -### Task 5.3: Add package-info.java +### Task 1.8: Add package-info.java **Files:** -- Create: `cli-processor/src/main/java/gov/nist/secauto/metaschema/cli/processor/config/package-info.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/package-info.java` -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ +--- -/** - * Layered configuration system for CLI tools. - *

- * This package provides infrastructure for loading and merging configuration - * files from multiple locations with precedence rules: - *

    - *
  1. Install directory - shipped defaults
  2. - *
  3. System-wide - administrator settings
  4. - *
  5. User home - user preferences
  6. - *
  7. Project local - project-specific overrides
  8. - *
  9. CLI/environment override - explicit override
  10. - *
- * - * @see gov.nist.secauto.metaschema.cli.processor.config.IConfigurationService - */ -@DefaultAnnotationForParameters(NonNull.class) -@DefaultAnnotationForFields(NonNull.class) -package gov.nist.secauto.metaschema.cli.processor.config; +### Task 1.9: Verify PR1 Build -import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields; -import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters; -import edu.umd.cs.findbugs.annotations.NonNull; +```bash +mvn -pl core clean install +mvn -pl core checkstyle:check ``` --- -### Task 5.4: Verify PR5 Build +## PR2: Configuration Model and Bundled Defaults -```bash -mvn -pl cli-processor clean install -mvn -pl cli-processor checkstyle:check -``` +**Goal:** Define the Metaschema configuration module, implement config loading, and ship bundled restrictive defaults. + +### Task 2.1: Create Metaschema Configuration Module + +**Files:** +- Create: `core/src/main/metaschema/resource-access-policy_metaschema.yaml` + +The Metaschema module definition for the resource access policy configuration model. See PRD for full module definition. --- -## PR6: Allowlist Configuration Loading and Merge +### Task 2.2: Configure Maven Code Generation -**Goal:** Implement allowlist-specific configuration loading using Metaschema-generated binding classes with scheme-deep/domain-shallow merge semantics. +**Files:** +- Modify: `core/pom.xml` (if needed - verify if metaschema-maven-plugin is already configured for `src/main/metaschema`) -**Module:** `databind-metaschema` +Verify generated binding classes compile and contain expected fields: +- `ResourceAccessPolicy` (root assembly) +- `SchemeConfig` (scheme configuration) +- `Pattern` (access pattern field) -**Package:** `gov.nist.secauto.metaschema.databind.metaschema.config` +--- -### Task 6.0: Create AllowlistConfigurationLoader +### Task 2.3: Create Bundled Default Policy **Files:** -- Create: `databind-metaschema/src/main/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoader.java` -- Test: `databind-metaschema/src/test/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoaderTest.java` +- Create: `core/src/main/resources/dev/metaschema/core/model/policy/default-resource-access-policy.yaml` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/BundledDefaultsTest.java` -**Step 1: Write the failing test** +**Test first:** ```java -package gov.nist.secauto.metaschema.databind.metaschema.config; +package dev.metaschema.core.model.policy; import static org.junit.jupiter.api.Assertions.*; -import gov.nist.secauto.metaschema.databind.IBindingContext; - -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.net.URI; -class AllowlistConfigurationLoaderTest { +class BundledDefaultsTest { - private static IBindingContext bindingContext; + private final ResourceAccessPolicy defaults = ResourceAccessPolicy.bundledDefaults(); - @TempDir - Path tempDir; + @Test + void testDefaultModeIsAudit() { + // Audit mode: should log but not throw + assertDoesNotThrow(() -> defaults.checkAccess( + URI.create("http://localhost/admin"))); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://pages.nist.gov/schemas/x.xml", + "https://example.com/api", + "file:///workspace/schema.xml", + "jar:file:///lib.jar!/schema/x.xsd", + }) + void testDefaultAllowedInEnforceMode(String uriString) { + ResourceAccessPolicy enforced = defaults.withMode(PolicyMode.ENFORCE); + assertDoesNotThrow(() -> enforced.checkAccess(URI.create(uriString))); + } - @BeforeAll - static void setUp() throws Exception { - bindingContext = IBindingContext.newInstance(); + @ParameterizedTest + @ValueSource(strings = { + "http://example.com/api", + "ftp://evil.com/file", + }) + void testDefaultDeniedSchemesInEnforceMode(String uriString) { + ResourceAccessPolicy enforced = defaults.withMode(PolicyMode.ENFORCE); + assertThrows(AccessViolationException.class, + () -> enforced.checkAccess(URI.create(uriString))); } - @Test - void testLoadYamlConfiguration() throws IOException { - Path configFile = tempDir.resolve("allowlist.yaml"); - Files.writeString(configFile, """ - default-policy: deny - schemes: - https: - enabled: true - http-rules: - - domain: nist.gov - paths: - - /schemas/ - """); - - AllowlistConfigurationLoader loader = new AllowlistConfigurationLoader(bindingContext); - AllowlistConfig config = loader.load(configFile); - - assertNotNull(config); - assertEquals("deny", config.getDefaultPolicy()); - assertNotNull(config.getSchemes()); + @ParameterizedTest + @ValueSource(strings = { + "https://localhost/admin", + "https://127.0.0.1/secret", + "https://169.254.169.254/meta", + "https://10.0.0.1/internal", + "https://192.168.1.1/router", + }) + void testDefaultDeniedNetworkInEnforceMode(String uriString) { + ResourceAccessPolicy enforced = defaults.withMode(PolicyMode.ENFORCE); + assertThrows(AccessViolationException.class, + () -> enforced.checkAccess(URI.create(uriString))); } - @Test - void testLoadJsonConfiguration() throws IOException { - Path configFile = tempDir.resolve("allowlist.json"); - Files.writeString(configFile, """ - { - "default-policy": "allow", - "schemes": { - "file": { - "enabled": false - } - } - } - """); - - AllowlistConfigurationLoader loader = new AllowlistConfigurationLoader(bindingContext); - AllowlistConfig config = loader.load(configFile); - - assertNotNull(config); - assertEquals("allow", config.getDefaultPolicy()); + @ParameterizedTest + @ValueSource(strings = { + "file:///etc/passwd", + "file:///proc/self/environ", + "file:///home/user/.ssh/id_rsa", + "file:///home/user/.aws/credentials", + }) + void testDefaultDeniedFilePathsInEnforceMode(String uriString) { + ResourceAccessPolicy enforced = defaults.withMode(PolicyMode.ENFORCE); + assertThrows(AccessViolationException.class, + () -> enforced.checkAccess(URI.create(uriString))); } } ``` -**Step 2: Write implementation** +**Implementation:** Load bundled YAML from classpath resource and parse into `ResourceAccessPolicy`. -```java -/* - * SPDX-FileCopyrightText: none - * SPDX-License-Identifier: CC0-1.0 - */ - -package gov.nist.secauto.metaschema.databind.metaschema.config; - -import gov.nist.secauto.metaschema.databind.IBindingContext; -import gov.nist.secauto.metaschema.databind.io.IBoundLoader; - -import java.io.IOException; -import java.nio.file.Path; +--- -import edu.umd.cs.findbugs.annotations.NonNull; +### Task 2.4: Implement Configuration Loading -/** - * Loads allowlist configuration files using Metaschema databind. - *

- * Supports loading from YAML, JSON, or XML formats based on file extension. - */ -public class AllowlistConfigurationLoader { +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/ResourceAccessPolicyLoader.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/ResourceAccessPolicyLoaderTest.java` - @NonNull - private final IBoundLoader loader; +**Test first:** Verify loading from YAML, JSON, and XML config files. Verify configuration layering with merge semantics. - /** - * Creates a new configuration loader. - * - * @param bindingContext - * the binding context for deserialization - */ - public AllowlistConfigurationLoader(@NonNull IBindingContext bindingContext) { - this.loader = bindingContext.newBoundLoader(); - } +**Implementation:** Uses `IBoundLoader` to load the generated binding classes, then converts to `ResourceAccessPolicy`. - /** - * Loads an allowlist configuration from a file. - *

- * The format is auto-detected from the file extension. - * - * @param configFile - * the path to the configuration file - * @return the loaded configuration - * @throws IOException - * if an error occurs reading the file - */ - @NonNull - public AllowlistConfig load(@NonNull Path configFile) throws IOException { - return loader.load(AllowlistConfig.class, configFile); - } -} -``` +--- -**Step 3: Commit** +### Task 2.5: Verify PR2 Build ```bash -git add databind-metaschema/src/main/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoader.java -git add databind-metaschema/src/test/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationLoaderTest.java -git commit -m "feat(config): add AllowlistConfigurationLoader using databind" +mvn -pl core clean install +mvn -pl core checkstyle:check ``` --- -### Task 6.1: Create AllowlistConfigurationMerger +## PR3: Loader Integration + +**Goal:** Integrate policy checking into all resource loading paths. + +### Task 3.1: Add Policy Support to Loader Interfaces **Files:** -- Create: `databind-metaschema/src/main/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationMerger.java` -- Test: `databind-metaschema/src/test/java/gov/nist/secauto/metaschema/databind/metaschema/config/AllowlistConfigurationMergerTest.java` +- Modify: `core/src/main/java/dev/metaschema/core/model/IModuleLoader.java` -**Step 1: Write the failing test** +Add method to set resource access policy: ```java -package gov.nist.secauto.metaschema.core.model.resolver.config; +/** + * Sets the resource access policy for this loader. + *

+ * When set, all URIs resolved by this loader are checked against the policy + * before loading. + * + * @param policy + * the policy to enforce, or {@code null} to disable + */ +void setResourceAccessPolicy(@Nullable IResourceAccessPolicy policy); +``` -import static org.junit.jupiter.api.Assertions.*; +--- -import org.junit.jupiter.api.Test; +### Task 3.2: Integrate Policy in AbstractModuleLoader -import java.util.List; -import java.util.Map; +**Files:** +- Modify: `core/src/main/java/dev/metaschema/core/model/AbstractModuleLoader.java` +- Test: `core/src/test/java/dev/metaschema/core/model/AbstractModuleLoaderPolicyTest.java` -class AllowlistConfigurationMergerTest { +**Test first:** Verify module import URIs are checked against policy. - private final AllowlistConfigurationMerger merger = new AllowlistConfigurationMerger(); +**Implementation:** Add policy field and check before URI resolution: - @Test - void testDeepMergeOnScheme() { - // Install config has https rules - Map install = Map.of( - "schemes", Map.of( - "https", Map.of( - "enabled", true, - "rules", List.of( - Map.of("domain", "nist.gov", "paths", List.of("/schemas/")))))); - - // User config adds more https rules - Map user = Map.of( - "schemes", Map.of( - "https", Map.of( - "rules", List.of( - Map.of("domain", "github.com", "paths", List.of("/repos/")))))); - - Map merged = merger.merge(install, user); - - // Both domains should be present (deep merge on scheme) - @SuppressWarnings("unchecked") - Map schemes = (Map) merged.get("schemes"); - @SuppressWarnings("unchecked") - Map https = (Map) schemes.get("https"); - @SuppressWarnings("unchecked") - List> rules = (List>) https.get("rules"); - - assertEquals(2, rules.size()); - assertTrue(rules.stream().anyMatch(r -> "nist.gov".equals(r.get("domain")))); - assertTrue(rules.stream().anyMatch(r -> "github.com".equals(r.get("domain")))); - } +```java +// In resolveImport or similar method: +URI resolvedResource = ObjectUtils.notNull(resource.resolve(importedResource)); +IResourceAccessPolicy policy = getResourceAccessPolicy(); +if (policy != null) { + policy.checkAccess(resolvedResource); +} +``` - @Test - void testShallowMergeOnDomain() { - // Install config has nist.gov with /schemas/ - Map install = Map.of( - "schemes", Map.of( - "https", Map.of( - "rules", List.of( - Map.of("domain", "nist.gov", "paths", List.of("/schemas/")))))); - - // User config redefines nist.gov with different paths - Map user = Map.of( - "schemes", Map.of( - "https", Map.of( - "rules", List.of( - Map.of("domain", "nist.gov", "paths", List.of("/docs/", "/api/")))))); - - Map merged = merger.merge(install, user); - - @SuppressWarnings("unchecked") - Map schemes = (Map) merged.get("schemes"); - @SuppressWarnings("unchecked") - Map https = (Map) schemes.get("https"); - @SuppressWarnings("unchecked") - List> rules = (List>) https.get("rules"); - - // Only one nist.gov entry (user's version replaces install's) - List> nistRules = rules.stream() - .filter(r -> "nist.gov".equals(r.get("domain"))) - .toList(); - assertEquals(1, nistRules.size()); - - // User's paths should be present, not install's - @SuppressWarnings("unchecked") - List paths = (List) nistRules.get(0).get("paths"); - assertTrue(paths.contains("/docs/")); - assertTrue(paths.contains("/api/")); - assertFalse(paths.contains("/schemas/")); - } +--- - @Test - void testEnabledFlagOverride() { - Map install = Map.of( - "schemes", Map.of( - "file", Map.of("enabled", false))); +### Task 3.3: Integrate Policy in DefaultBoundLoader + +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/io/DefaultBoundLoader.java` +- Test: `databind/src/test/java/dev/metaschema/databind/io/DefaultBoundLoaderPolicyTest.java` - Map user = Map.of( - "schemes", Map.of( - "file", Map.of("enabled", true))); +**Test first:** Verify document loading URIs are checked against policy. - Map merged = merger.merge(install, user); +--- - @SuppressWarnings("unchecked") - Map schemes = (Map) merged.get("schemes"); - @SuppressWarnings("unchecked") - Map file = (Map) schemes.get("file"); +### Task 3.4: Integrate Policy in BindingConstraintLoader - // User's enabled=true should override install's enabled=false - assertEquals(true, file.get("enabled")); - } -} -``` +**Files:** +- Modify: `databind/src/main/java/dev/metaschema/databind/model/metaschema/BindingConstraintLoader.java` +- Test: `databind/src/test/java/dev/metaschema/databind/model/metaschema/BindingConstraintLoaderPolicyTest.java` -*(Implementation follows TDD pattern)* +**Test first:** Verify constraint import URIs are checked against policy. --- -### Task 6.2: Create AllowlistConfiguration POJO +### Task 3.5: Integrate Policy in DefaultXmlDeserializer **Files:** -- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfiguration.java` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/resolver/config/AllowlistConfigurationTest.java` +- Modify: `databind/src/main/java/dev/metaschema/databind/io/xml/DefaultXmlDeserializer.java` +- Test: `databind/src/test/java/dev/metaschema/databind/io/xml/DefaultXmlDeserializerPolicyTest.java` -*(Implementation loads YAML into typed configuration objects)* +**Test first:** Verify XML entity resolution URIs are checked against policy. --- -### Task 6.3: Verify PR6 Build +### Task 3.6: Verify PR3 Build ```bash -mvn -pl core clean install -mvn -pl core checkstyle:check +mvn clean install -PCI -Prelease ``` --- -## PR7: Loader Integration +## PR4: CLI Integration and Documentation -**Goal:** Integrate allowlist resolver with all loading paths. +**Goal:** Add CLI flags for policy mode control and documentation. -### Task 6.1: Update AbstractModuleLoader +### Task 4.1: Add CLI Flags **Files:** -- Modify: `core/src/main/java/gov/nist/secauto/metaschema/core/model/AbstractModuleLoader.java:88-92` -- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/AbstractModuleLoaderTest.java` +- Modify: `metaschema-cli/src/main/java/dev/metaschema/cli/CLI.java` (or relevant command classes) -**Current code (line ~90):** -```java -URI resolvedResource = ObjectUtils.notNull(resource.resolve(importedResource)); -``` +Add flags: +- `--resource-policy-mode=` - Override enforcement mode +- `--resource-policy=` - Load custom policy configuration file -**Updated code:** -```java -URI resolvedResource = ObjectUtils.notNull(resource.resolve(importedResource)); -// Apply URI resolver if configured -IUriResolver uriResolver = getUriResolver(); -if (uriResolver != null) { - resolvedResource = uriResolver.resolve(resolvedResource); -} -``` +--- -### Task 6.2: Update BindingConstraintLoader +### Task 4.2: Add Environment Variable Support -**Files:** -- Modify: `databind/src/main/java/gov/nist/secauto/metaschema/databind/model/metaschema/BindingConstraintLoader.java:104,135` +Support `METASCHEMA_RESOURCE_POLICY_MODE` environment variable for mode override. -**Similar pattern to AbstractModuleLoader** +--- -### Task 6.3: Update DefaultXmlDeserializer +### Task 4.3: Documentation **Files:** -- Modify: `databind/src/main/java/gov/nist/secauto/metaschema/databind/io/xml/DefaultXmlDeserializer.java:103-118` - -**Updated XMLResolver to use IUriResolver** +- Update: Website documentation with resource access policy guide +- Update: CLI help text --- -## PR7: Documentation - -**Goal:** Update documentation and add examples. +### Task 4.4: Final Verification -### Task 7.1: Add Usage Documentation - -**Files:** -- Create: `docs/allowlist-resolver.md` - -### Task 7.2: Update README - -**Files:** -- Modify: `README.md` - Add security section +```bash +mvn clean install -PCI -Prelease +``` --- ## Completion Checklist -**Phase 1: Core Resolver** -- [ ] Core interfaces and exceptions -- [ ] SchemePolicy implementation -- [ ] Built-in denylist -- [ ] Rule implementations (FileSystem, Http, Jar) -- [ ] AllowlistUriResolver and builder - -**Phase 2: Configuration System** -- [ ] Metaschema module definition (`databind-metaschema/src/main/metaschema/allowlist-config_metaschema.yaml`) -- [ ] Maven code generation verification -- [ ] IConfigurationService interface (`cli-processor`) -- [ ] ConfigurationService with directory discovery (`cli-processor`) -- [ ] AllowlistConfigurationLoader (`databind-metaschema`) -- [ ] AllowlistConfigurationMerger (`databind-metaschema`) - -**Phase 3: Integration** -- [ ] Loader integration (AbstractModuleLoader, BindingConstraintLoader, DefaultXmlDeserializer) -- [ ] CLI integration (--config-dir, METASCHEMA_CONFIG_DIR) +**Phase 1: Policy Engine Core (PR1)** +- [ ] `PolicyMode` enum with DISABLED/AUDIT/ENFORCE +- [ ] `AccessViolationException` for ENFORCE mode +- [ ] `GlobMatcher` with `.gitignore`-style glob matching +- [ ] `SchemePatternSet` with ordered pattern evaluation and `!` negation +- [ ] `IResourceAccessPolicy` interface +- [ ] `ResourceAccessPolicy` with builder +- [ ] `FileProtections` with defaults, builder, and customization API +- [ ] `package-info.java` +- [ ] All tests passing + +**Phase 2: Configuration Model (PR2)** +- [ ] Metaschema module definition (`resource-access-policy_metaschema.yaml`) +- [ ] Maven code generation verified +- [ ] Bundled default policy (restrictive, audit mode) +- [ ] `ResourceAccessPolicyLoader` for config file loading +- [ ] Configuration layering with merge semantics +- [ ] All tests passing + +**Phase 3: Loader Integration (PR3)** +- [ ] `IModuleLoader.setResourceAccessPolicy()` method +- [ ] `AbstractModuleLoader` policy integration +- [ ] `DefaultBoundLoader` policy integration +- [ ] `BindingConstraintLoader` policy integration +- [ ] `DefaultXmlDeserializer` policy integration +- [ ] Integration tests for each loader type +- [ ] All tests passing + +**Phase 4: CLI Integration (PR4)** +- [ ] `--resource-policy-mode` CLI flag +- [ ] `--resource-policy` CLI flag +- [ ] `METASCHEMA_RESOURCE_POLICY_MODE` env var - [ ] Documentation +- [ ] Full CI build passing **Final Verification:** ```bash From 4c99da1bbfc37f8388ba249c38b658eaf77d6fa4 Mon Sep 17 00:00:00 2001 From: David Waltermire Date: Sun, 8 Feb 2026 17:00:58 -0500 Subject: [PATCH 4/4] refactor: incorporate security analysis into Resource Access Policy PRD Address P0-P2 findings from security, API design, and UX analysis: - Add mandatory URI security processing pipeline (path normalization, percent-decoding, symlink resolution, case sensitivity) - Replace glob-based IP patterns with programmatic NetworkSecurityChecker using CIDR block matching via ipaddress library - Change zero-config default from AUDIT to DISABLED for backwards compat - Change enabled+no-patterns semantics from allow-all to deny - Add directory equivalence rule (path/** also matches path itself) - Add FileProtections conflict detection at build time - Add configuration ratcheting with locked flag - Add diagnostic API with explain() and PolicyDecision - Add JAR scheme recursive checking - Rename FileProtections.none() to FileProtections.disabled() - Add CaseSensitivity and SymlinkPolicy enums - Restructure CLI as resource-policy command with dump/check subcommands - Add comprehensive IP CIDR boundary value tests --- PRDs/20251217-allowlist-resolver/PRD.md | 1153 +++++++++++---- .../implementation-plan.md | 1299 ++++++++++++----- 2 files changed, 1787 insertions(+), 665 deletions(-) diff --git a/PRDs/20251217-allowlist-resolver/PRD.md b/PRDs/20251217-allowlist-resolver/PRD.md index d71d49703b..46085b8999 100644 --- a/PRDs/20251217-allowlist-resolver/PRD.md +++ b/PRDs/20251217-allowlist-resolver/PRD.md @@ -2,11 +2,11 @@ **Issue:** [#183 - Add new allowlist-only resolver for loading models, instances, and dynamic model generation](https://github.com/metaschema-framework/metaschema-java/issues/183) -**Goal:** Provide a policy-based URI resolver that controls resource access using glob patterns, with graduated enforcement modes (disabled, audit, enforce) for low-impact, backwards-compatible deployment. +**Goal:** Provide a policy-based URI access control system using glob patterns with programmatic IP-based SSRF protection, graduated enforcement modes, mandatory URI normalization, and defense-in-depth file system protections. -**Architecture:** Implement a `ResourceAccessPolicy` in the `core` module using `.gitignore`-style glob patterns grouped by URI scheme. Integrate at loader level (`IModuleLoader`, `IBoundLoader`) with configurable enforcement modes. Default: restrictive rules in audit mode (log violations but allow all requests). +**Architecture:** Implement a `ResourceAccessPolicy` in the `core` module combining glob pattern matching with IP-based network security. Integrate at loader level (`IModuleLoader`, `IBoundLoader`) with configurable enforcement modes. Default: DISABLED (fully backwards compatible); opt-in to AUDIT or ENFORCE. -**Tech Stack:** Java 11, existing Metaschema core interfaces, Metaschema-based configuration model, SLF4J for audit logging. +**Tech Stack:** Java 11, existing Metaschema core interfaces, Metaschema-based configuration model, SLF4J for audit logging, IP address library for CIDR block matching. --- @@ -24,47 +24,190 @@ As a developer of Metaschema-based tooling deploying services, I need a resolver | Threat | Attack Vector | Mitigation | |--------|--------------|------------| -| Local File Inclusion | `../../../etc/passwd` in imports | Path normalization + pattern-based access control | -| SSRF to Internal Services | `http://localhost:8080/admin` | Default-deny for `http` scheme patterns | -| Cloud Metadata Access | `http://169.254.169.254/` | Default-deny for private/link-local patterns | +| Local File Inclusion | `../../../etc/passwd` in imports | Mandatory path normalization + symlink resolution + pattern-based access control | +| URL Encoding Bypass | `file:///etc/p%61sswd` | Mandatory URI percent-decoding before matching | +| SSRF to Internal Services | `http://localhost:8080/admin` | IP-based SSRF checking via `NetworkSecurityChecker` | +| IP Encoding Bypass | `http://2130706433/`, `http://0x7f000001/` | Programmatic IP resolution, not string patterns | +| IPv6 SSRF | `http://[::ffff:127.0.0.1]/` | `InetAddress`-based classification of all IP forms | +| Cloud Metadata Access | `http://169.254.169.254/` | CIDR block checking for link-local ranges | +| HTTP Redirect Bypass | `302` redirect to `http://169.254.169.254/` | Re-check policy after every redirect | | XXE Attacks | XML entity resolution to arbitrary URLs | Route entity resolution through policy | | Scheme Injection | `file://`, `ftp://`, `gopher://` | Scheme-level allow/deny with glob patterns | +| JAR Scheme SSRF | `jar:http://evil.com/mal.jar!/path` | Recursive policy check on JAR inner URI | +| Symlink Traversal | Symlink from allowed → denied path | Symlink resolution before policy check (default) | +| Config Privilege Escalation | Project-local config weakens admin policy | Ratchet-based configuration layering | +| ReDoS via Patterns | Crafted glob patterns in config files | Non-backtracking regex, pattern complexity limits | --- ## Design Decisions -### 1. Glob Pattern Model (`.gitignore`-style) +### 1. Glob Pattern Model -Patterns use familiar `.gitignore` glob syntax with `!` negation: +Patterns use glob syntax with `!` negation for deny rules: -- **Allow patterns** define what resources are accessible -- **`!` patterns** create exceptions (deny previously allowed resources) +- **Allow patterns** (no prefix) define what resources are accessible +- **Deny patterns** (`!` prefix) create exceptions that block previously allowed resources - Patterns are evaluated **in order**, last match wins -- Patterns are organized **by scheme** (file, https, http, jar) +- Patterns are organized **by URI scheme** (file, https, http, jar) -This replaces the previous allowlist+denylist dual model with a single, unified pattern list per scheme. Users familiar with `.gitignore` can immediately understand and author policies. +**Important — `!` means DENY:** Unlike `.gitignore` where `!` means "re-include" (stop ignoring), in this system `!` means "deny access." This is the opposite semantic. Documentation must make this explicit. -### 2. Enforcement Modes +The pattern syntax itself (glob wildcards `*`, `**`, `?`) follows `.gitignore` conventions but the behavioral model is different. Documentation should describe this as "glob pattern matching with last-match-wins evaluation," not as ".gitignore-style." + +**Directory equivalence rule:** A pattern ending in `/**` also matches the directory itself (without trailing slash or children). For example: +- `/workspace/**` matches `/workspace`, `/workspace/`, and `/workspace/project/schema.xml` +- `pages.nist.gov/**` matches `pages.nist.gov`, `pages.nist.gov/`, and `pages.nist.gov/schemas/foo.xml` + +This prevents a common misconfiguration where allowing a directory subtree via `path/**` unexpectedly denies access to the directory path itself. The implementation compiles `path/**` as matching `path`, `path/`, and `path/`. + +### 2. Enforcement Modes & Zero-Config Behavior Three graduated enforcement levels for safe rollout: | Mode | Behavior | Use Case | |------|----------|----------| -| `DISABLED` | No policy checking; all URIs allowed | Legacy behavior, maximum compatibility | +| `DISABLED` | No policy checking; all URIs allowed | Default — legacy behavior, maximum compatibility | | `AUDIT` | Check policy, **log violations**, but **allow** all requests | Migration period, discovering needed rules | | `ENFORCE` | Check policy, **block** violations with exception | Production hardened | -**Default mode:** `AUDIT` — provides visibility into what would be blocked without breaking existing workflows. +**Zero-config default: `DISABLED`.** When a library user upgrades to a version containing this feature without changing any code, behavior is unchanged. No new log entries, no blocking. Security requires explicit opt-in. This avoids: +- Surprising existing users with new WARN log noise after an upgrade +- Triggering production monitoring alerts unexpectedly +- Breaking existing workflows + +**Explicit opt-in is required** via one of: +- **API:** `loader.setResourceAccessPolicy(ResourceAccessPolicy.bundledDefaults())` +- **Config file:** Place a `resource-access-policy.yaml` in a search path +- **CLI flag:** `--resource-policy-mode=audit` or `--resource-policy-mode=enforce` + +**Factory methods for common scenarios:** + +```java +// Restrictive defaults in AUDIT mode (recommended starting point) +ResourceAccessPolicy.bundledDefaults() + +// Permissive for local development (allows localhost, http) +ResourceAccessPolicy.development() + +// Explicit no-op (same as not setting a policy) +ResourceAccessPolicy.disabled() +``` The mode is configurable via: - **API:** `ResourceAccessPolicy.builder().mode(PolicyMode.AUDIT)` - **Config file:** `mode: audit` in the policy configuration - **CLI flag:** `--resource-policy-mode=enforce` -### 3. Scheme-Based Pattern Organization +### 3. URI Security Processing (Mandatory) + +All URIs undergo mandatory security processing **before** pattern matching. This is a non-negotiable requirement, not an implementation detail. + +**Processing pipeline for every URI:** + +```text +Raw URI + │ + ├─ 1. Percent-decode URI components (exactly once) + │ file:///workspace/p%61th → file:///workspace/path + │ + ├─ 2. Normalize scheme to lowercase + │ FILE:///path → file:///path + │ + ├─ 3. For file: scheme: + │ a. Resolve path via Path.of(path).normalize() + │ /workspace/../etc/passwd → /etc/passwd + │ b. Reject paths still containing ".." after normalization + │ c. If symlink policy is FOLLOW (default): + │ Resolve via Path.toRealPath() to canonical path + │ d. Apply case folding per CaseSensitivity mode + │ + ├─ 4. For http/https schemes: + │ a. Normalize hostname to lowercase (RFC 3986) + │ b. Strip default ports (80 for http, 443 for https) + │ c. Pass to NetworkSecurityChecker for IP-based SSRF check + │ + ├─ 5. For jar: scheme: + │ a. Parse inner URI (before !) and recursively check policy + │ b. Parse internal path (after !) for scheme pattern matching + │ + └─ 6. For URIs without a scheme (relative URIs): + Resolve to absolute URI before policy checking. + Deny if resolution is not possible. +``` + +**Symlink resolution policy:** + +| Mode | Behavior | Default | +|------|----------|---------| +| `FOLLOW` | Resolve symlinks via `Path.toRealPath()` before checking | Yes (default) | +| `NOFOLLOW` | Check the path as-is without symlink resolution | No | + +Symlink resolution is enabled by default because a symlink from an allowed directory to a sensitive path is a common bypass vector. When `FOLLOW` is active, the **canonical (real) path** is checked against the policy, not the symlink path. + +**Case sensitivity mode:** + +| Mode | Behavior | Use Case | +|------|----------|----------| +| `SYSTEM_DEFAULT` | Auto-detect from OS: case-insensitive on Windows, case-sensitive elsewhere | Default | +| `CASE_SENSITIVE` | Always case-sensitive matching | Unix-only deployments | +| `CASE_INSENSITIVE` | Always case-insensitive matching | Windows, testing | + +Case sensitivity applies to file path matching in both `FileProtections` and file scheme patterns. For network schemes, hostnames are always case-folded to lowercase per RFC 3986. + +**Configurable via API:** + +```java +ResourceAccessPolicy.builder() + .symlinkPolicy(SymlinkPolicy.FOLLOW) // default + .caseSensitivity(CaseSensitivity.SYSTEM_DEFAULT) // default + .build(); +``` + +### 4. Network Security (IP-Based SSRF Protection) + +**Glob patterns alone cannot protect against SSRF** because IP addresses have multiple representations that bypass string matching: -Patterns are grouped by URI scheme for clarity and to avoid ambiguity: +| Representation | Example | Resolves To | +|---------------|---------|-------------| +| Standard | `127.0.0.1` | 127.0.0.1 | +| Decimal | `2130706433` | 127.0.0.1 | +| Hexadecimal | `0x7f000001` | 127.0.0.1 | +| Octal | `0177.0.0.1` | 127.0.0.1 | +| Shorthand | `127.1` | 127.0.0.1 | +| IPv4-mapped IPv6 | `::ffff:127.0.0.1` | 127.0.0.1 | +| IPv6 expanded | `0:0:0:0:0:0:0:1` | ::1 | + +The system uses a `NetworkSecurityChecker` that programmatically resolves hostnames to IP addresses and checks them against CIDR blocks using an IP address library. + +**Blocked CIDR ranges (checked programmatically, not via glob patterns):** + +| CIDR Block | Description | +|-----------|-------------| +| `127.0.0.0/8` | IPv4 loopback | +| `::1/128` | IPv6 loopback | +| `10.0.0.0/8` | Private (Class A) | +| `172.16.0.0/12` | Private (Class B) | +| `192.168.0.0/16` | Private (Class C) | +| `169.254.0.0/16` | Link-local (includes cloud metadata 169.254.169.254) | +| `fe80::/10` | IPv6 link-local | +| `fc00::/7` | IPv6 unique local address (ULA) | +| `::ffff:0:0/96` | IPv4-mapped IPv6 (checked after mapping to IPv4) | +| `0.0.0.0/8` | Unspecified / "this" network | +| `100.64.0.0/10` | Shared address space (CGNAT) | + +**Implementation:** Uses an IP address library (e.g., `com.github.seancfoley:ipaddress`) for CIDR matching. The `InetAddress` from `java.net` resolves all IP encoding variants. The CIDR library handles range comparisons. + +**HTTP redirect re-checking:** After any HTTP redirect (3xx), the new URI must be re-checked against the policy before following the redirect. This prevents: +- Policy checks `https://allowed-host.com/` → allowed +- HTTP client follows 302 to `http://169.254.169.254/latest/meta-data/` +- Cloud metadata exfiltrated + +Re-check is documented as a requirement for HTTP client integration. The policy engine provides the `checkAccess()` method; the loader must call it again after receiving a redirect. + +### 5. Scheme-Based Pattern Organization + +Patterns are grouped by URI scheme for clarity: ```yaml resource-access-policy: @@ -81,126 +224,163 @@ resource-access-policy: patterns: - "/workspace/**" - "/data/schemas/**" - - "!**/.ssh/**" - - "!**/.aws/**" - scheme: jar patterns: - - "/schema/**" - - "/META-INF/metaschema/**" + - "**" ``` -Within each scheme section: -- `enabled: false` disables the entire scheme (deny all) -- `enabled: true` (default) enables pattern matching -- If patterns are present, only matching URIs are allowed -- If no patterns are present and enabled is true, all URIs for that scheme are allowed -- `!` patterns create exceptions within the allowed set +**Scheme semantics:** + +| Configuration | Behavior | +|--------------|----------| +| `enabled: false` | Deny all URIs for this scheme | +| `enabled: true` + patterns present | Match against patterns (last match wins) | +| `enabled: true` + no patterns | Use `default-scheme-policy` (typically deny) | + +**Important change:** `enabled: true` with no patterns uses `default-scheme-policy` (default: deny), NOT "allow all." This prevents a common misconfiguration where an empty scheme section silently allows everything. + +**Port handling for host-based schemes (http, https):** + +Ports are stripped before pattern matching. Default ports (80 for http, 443 for https) are always stripped. Non-default ports are also stripped so that patterns match against `host/path` only. Port restrictions can be added as a future enhancement if needed. + +Example: `https://localhost:8443/api` → match target is `localhost/api`. + +**Scheme name validation:** -### 4. File System Protections (Defense-in-Depth) +Scheme names are validated against a known set at config load time: `http`, `https`, `file`, `jar`, `ftp`, `data`. Unrecognized scheme names generate a WARNING log. This catches typos like `htps` that would silently create dead config entries. -The `file` scheme ships with a **default allow-list of safe path patterns**, providing defense-in-depth against misconfiguration. File protections are checked **before** user-defined scheme patterns — a path must be allowed by file protections before scheme patterns are evaluated. This can be adjusted via the API. +**Relative URI handling:** -**Model:** File protections use an allow-list approach. Only paths matching an allow pattern are permitted; everything else is denied. This is the inverse of the scheme-level glob patterns — protections define a **floor** of safe paths. +URIs without a scheme must be resolved to absolute URIs before policy checking. If resolution is not possible, the URI is denied. + +### 6. File System Protections (Defense-in-Depth) + +The `file` scheme ships with a **default allow-list of safe path patterns**. File protections are checked **before** user-defined scheme patterns — a path must be allowed by file protections before scheme patterns are evaluated. + +**Model:** Allow-list. Only paths matching an allow pattern are permitted. Everything else is denied. **Default allow patterns (shipped with the library):** All platforms: -- `/**` — current working directory subtree (resolved at policy creation time) +- `/**` — current working directory subtree - `/**` — user's home directory subtree -- `!/.ssh/**` — except SSH keys -- `!/.aws/**` — except AWS credentials -- `!/.gnupg/**` — except GPG keys +- `!/.*/**` — except ALL dot-directories in home (blanket exclusion) - `!**/Library/Keychains/**` — except macOS keychains - `!**/Library/Application Support/com.apple.TCC/**` — except macOS privacy DB - `!**/AppData/**` — except Windows AppData -Note: `` and `` are resolved to absolute paths at policy creation time, not treated as literal patterns. +Notes: +- `` and `` are resolved to absolute paths at policy creation time +- The blanket `!/.*/**` pattern excludes all dot-directories: `.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.azure`, `.netrc`, `.config`, `.local`, `.bash_history`, `.password-store`, `.vault-token`, etc. This is more secure than enumerating individual directories +- If CWD is `/` (root) or `C:\`, a WARNING is logged: "CWD is the filesystem root; FileProtections allow the entire filesystem" **What the defaults block (by omission):** -Since only the CWD and home directory subtrees are allowed, paths like these are denied automatically: -- `/etc/**`, `/proc/**`, `/sys/**`, `/dev/**` — system directories -- `/root/**` — root home (unless CWD is there) -- `C:/Windows/**` — Windows system directory -- Any path outside CWD and home +| Blocked Path | Reason | +|-------------|--------| +| `/etc/**`, `/proc/**`, `/sys/**`, `/dev/**` | System directories (outside CWD/home) | +| `/root/**` | Root home (unless CWD is there) | +| `C:/Windows/**` | Windows system directory | +| `~/.ssh/**`, `~/.aws/**`, `~/.gnupg/**` | Sensitive dot-directories (blanket exclusion) | +| `~/.kube/**`, `~/.docker/**`, `~/.azure/**` | Cloud/container credentials | +| `~/.config/gcloud/**`, `~/.config/gh/**` | Service credentials | +| `~/.netrc`, `~/.npmrc`, `~/.pypirc` | Network tokens | +| `~/.*_history` | Shell history | + +**Case sensitivity and symlink modes:** -**Behavior by mode:** +FileProtections respect the policy-level `CaseSensitivity` and `SymlinkPolicy` settings. On Windows with `SYSTEM_DEFAULT`, paths are compared case-insensitively. -| Mode | File protection behavior | -|------|--------------------------| -| `DISABLED` | Not checked (policy is fully off) | -| `AUDIT` | Checked, violations logged as WARN, request **allowed** | -| `ENFORCE` | Checked, violations **blocked** with `AccessViolationException` | +**Conflict detection at build time:** + +When the builder constructs a policy, it checks for conflicts between scheme patterns and FileProtections. If a file scheme allow pattern (e.g., `/opt/data/**`) would be blocked by FileProtections (because `/opt/data/` is outside CWD and home), the builder throws `IllegalStateException`: + +```text +Conflict: file scheme pattern '/opt/data/**' will never match because FileProtections +does not allow '/opt/data/'. Add it to FileProtections via: + .fileProtections(FileProtections.builder().includeDefaults().allow("/opt/data/**").build()) +``` -**API for adjusting the protection list:** +**API:** ```java -// Default behavior: CWD subtree + home (minus sensitive dot-dirs) -// are allowed. Everything else denied. +// Default: CWD + home minus dot-dirs ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) .forScheme("file") .allow("/workspace/**") .build(); -// file:///workspace/schema.xml → ALLOWED -// file:///etc/passwd → DENIED (not in file protections allow-list) -// Inspect the default allow patterns +// Inspect defaults List defaults = FileProtections.defaultAllowPatterns(); -// Customize: start from defaults and allow additional paths +// Customize: extend defaults ResourceAccessPolicy custom = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) .fileProtections(FileProtections.builder() - .includeDefaults() // CWD + home minus sensitive dirs - .allow("/opt/metaschema/**") // add another safe area - .allow("/data/schemas/**") // add another safe area + .includeDefaults() + .allow("/opt/metaschema/**") .build()) .forScheme("file") .allow("/workspace/**") .build(); -// Customize: start from defaults but narrow scope +// Customize: narrow defaults ResourceAccessPolicy tighter = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) .fileProtections(FileProtections.builder() .includeDefaults() - .remove("/**") // remove home dir access + .remove("/**") // remove home dir access .build()) .forScheme("file") .allow("/workspace/**") .build(); -// Completely replace: no defaults, fully custom safe list +// Fully custom (no defaults) ResourceAccessPolicy fullyCustom = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) .fileProtections(FileProtections.builder() - .allow("/opt/app/**") // only this directory tree - .build()) // no defaults included + .allow("/opt/app/**") + .build()) .forScheme("file") .allow("/opt/app/schemas/**") .build(); -// Disable file protections entirely (not recommended) +// Disable file protections (NOT RECOMMENDED — security warning logged) ResourceAccessPolicy noProtections = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) - .fileProtections(FileProtections.none()) + .fileProtections(FileProtections.disabled()) .forScheme("file") .allow("/workspace/**") .build(); ``` -**Evaluation order:** File protections are checked **before** user-defined scheme patterns. The flow for a `file:` URI is: -1. Check file protections allow-list (is the path in a safe area?) -2. If denied by file protections → apply mode behavior (log/block), stop -3. If allowed by file protections → check user's scheme patterns (last match wins) -4. If no scheme pattern matches → use `default-scheme-policy` +`FileProtections.disabled()` (renamed from `none()`) disables all file system protections. When called: +- Logs a `WARN`: "FileProtections disabled — file scheme relies solely on scheme patterns for security" +- The method's Javadoc includes a security warning about the implications + +**Evaluation order for `file:` URIs:** + +```text +1. Apply URI security processing (normalize, decode, resolve symlinks, case-fold) +2. Check FileProtections allow-list (is path in a safe area?) +3. If denied by FileProtections → apply mode behavior (log/block), stop +4. If allowed by FileProtections → check scheme patterns (last match wins) +5. If no scheme pattern matches → use default-scheme-policy +``` + +### 7. JAR Scheme Recursive Checking -This means file protections act as a gate — a path must be in a safe area before user scheme patterns are even considered. +The `jar:` URI format is `jar:!/`. Both components must be checked: -### 5. Default Bundled Policy +1. **Inner URI** (JAR location): Parsed and recursively checked against the policy using the inner URI's scheme. This prevents SSRF through `jar:http://evil.com/mal.jar!/schema.xsd`. +2. **Internal path** (after `!`): Checked against `jar` scheme patterns. -The library ships with a **restrictive default policy in audit mode**: +If the inner URI has no `!` separator, it is treated as a malformed JAR URI and denied. + +### 8. Default Bundled Policy + +The library ships with a **restrictive default policy in AUDIT mode**: ```yaml resource-access-policy: @@ -210,98 +390,288 @@ resource-access-policy: - scheme: https patterns: - "**" - - "!localhost/**" - - "!127.*/**" - - "!10.*/**" - - "!172.16.*/**" - - "!172.17.*/**" - - "!172.18.*/**" - - "!172.19.*/**" - - "!172.2?.*/**" - - "!172.30.*/**" - - "!172.31.*/**" - - "!192.168.*/**" - - "!169.254.*/**" - - "![::1]/**" - - "!metadata.google.internal/**" - scheme: http enabled: false - scheme: file patterns: - "**" - - "!/etc/**" - - "!/proc/**" - - "!/sys/**" - - "!/dev/**" - - "!/root/**" - - "!**/.ssh/**" - - "!**/.aws/**" - - "!**/.gnupg/**" - - "!/var/run/**" - - "!C:/Windows/**" - - "!**/AppData/**" - scheme: jar patterns: - "**" ``` -In `AUDIT` mode (the default), this logs every URI access that would fail these rules but allows the request to proceed. Users can review logs to understand their access patterns before switching to `ENFORCE`. +**Note:** Private IP blocking and cloud metadata protection are handled by the `NetworkSecurityChecker` (Design Decision 4), not by glob patterns. The glob patterns in the default policy focus on scheme-level allow/deny and path restrictions. This separation ensures that IP encoding bypasses cannot circumvent network security. -### 6. Policy on Loader (Library API) +**When loaded via `ResourceAccessPolicy.bundledDefaults()`:** +- Mode is AUDIT (log violations, allow all requests) +- `NetworkSecurityChecker` is enabled with all default CIDR blocks +- `FileProtections` are enabled with default allow patterns +- HTTP scheme is disabled entirely +- HTTPS, file, and jar schemes allow all paths (network security and FileProtections provide the restrictions) -For library users, policy is set on the loader: +### 9. Library API (Policy on Loader) + +Policy is set on the loader via a new method: ```java -// Create a policy +IModuleLoader loader = ...; + +// Recommended: use bundled defaults (AUDIT mode) +loader.setResourceAccessPolicy(ResourceAccessPolicy.bundledDefaults()); + +// Override mode +loader.setResourceAccessPolicy( + ResourceAccessPolicy.bundledDefaults().withMode(PolicyMode.ENFORCE)); + +// Development mode (allows localhost, HTTP) +loader.setResourceAccessPolicy(ResourceAccessPolicy.development()); + +// Modify an existing policy +loader.setResourceAccessPolicy( + ResourceAccessPolicy.bundledDefaults() + .toBuilder() + .forScheme("https") + .allow("my-internal-host.com/**") + .build()); + +// Custom policy ResourceAccessPolicy policy = ResourceAccessPolicy.builder() - .mode(PolicyMode.AUDIT) + .mode(PolicyMode.ENFORCE) .forScheme("https") .allow("pages.nist.gov/**") .allow("raw.githubusercontent.com/metaschema-framework/**") .forScheme("file") .allow("/workspace/schemas/**") - .deny("**/.ssh/**") .forScheme("jar") .allowAll() - .defaultDeny() + .denyUnlistedSchemes() .build(); - -// Set on loader -IModuleLoader loader = ...; loader.setResourceAccessPolicy(policy); +``` -// Or use bundled defaults -loader.setResourceAccessPolicy(ResourceAccessPolicy.bundledDefaults()); +**Key API points:** +- `IUriResolver` interface remains unchanged for backwards compatibility +- `setResourceAccessPolicy(null)` disables policy checking (equivalent to DISABLED) +- `ResourceAccessPolicy` is **immutable** (final class, all-final fields). `withMode()` and `toBuilder()` create new instances. +- Loaders should use `volatile` or `AtomicReference` for the policy field for thread safety -// Override mode -loader.setResourceAccessPolicy( - ResourceAccessPolicy.bundledDefaults() - .withMode(PolicyMode.ENFORCE)); +**`ResourceAccessPolicy.development()` factory:** + +Returns a permissive policy for local development: + +```java +// Equivalent to: +ResourceAccessPolicy.builder() + .mode(PolicyMode.AUDIT) + .forScheme("https").allowAll() + .forScheme("http").allow("localhost/**") + .forScheme("file").allowAll() + .forScheme("jar").allowAll() + .denyUnlistedSchemes() + .networkSecurity(NetworkSecurityConfig.builder() + .allowLoopback(true) // allow localhost for dev + .build()) + .build(); +``` + +### 10. Diagnostic API & Error Messages + +#### Diagnostic API + +Users need a way to test policies without trial-and-error. The `explain()` method returns a structured `PolicyDecision` without throwing: + +```java +PolicyDecision decision = policy.explain(URI.create("https://10.0.0.1/api")); +decision.isAllowed(); // false +decision.getLayer(); // "network-security" +decision.getDenialReason(); // "IP 10.0.0.1 is in private range 10.0.0.0/8" +decision.getRemediation(); // "Add to NetworkSecurityConfig: .allowCidr(\"10.0.0.1/32\")" +decision.getEvaluationTrace(); // ordered list of evaluation steps + +// Human-readable summary of all rules +String summary = policy.describeEffectiveRules(); +``` + +**`PolicyDecision` fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `allowed` | `boolean` | Whether the URI would be allowed | +| `layer` | `String` | Which layer denied/allowed: "disabled", "file-protections", "network-security", "scheme-patterns", "default-scheme-policy" | +| `denialReason` | `String` | Human-readable reason for denial (null if allowed) | +| `matchingPattern` | `String` | The specific pattern that matched (null if N/A) | +| `configSource` | `String` | Where the matching rule came from (e.g., "bundled defaults", file path) | +| `remediation` | `String` | What to add to allow this URI | +| `evaluationTrace` | `List` | Ordered list of all evaluation steps | + +#### Error Messages + +Error messages must be actionable. Format for `AccessViolationException`: + +```text +Resource access policy violation: 'file:///etc/passwd' was denied. + Normalized URI: /etc/passwd + Denied by: file-protections (path not in allowed areas: , ) + Source: bundled defaults + To allow: FileProtections.builder().includeDefaults().allow("/etc/passwd").build() + Or run with --resource-policy-mode=audit to log without blocking. +``` + +Format for AUDIT mode log messages: + +```text +WARN [resource-access-policy] URI 'https://10.0.0.5/api/schema.json' would be denied + in ENFORCE mode. Denied by: network-security (IP 10.0.0.5 in private range 10.0.0.0/8). + To allow: add to NetworkSecurityConfig: .allowCidr("10.0.0.5/32") +``` + +**Logging conventions:** +- Logger name: `dev.metaschema.core.model.policy` +- All audit messages prefixed with `[resource-access-policy]` for grep-ability +- AUDIT violations: WARN level +- ENFORCE violations: ERROR level (before throwing) +- Allowed requests: DEBUG level (optional) +- Policy initialization: INFO level (which config files loaded) + +### 11. Builder API Design + +The builder uses nested builders for type-safe state transitions: + +```text +ResourceAccessPolicy.builder() → ResourceAccessPolicyBuilder + .mode(PolicyMode) → self + .symlinkPolicy(SymlinkPolicy) → self + .caseSensitivity(CaseSensitivity) → self + .fileProtections(FileProtections) → self + .networkSecurity(NetworkSecurityConfig) → self + .forScheme("https") → SchemeConfigBuilder + .allow("pattern") → self + .deny("!pattern") → self + .allowAll() → self + .denyAll() → self + .forScheme("file") → new SchemeConfigBuilder (finalizes previous) + .denyUnlistedSchemes() → ResourceAccessPolicyBuilder (finalizes scheme) + .build() → ResourceAccessPolicy + .denyUnlistedSchemes() → self (renamed from defaultDeny()) + .build() → ResourceAccessPolicy +``` + +**Key behaviors:** +- Calling `.forScheme()` twice for the same scheme **appends** patterns (does not replace) +- Calling `.allow()` or `.deny()` without a preceding `.forScheme()` throws `IllegalStateException` +- `.build()` validates the policy and runs conflict detection +- The built `ResourceAccessPolicy` is **immutable** — all internal collections are unmodifiable copies + +**`toBuilder()` method:** + +Creates a new builder pre-populated from an existing policy: + +```java +ResourceAccessPolicy modified = existingPolicy.toBuilder() + .forScheme("https") + .allow("additional-host.com/**") + .build(); +``` + +### 12. Configuration Layering & Ratcheting + +Configurations loaded from multiple locations, merged with precedence: + +| Priority | Location | Platform | Purpose | +|----------|----------|----------|---------| +| 1 (lowest) | Bundled in JAR | All | Restrictive defaults in audit mode | +| 2 | `/config/` | All | Distribution-specific overrides | +| 3 | `/etc/metaschema/` | Unix | System-wide administrator settings | +| 3 | `%ProgramData%\metaschema\` | Windows | System-wide administrator settings | +| 4 | `~/.metaschema/` | All | User-specific preferences | +| 5 | `./.metaschema/` | All | Project-specific overrides | +| 6 (highest) | CLI `--resource-policy` | All | CLI argument override | + +#### Ratchet Principle (Security) + +Higher-precedence configs **can only tighten policy, never loosen it:** + +- **Mode ratcheting:** Restriction order: DISABLED < AUDIT < ENFORCE. A higher-precedence layer's mode must be >= the lower-precedence layer's mode. If a project-local config sets `mode: disabled` but the system config sets `mode: enforce`, the effective mode is `enforce`. A WARNING is logged when a layer attempts to weaken the mode. +- **`locked` flag:** Any config layer can mark settings as `locked: true`, preventing higher-precedence layers from changing them. Example: system admin sets `mode: enforce, locked: true` — project-level configs cannot change the mode. + +```yaml +# System-level config (/etc/metaschema/resource-access-policy.yaml) +resource-access-policy: + mode: enforce + locked: true # cannot be weakened by project-level configs +``` + +#### Merge Semantics + +| Setting | Merge Behavior | +|---------|----------------| +| `mode` | Ratchet: most restrictive wins | +| `default-scheme-policy` | Higher-precedence wins (subject to ratchet) | +| Scheme configs (default) | Higher-precedence **replaces** entire scheme config | +| Scheme configs (`inherit: true`) | Higher-precedence **appends** patterns to lower-precedence | + +**Additive merge via `inherit`:** + +```yaml +# Project-level: add patterns to bundled defaults instead of replacing +resource-access-policy: + schemes: + - scheme: https + inherit: true # append to lower-layer patterns + patterns: + - "my-internal-host.com/**" # additional allow ``` -This keeps the API on the loader itself, making it discoverable and natural for library users. The `IUriResolver` interface remains unchanged for backwards compatibility. +Without `inherit: true`, the project-level `https` section would replace the bundled defaults entirely, losing any deny patterns for private IPs (though those are now handled by `NetworkSecurityChecker`, this still matters for scheme-level patterns). + +#### YAML Configuration Footgun + +YAML's `!` character is the tag prefix. Unquoted deny patterns will cause silent parsing failures: + +```yaml +# WRONG — YAML interprets ! as a tag +patterns: + - !**/.ssh/** + +# CORRECT — must be quoted +patterns: + - "!**/.ssh/**" +``` -### 7. All in Core Module +Config loading should validate that pattern strings do not contain YAML artifacts and log a clear error if parsing produces unexpected types. + +#### Pattern Complexity Limits + +To prevent ReDoS attacks via crafted glob patterns in user-controlled config files: +- Maximum patterns per scheme: 100 +- Maximum pattern length: 500 characters +- Glob-to-regex compilation uses possessive quantifiers or atomic groups to prevent catastrophic backtracking + +### 13. All in Core Module The entire policy engine lives in `core`: - Policy model, pattern matching, enforcement +- URI normalization and security processing +- Network security checker - Metaschema configuration model and loading - Default bundled policy CLI-specific concerns (CLI flags, environment variables) are handled in `cli-processor`/`metaschema-cli` but delegate to the core API. -### 8. Integration Points +### 14. Integration Points All resolution paths check the policy: | Component | Current Behavior | Change Required | |-----------|-----------------|-----------------| | `DefaultBoundLoader` | Uses `IUriResolver` | Add policy check before resolution | -| `AbstractModuleLoader` | Raw `URI.resolve()` for imports | Add policy check | +| `AbstractModuleLoader` | Raw `URI.resolve()` for imports | Add policy check; re-check after redirect | | `BindingConstraintLoader` | Raw `URI.resolve()` for imports | Add policy check | -| `DefaultXmlDeserializer` | Custom `XMLResolver` for entities | Route through policy | -| `DefaultJsonDeserializer` | Reads from provided `Reader` | None - uses loader with policy | -| `DefaultYamlDeserializer` | Reads from provided `Reader` | None - uses loader with policy | +| `DefaultXmlDeserializer` | Custom `XMLResolver` for entities | Route through policy; re-check after redirect | +| `DefaultJsonDeserializer` | Reads from provided `Reader` | None — uses loader with policy | +| `DefaultYamlDeserializer` | Reads from provided `Reader` | None — uses loader with policy | + +**Relative URI resolution:** Components that resolve relative URIs (e.g., `../schemas/foo.xml`) must resolve them to absolute URIs before calling `checkAccess()`. --- @@ -310,45 +680,60 @@ All resolution paths check the policy: ### Component Diagram ```text -┌─────────────────────────────────────────────────────────────────┐ -│ ResourceAccessPolicy │ -├─────────────────────────────────────────────────────────────────┤ -│ ┌─────────────────┐ ┌──────────────────────────────────────┐ │ -│ │ PolicyMode │ │ SchemePatterns │ │ -│ │ ───────────── │ │ ──────────────────────────────── │ │ -│ │ DISABLED │ │ file: │ │ -│ │ AUDIT │ │ allow: /workspace/** │ │ -│ │ ENFORCE │ │ deny: **/.ssh/** │ │ -│ │ │ │ https: │ │ -│ │ │ │ allow: pages.nist.gov/** │ │ -│ │ │ │ deny: localhost/** │ │ -│ │ │ │ http: │ │ -│ │ │ │ (disabled) │ │ -│ │ │ │ jar: │ │ -│ │ │ │ allow: ** │ │ -│ └─────────────────┘ └──────────────────────────────────────┘ │ -│ ┌───────────────────────────────────────────────────────────┐ │ -│ │ Audit Logger (SLF4J) │ │ -│ │ ─────────────────────────────────────────────────────── │ │ -│ │ AUDIT mode: log WARN for violations, allow request │ │ -│ │ ENFORCE mode: log ERROR for violations, throw exception │ │ -│ │ All modes: log DEBUG for allowed requests (optional) │ │ -│ └───────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────┐ +│ ResourceAccessPolicy │ +├──────────────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌────────────────────┐ ┌──────────────────────┐ │ +│ │ PolicyMode │ │ UriNormalizer │ │ NetworkSecurityChkr │ │ +│ │ ─────────── │ │ ────────────── │ │ ──────────────────── │ │ +│ │ DISABLED │ │ percent-decode │ │ CIDR block matching │ │ +│ │ AUDIT │ │ path normalize │ │ IP resolution │ │ +│ │ ENFORCE │ │ symlink resolve │ │ loopback check │ │ +│ │ │ │ case folding │ │ site-local check │ │ +│ └─────────────┘ └────────────────────┘ │ link-local check │ │ +│ └──────────────────────┘ │ +│ ┌──────────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ FileProtections │ │ SchemePatterns │ │ +│ │ ────────────────── │ │ ──────────────────────────── │ │ +│ │ allow: /** │ │ file: │ │ +│ │ allow: /** │ │ allow: /workspace/** │ │ +│ │ deny: /.*/** │ │ https: │ │ +│ │ (checked FIRST) │ │ allow: pages.nist.gov/** │ │ +│ │ │ │ http: │ │ +│ │ │ │ (disabled) │ │ +│ │ │ │ jar: │ │ +│ │ │ │ allow: ** │ │ +│ └──────────────────────────┘ └──────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Audit Logger (SLF4J) │ │ +│ │ Logger: dev.metaschema.core.model.policy │ │ +│ │ Prefix: [resource-access-policy] │ │ +│ │ AUDIT: WARN for violations, allow request │ │ +│ │ ENFORCE: ERROR for violations, throw AccessViolationException│ │ +│ │ All: DEBUG for allowed requests, INFO for policy init │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ ``` ### Class Hierarchy ```text dev.metaschema.core.model.policy/ -├── ResourceAccessPolicy.java # Main policy implementation -├── ResourceAccessPolicyBuilder.java # Fluent builder ├── PolicyMode.java # Enum: DISABLED, AUDIT, ENFORCE +├── SymlinkPolicy.java # Enum: FOLLOW, NOFOLLOW +├── CaseSensitivity.java # Enum: SYSTEM_DEFAULT, CASE_SENSITIVE, CASE_INSENSITIVE ├── AccessViolationException.java # Exception for ENFORCE mode ├── IResourceAccessPolicy.java # Interface for policy checking +├── ResourceAccessPolicy.java # Main policy implementation (immutable) +├── ResourceAccessPolicyBuilder.java # Fluent builder with nested SchemeConfigBuilder ├── SchemePatternSet.java # Glob patterns for one scheme -├── GlobMatcher.java # .gitignore-style glob matching -├── FileProtections.java # Adjustable file system deny patterns (ships with defaults) +├── GlobMatcher.java # Glob pattern → regex compilation +├── FileProtections.java # Adjustable file system allow-list +├── UriNormalizer.java # URI security processing pipeline +├── NetworkSecurityChecker.java # IP-based SSRF protection +├── NetworkSecurityConfig.java # Configuration for network security +├── PolicyDecision.java # Diagnostic result from explain() +├── EvaluationStep.java # Single step in evaluation trace └── package-info.java ``` @@ -358,28 +743,40 @@ dev.metaschema.core.model.policy/ Loader receives URI │ ▼ -┌──────────────────────────────────┐ -│ IResourceAccessPolicy.check() │ -│ ┌────────────────────────────┐ │ -│ │ 1. If DISABLED → return │ │ -│ │ 2. Get scheme from URI │ │ -│ │ 3. If file: scheme, check │ │ -│ │ FileProtections first │ │ -│ │ (allow-list gate) │ │ -│ │ 4. Find SchemePatternSet │ │ -│ │ 5. Match against patterns │ │ -│ │ (last match wins) │ │ -│ │ 6. Apply mode behavior │ │ -│ └────────────────────────────┘ │ -└──────────────────────────────────┘ - │ - ├── DISABLED → allow silently - │ - ├── AUDIT + violation → log WARN, allow - ├── AUDIT + allowed → (optionally log DEBUG), allow - │ - ├── ENFORCE + violation → log ERROR, throw AccessViolationException - └── ENFORCE + allowed → allow +┌──────────────────────────────────────┐ +│ 1. If DISABLED → return immediately │ +│ │ +│ 2. UriNormalizer.normalize(uri) │ +│ ├─ percent-decode │ +│ ├─ lowercase scheme │ +│ ├─ file: normalize path + symlinks│ +│ └─ http/https: lowercase host │ +│ │ +│ 3. If http/https: │ +│ NetworkSecurityChecker.check() │ +│ ├─ resolve hostname → InetAddress│ +│ ├─ check against CIDR blocks │ +│ └─ if private/reserved → deny │ +│ │ +│ 4. If file: │ +│ FileProtections.isAllowed(path) │ +│ └─ if not in safe area → deny │ +│ │ +│ 5. If jar: │ +│ ├─ parse inner URI │ +│ ├─ recursively check inner URI │ +│ └─ check internal path patterns │ +│ │ +│ 6. SchemePatternSet.isAllowed() │ +│ └─ last match wins │ +│ │ +│ 7. If no match → default-scheme- │ +│ policy │ +│ │ +│ 8. Apply mode behavior │ +│ ├─ AUDIT: log WARN, allow │ +│ └─ ENFORCE: log ERROR, throw │ +└──────────────────────────────────────┘ ``` --- @@ -392,6 +789,8 @@ Loader receives URI // Restrictive server mode ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) + .symlinkPolicy(SymlinkPolicy.FOLLOW) + .caseSensitivity(CaseSensitivity.SYSTEM_DEFAULT) .forScheme("https") .allow("pages.nist.gov/**") .allow("raw.githubusercontent.com/metaschema-framework/**") @@ -400,60 +799,61 @@ ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .denyAll() .forScheme("file") .allow("/data/schemas/**") - .deny("**/.ssh/**") - .deny("**/.aws/**") .forScheme("jar") .allowAll() - .defaultDeny() // deny unlisted schemes + .denyUnlistedSchemes() .build(); -// Development mode - permissive with audit -ResourceAccessPolicy devPolicy = ResourceAccessPolicy.builder() - .mode(PolicyMode.AUDIT) - .forScheme("https").allowAll() - .forScheme("http").allow("localhost/**") // allow local dev servers - .forScheme("file").allowAll() - .forScheme("jar").allowAll() - .defaultDeny() - .build(); +// Development mode (one-liner) +ResourceAccessPolicy devPolicy = ResourceAccessPolicy.development(); -// Use bundled defaults +// Bundled defaults (one-liner) ResourceAccessPolicy defaults = ResourceAccessPolicy.bundledDefaults(); -// Override mode on bundled defaults -ResourceAccessPolicy enforced = ResourceAccessPolicy.bundledDefaults() - .withMode(PolicyMode.ENFORCE); +// Modify existing policy +ResourceAccessPolicy modified = defaults.toBuilder() + .mode(PolicyMode.ENFORCE) + .forScheme("https") + .allow("my-internal-host.com/**") + .build(); -// Set on loader -IModuleLoader loader = ...; -loader.setResourceAccessPolicy(policy); +// Override just the mode +ResourceAccessPolicy enforced = defaults.withMode(PolicyMode.ENFORCE); + +// Diagnostic check (does NOT throw) +PolicyDecision decision = policy.explain(URI.create("https://10.0.0.1/api")); + +// Effective rules summary +String rules = policy.describeEffectiveRules(); ``` ### Metaschema-Based Configuration Model The policy configuration uses a Metaschema-defined model, enabling: - **Type-safe configuration** via generated Java classes -- **Multi-format support** - XML, JSON, or YAML -- **Schema validation** - configs validated against the Metaschema model -- **Dogfooding** - using Metaschema for its own tooling +- **Multi-format support** — XML, JSON, or YAML +- **Schema validation** — configs validated against the Metaschema model -**Metaschema Module Definition** (`resource-access-policy_metaschema.yaml`): +**Metaschema Module Definition** (`resource-access-policy-config_metaschema.yaml`): + +Note: The root assembly is named `resource-access-policy-config` (not `resource-access-policy`) to avoid naming collision with the hand-written `ResourceAccessPolicy` class. ```yaml metaschema: - schema-name: Resource Access Policy + schema-name: Resource Access Policy Configuration schema-version: 1.0.0 - short-name: resource-access-policy + short-name: resource-access-policy-config namespace: http://csrc.nist.gov/ns/metaschema/resource-access-policy/1.0 json-base-uri: http://csrc.nist.gov/ns/metaschema/resource-access-policy/1.0 definitions: - define-assembly: - name: resource-access-policy - formal-name: Resource Access Policy + name: resource-access-policy-config + formal-name: Resource Access Policy Configuration description: >- - Policy controlling which URIs can be accessed during resource loading. - Uses glob patterns grouped by URI scheme. + Configuration controlling which URIs can be accessed during resource + loading. Uses glob patterns grouped by URI scheme with IP-based + network security. root-name: resource-access-policy flags: - define-flag: @@ -487,6 +887,13 @@ metaschema: - enum: value: deny description: Deny unlisted schemes + - define-flag: + name: locked + as-type: boolean + formal-name: Locked + description: >- + When true, higher-precedence configuration layers cannot + weaken this policy (ratchet enforcement). model: - assembly: ref: scheme-config @@ -515,6 +922,13 @@ metaschema: description: >- Whether this scheme is enabled. When false, all URIs with this scheme are denied. + - define-flag: + name: inherit + as-type: boolean + formal-name: Inherit + description: >- + When true, patterns are appended to lower-precedence layer + patterns instead of replacing them. model: - field: ref: pattern @@ -531,6 +945,7 @@ metaschema: A glob pattern controlling access. Patterns without a prefix are allow patterns. Patterns starting with ! are deny patterns (exceptions). Patterns are evaluated in order; last match wins. + IMPORTANT: In YAML, patterns starting with ! must be quoted. ``` ### Example Configuration Files @@ -538,6 +953,7 @@ metaschema: **YAML format** (`resource-access-policy.yaml`): ```yaml +# Patterns starting with ! MUST be quoted in YAML resource-access-policy: mode: audit default-scheme-policy: deny @@ -546,51 +962,18 @@ resource-access-policy: patterns: - "pages.nist.gov/**" - "raw.githubusercontent.com/metaschema-framework/**" - - "!*.internal/**" + - "!*.internal/**" # quoted! YAML ! is a tag prefix - scheme: http enabled: false - scheme: file patterns: - "/data/schemas/**" - "/workspace/**" - - "!**/.ssh/**" - - "!**/.aws/**" - scheme: jar patterns: - "**" ``` -**JSON format** (`resource-access-policy.json`): - -```json -{ - "resource-access-policy": { - "mode": "audit", - "default-scheme-policy": "deny", - "schemes": [ - { - "scheme": "https", - "patterns": [ - "pages.nist.gov/**", - "raw.githubusercontent.com/metaschema-framework/**" - ] - }, - { - "scheme": "http", - "enabled": false - }, - { - "scheme": "file", - "patterns": [ - "/data/schemas/**", - "!**/.ssh/**" - ] - } - ] - } -} -``` - ### Loading Configuration ```java @@ -613,64 +996,51 @@ moduleLoader.setResourceAccessPolicy(policy); --- -## Configuration Layering - -Configurations are loaded from multiple locations, merged with higher-precedence layers overriding lower ones: - -| Priority | Location | Platform | Purpose | -|----------|----------|----------|---------| -| 1 (lowest) | Bundled in JAR | All | Restrictive defaults in audit mode | -| 2 | `/config/` | All | Distribution-specific overrides | -| 3 | `/etc/metaschema/` | Unix | System-wide administrator settings | -| 3 | `%ProgramData%\metaschema\` | Windows | System-wide administrator settings | -| 4 | `~/.metaschema/` | All | User-specific preferences | -| 5 | `./.metaschema/` | All | Project-specific overrides | -| 6 (highest) | CLI `--resource-policy` | All | CLI argument override | - -### Merge Semantics - -- **Mode**: Higher-precedence layer's mode wins -- **Scheme configs**: Merged by scheme name; higher-precedence replaces entire scheme config -- **Default scheme policy**: Higher-precedence layer's value wins - ---- - ## Glob Pattern Matching ### Syntax -Patterns follow `.gitignore` glob syntax applied to the scheme-specific part of URIs: - | Pattern | Matches | Example | |---------|---------|---------| -| `**` | Everything | All URIs for the scheme | +| `**` | Everything (any characters including `/`) | All URIs for the scheme | +| `*` | Any characters except `/` (single segment) | One directory level | +| `?` | Single character except `/` | One character | | `*.nist.gov/**` | Subdomain wildcard | `pages.nist.gov/schemas/foo.xml` | -| `example.com/path/**` | Path prefix | `example.com/path/to/resource` | -| `/workspace/**` | Directory tree (file scheme) | `/workspace/project/schema.xml` | -| `/workspace/*` | Single level (file scheme) | `/workspace/schema.xml` but not `/workspace/sub/schema.xml` | -| `!pattern` | Deny (exception) | Negates a previous allow | +| `/workspace/**` | Directory tree | `/workspace/project/schema.xml` | +| `/workspace/*` | Single level | `/workspace/schema.xml` (not deeper) | +| `!pattern` | **Deny** (block previously allowed) | Negates a previous allow | ### Pattern Evaluation For a given URI: -1. Extract the scheme -2. Find the matching `SchemePatternSet` -3. If scheme is `enabled: false`, result is **deny** -4. If no patterns defined and `enabled: true`, result is **allow** -5. Evaluate patterns in order; **last matching pattern wins** -6. If no pattern matches, use `default-scheme-policy` (default: deny) +1. Apply URI security processing (normalize, decode, resolve symlinks) +2. Extract the scheme (lowercased) +3. Find the matching `SchemePatternSet` +4. If scheme is `enabled: false`, result is **deny** +5. If no patterns defined and `enabled: true`, use `default-scheme-policy` +6. Evaluate patterns in order; **last matching pattern wins** +7. If no pattern matches, use `default-scheme-policy` (default: deny) ### What Patterns Match Against -For each scheme, patterns match against the scheme-specific part: +After URI normalization, patterns match against the scheme-specific part: | Scheme | Pattern matches against | Example URI → match target | |--------|------------------------|---------------------------| -| `file` | Path component | `file:///workspace/foo.xml` → `/workspace/foo.xml` | -| `https` | Host + path | `https://nist.gov/schemas/x.xml` → `nist.gov/schemas/x.xml` | -| `http` | Host + path | `http://localhost:8080/api` → `localhost:8080/api` | +| `file` | Normalized path | `file:///workspace/foo.xml` → `/workspace/foo.xml` | +| `https` | Host + path (no port) | `https://nist.gov:443/x.xml` → `nist.gov/x.xml` | +| `http` | Host + path (no port) | `http://localhost:8080/api` → `localhost/api` | | `jar` | Path within JAR | `jar:file:///lib.jar!/schema/x.xsd` → `/schema/x.xsd` | +### Important: `!` Means DENY + +Unlike `.gitignore` where `!` means "re-include" (stop ignoring a file), in this system `!` means **deny access**. This is the opposite semantic: + +| System | `!` Meaning | Example | +|--------|------------|---------| +| `.gitignore` | "Do NOT ignore this file" (re-include) | `!important.log` keeps the file tracked | +| Resource Access Policy | "DENY access to this resource" (block) | `"!**/.ssh/**"` blocks SSH key access | + --- ## Success Criteria @@ -680,36 +1050,50 @@ From Issue #183: - [ ] A Pull Request is submitted that fully addresses the goals - [ ] The CI-CD build process runs without any reported errors -### Additional Acceptance Criteria +### Functional -**Functional:** - [ ] Module loading checks policy for imports - [ ] Document loading checks policy - [ ] Constraint loading checks policy for imports - [ ] XML entity resolution checks policy - [ ] Glob pattern matching works correctly for all schemes -- [ ] `!` negation patterns create proper exceptions +- [ ] `!` deny patterns create proper exceptions - [ ] DISABLED mode allows all URIs without logging - [ ] AUDIT mode logs violations but allows all URIs - [ ] ENFORCE mode blocks violations with `AccessViolationException` -- [ ] Bundled defaults are restrictive (deny localhost, private IPs, sensitive paths) +- [ ] Bundled defaults are restrictive - [ ] Configuration loading works from YAML, JSON, and XML -- [ ] Configuration layering merges correctly - -**Security:** -- [ ] Path traversal attacks are caught (../../../etc/passwd) -- [ ] SSRF to localhost is caught in default policy -- [ ] SSRF to private IP ranges is caught in default policy -- [ ] Cloud metadata endpoints are caught in default policy -- [ ] Sensitive system paths are caught in default policy - -**Backwards Compatibility:** -- [ ] Default mode (AUDIT) does not break any existing workflows +- [ ] Configuration layering merges correctly with ratcheting +- [ ] `development()` factory allows localhost and HTTP + +### Security + +- [ ] Path traversal attacks caught via mandatory normalization +- [ ] URL encoding bypasses caught via mandatory percent-decoding +- [ ] Symlinks resolved before policy check (default mode) +- [ ] SSRF to localhost caught via IP-based checking (all encodings) +- [ ] SSRF to private IP ranges caught via CIDR block matching +- [ ] Cloud metadata endpoints caught (169.254.169.254) +- [ ] IPv4-mapped IPv6 addresses caught +- [ ] JAR scheme inner URIs recursively checked +- [ ] HTTP redirect URIs re-checked against policy +- [ ] Config layering cannot weaken policy (ratchet) +- [ ] Sensitive system paths denied by FileProtections +- [ ] Case-insensitive matching works on Windows +- [ ] ReDoS prevented via non-backtracking regex + +### Backwards Compatibility + +- [ ] Zero-config default (DISABLED) does not break any existing workflows - [ ] Existing code without policy configuration works unchanged - [ ] Library users can opt-in without changing their URI resolvers - [ ] CLI users can override mode via flags -**Non-Functional:** +### Non-Functional + +- [ ] Actionable error messages with layer, source, and remediation +- [ ] `explain()` provides evaluation trace for debugging +- [ ] `describeEffectiveRules()` provides human-readable policy summary - [ ] Clear log messages identifying policy violations - [ ] Minimal performance overhead for URI resolution - [ ] 80%+ test coverage for policy code @@ -720,11 +1104,104 @@ From Issue #183: ### Unit Tests -- `GlobMatcher` - Pattern matching for all glob syntax variants -- `SchemePatternSet` - Pattern evaluation with `!` negation and ordering -- `ResourceAccessPolicy` - Policy checking across modes -- `PolicyMode` - Mode behavior (disabled/audit/enforce) -- Metaschema configuration loading and validation +- `GlobMatcher` — Pattern matching for all glob syntax variants, case sensitivity +- `SchemePatternSet` — Pattern evaluation with `!` negation, ordering, empty patterns +- `ResourceAccessPolicy` — Policy checking across modes, factory methods, toBuilder +- `PolicyMode` — Mode behavior (disabled/audit/enforce) +- `UriNormalizer` — Path normalization, percent-decoding, symlink resolution +- `NetworkSecurityChecker` — IP-based CIDR block matching (see below) +- `FileProtections` — Allow-list, defaults, builder, conflict detection, case sensitivity +- `PolicyDecision` — Diagnostic results from explain() +- Configuration loading and validation + +### IP Range Boundary Tests + +Explicit boundary value tests for every private CIDR block, using the IP library: + +```java +@ParameterizedTest +@CsvSource({ + // 127.0.0.0/8 (loopback) + "126.255.255.255, true", // just below range — allowed + "127.0.0.0, false", // start of range — blocked + "127.0.0.1, false", // standard loopback — blocked + "127.255.255.255, false", // end of range — blocked + "128.0.0.0, true", // just above range — allowed + + // 10.0.0.0/8 (private Class A) + "9.255.255.255, true", + "10.0.0.0, false", + "10.255.255.255, false", + "11.0.0.0, true", + + // 172.16.0.0/12 (private Class B) + "172.15.255.255, true", + "172.16.0.0, false", + "172.31.255.255, false", + "172.32.0.0, true", + + // 192.168.0.0/16 (private Class C) + "192.167.255.255, true", + "192.168.0.0, false", + "192.168.255.255, false", + "192.169.0.0, true", + + // 169.254.0.0/16 (link-local, includes cloud metadata) + "169.253.255.255, true", + "169.254.0.0, false", + "169.254.169.254, false", // cloud metadata + "169.254.255.255, false", + "169.255.0.0, true", + + // 100.64.0.0/10 (CGNAT) + "100.63.255.255, true", + "100.64.0.0, false", + "100.127.255.255, false", + "100.128.0.0, true", + + // 0.0.0.0/8 (unspecified) + "0.0.0.0, false", + "0.255.255.255, false", + "1.0.0.0, true", +}) +void testIpv4CidrBoundaries(String ip, boolean allowed) { ... } + +@ParameterizedTest +@CsvSource({ + "::1, false", // IPv6 loopback + "::2, true", // not loopback + "fe80::1, false", // link-local + "fe7f::1, true", // not link-local + "fc00::1, false", // ULA + "fbff::1, true", // not ULA + "::ffff:127.0.0.1, false", // IPv4-mapped loopback + "::ffff:8.8.8.8, true", // IPv4-mapped public +}) +void testIpv6CidrBoundaries(String ip, boolean allowed) { ... } + +@ParameterizedTest +@CsvSource({ + "2130706433, false", // decimal 127.0.0.1 + "0x7f000001, false", // hex 127.0.0.1 + "0177.0.0.1, false", // octal 127.0.0.1 + "127.1, false", // shorthand 127.0.0.1 +}) +void testAlternateIpEncodings(String host, boolean allowed) { ... } +``` + +### Security Tests + +- Path traversal: `../../../etc/passwd`, `..%2f..%2f`, double-encoding +- URL encoding bypass: `%61` for `a`, `%2f` for `/` +- Symlink traversal: symlink from allowed to denied directory +- IP encoding bypass: decimal, octal, hex, shorthand, IPv4-mapped IPv6 +- DNS rebinding documentation (test that re-check API exists) +- HTTP redirect re-checking +- JAR inner URI SSRF: `jar:http://evil.com/mal.jar!/path` +- Case sensitivity: Windows paths, scheme names +- `!` pattern bypass attempts +- ReDoS resistance: patterns with deep nesting +- Config ratchet: lower-precedence config attempts to weaken ### Integration Tests @@ -733,15 +1210,7 @@ From Issue #183: - Constraint loading with policy - XML entity resolution with policy - Configuration layering from multiple sources - -### Security Tests - -- Path traversal attack vectors -- SSRF attack vectors (localhost, private IPs, metadata endpoints) -- Scheme injection attacks -- Unicode/encoding bypass attempts -- Case sensitivity handling (Windows paths) -- `!` pattern bypass attempts +- Ratchet enforcement across config layers --- @@ -749,34 +1218,86 @@ From Issue #183: | Risk | Impact | Mitigation | |------|--------|------------| -| Breaking existing applications | High | AUDIT mode as default; DISABLED available | -| Performance overhead | Medium | Efficient glob matching; pattern compilation | -| Incomplete default policy | Medium | Community feedback during audit phase | -| Configuration complexity | Medium | `.gitignore`-style syntax is widely known | -| Platform-specific path issues | Medium | Test on Windows/Linux/Mac; normalize paths | +| Breaking existing applications | High | DISABLED as zero-config default; explicit opt-in required | +| Performance overhead | Medium | Efficient glob matching; pattern compilation; IP address caching | +| Incomplete SSRF protection | High | IP-based checking via library, not just string patterns | +| Path traversal bypass | High | Mandatory normalization + symlink resolution before matching | +| Configuration complexity | Medium | Factory methods for common scenarios; diagnostic API | +| FileProtections confusion | Medium | Conflict detection at build time; actionable error messages | +| Platform-specific path issues | Medium | CaseSensitivity mode; test on Windows/Linux/Mac | +| ReDoS via crafted patterns | Medium | Non-backtracking regex; pattern complexity limits | +| Config privilege escalation | High | Ratchet principle; locked flag | --- ## Migration Path -### Phase 1: Deployment (AUDIT mode) +### Phase 1: Opt-In (AUDIT mode) -1. Deploy with default policy (restrictive rules, audit mode) -2. Monitor logs for `WARN` entries showing policy violations -3. Adjust policy patterns to match actual access needs -4. Share policy configs across team/org +1. Add `loader.setResourceAccessPolicy(ResourceAccessPolicy.bundledDefaults())` to your code +2. Deploy — AUDIT mode logs violations but allows all requests +3. Monitor logs for `[resource-access-policy]` WARN entries +4. Use `policy.explain(uri)` to understand specific decisions +5. Adjust policy patterns to match actual access needs +6. Run in AUDIT mode until no new warnings appear for at least 2 weeks ### Phase 2: Enforcement -1. Once policy accurately reflects needed access, switch to `ENFORCE` -2. `ResourceAccessPolicy.bundledDefaults().withMode(PolicyMode.ENFORCE)` -3. Or in config: `mode: enforce` +1. Switch to ENFORCE: `ResourceAccessPolicy.bundledDefaults().withMode(PolicyMode.ENFORCE)` +2. Or in config: `mode: enforce` +3. Monitor for `AccessViolationException` in error tracking +4. Use `--resource-policy-mode=audit` as emergency rollback ### Phase 3: Customization 1. Create project-specific `.metaschema/resource-access-policy.yaml` 2. Override bundled defaults for organization-specific needs 3. Use per-loader policies for fine-grained control +4. Deploy organization-wide policies via `/etc/metaschema/` + +--- + +## CLI Integration + +### Global Flags + +These flags apply to all commands that load resources (e.g., `validate`, `validate-content`, `convert`, `generate-schema`): + +| Flag | Description | +|------|-------------| +| `--resource-policy-mode=` | Override enforcement mode (disabled/audit/enforce) | +| `--resource-policy=` | Load custom policy configuration file | + +### `resource-policy` Command + +A new top-level command with subcommands for policy diagnostics. Follows the same parent/subcommand pattern as the existing `metapath` command (`AbstractParentCommand` with `AbstractTerminalCommand` subcommands). + +| Subcommand | Description | +|------------|-------------| +| `resource-policy dump` | Print effective merged policy as YAML and exit | +| `resource-policy check ` | Check a single URI against the policy and print evaluation trace | + +**Usage examples:** + +```bash +# Dump the effective policy (bundled defaults + any config files) +metaschema-cli resource-policy dump + +# Dump with a custom config overlay +metaschema-cli resource-policy dump --resource-policy=my-policy.yaml + +# Check whether a specific URI would be allowed +metaschema-cli resource-policy check https://example.com/schema.xsd + +# Check with enforce mode override +metaschema-cli resource-policy check --resource-policy-mode=enforce file:///etc/passwd +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `METASCHEMA_RESOURCE_POLICY_MODE` | Override enforcement mode | --- @@ -787,3 +1308,5 @@ From Issue #183: - Content inspection (only URI-based filtering) - Certificate validation (use JVM truststore config) - Real-time file watching for config changes (explicit reload only) +- DNS rebinding protection at the HTTP client level (documented as integration requirement) +- Port-based restrictions (may be added in a future version) diff --git a/PRDs/20251217-allowlist-resolver/implementation-plan.md b/PRDs/20251217-allowlist-resolver/implementation-plan.md index 70d4370823..8c12628fff 100644 --- a/PRDs/20251217-allowlist-resolver/implementation-plan.md +++ b/PRDs/20251217-allowlist-resolver/implementation-plan.md @@ -1,34 +1,34 @@ # Resource Access Policy - Implementation Plan -**Goal:** Implement policy-based URI access control with glob patterns, graduated enforcement modes, and bundled defaults. +**Goal:** Implement policy-based URI access control with glob patterns, IP-based SSRF protection, mandatory URI normalization, graduated enforcement modes, and bundled defaults. **Architecture:** All policy engine code in `core` module. CLI integration in `cli-processor`/`metaschema-cli`. -**Tech Stack:** Java 11, JUnit 5, SLF4J, Metaschema databind for configuration model. +**Tech Stack:** Java 11, JUnit 5, SLF4J, IP address library (`com.github.seancfoley:ipaddress`), Metaschema databind for configuration model. --- ## PR Breakdown -Implementation is organized into 4 PRs, each building on the previous: - | PR | Scope | Estimated Files | Key Deliverables | |----|-------|-----------------|------------------| -| PR1 | Policy engine core | ~15 files | `GlobMatcher`, `SchemePatternSet`, `PolicyMode`, `ResourceAccessPolicy`, builder | -| PR2 | Configuration model | ~10 files | Metaschema module, config loading, bundled defaults, layering | -| PR3 | Loader integration | ~10 files | `IModuleLoader`/`IBoundLoader` integration, XML entity policy | -| PR4 | CLI integration + docs | ~8 files | CLI flags, env vars, documentation | +| PR1 | Policy engine core | ~25 files | Enums, `GlobMatcher`, `UriNormalizer`, `NetworkSecurityChecker`, `SchemePatternSet`, `FileProtections`, `ResourceAccessPolicy`, builder, diagnostics | +| PR2 | Configuration model | ~10 files | Metaschema module, config loading, bundled defaults, layering with ratchet | +| PR3 | Loader integration | ~10 files | `IModuleLoader`/`IBoundLoader` integration, XML entity policy, redirect re-check | +| PR4 | CLI integration + docs | ~8 files | CLI flags, `resource-policy` command with `dump`/`check` subcommands, env vars, documentation | --- ## PR1: Policy Engine Core -**Goal:** Implement the glob-based pattern matching engine, enforcement modes, and the `ResourceAccessPolicy` builder. +**Goal:** Implement the glob-based pattern matching engine, URI normalization, IP-based network security, enforcement modes, diagnostics, and the `ResourceAccessPolicy` builder. **Module:** `core` **Package:** `dev.metaschema.core.model.policy` +**New dependency:** Add `com.github.seancfoley:ipaddress` to `core/pom.xml` for CIDR block matching. + ### Task 1.1: Create PolicyMode Enum **Files:** @@ -49,8 +49,8 @@ import org.junit.jupiter.params.provider.CsvSource; class PolicyModeTest { @Test - void testDefaultModeIsAudit() { - assertEquals(PolicyMode.AUDIT, PolicyMode.defaultMode()); + void testDefaultModeIsDisabled() { + assertEquals(PolicyMode.DISABLED, PolicyMode.defaultMode()); } @ParameterizedTest @@ -75,6 +75,24 @@ class PolicyModeTest { void testFromString(String input, PolicyMode expected) { assertEquals(expected, PolicyMode.fromString(input)); } + + @Test + void testRestrictionOrdering() { + assertTrue(PolicyMode.DISABLED.ordinal() < PolicyMode.AUDIT.ordinal()); + assertTrue(PolicyMode.AUDIT.ordinal() < PolicyMode.ENFORCE.ordinal()); + } + + @ParameterizedTest + @CsvSource({ + "DISABLED, AUDIT, AUDIT", + "AUDIT, ENFORCE, ENFORCE", + "ENFORCE, DISABLED, ENFORCE", + "AUDIT, DISABLED, AUDIT", + "ENFORCE, AUDIT, ENFORCE" + }) + void testMostRestrictive(PolicyMode a, PolicyMode b, PolicyMode expected) { + assertEquals(expected, PolicyMode.mostRestrictive(a, b)); + } } ``` @@ -89,6 +107,10 @@ import edu.umd.cs.findbugs.annotations.NonNull; /** * Enforcement mode for resource access policies. + * + *

Modes are ordered by restriction level: DISABLED < AUDIT < ENFORCE. + * The {@link #mostRestrictive(PolicyMode, PolicyMode)} method supports the + * ratchet principle where configuration layers can only tighten policy. */ public enum PolicyMode { /** No policy checking; all URIs allowed silently. */ @@ -106,57 +128,38 @@ public enum PolicyMode { this.blockEnabled = blockEnabled; } - /** - * Whether this mode performs policy checks. - * - * @return {@code true} if policy rules are evaluated - */ - public boolean isCheckEnabled() { - return checkEnabled; - } + /** Whether this mode performs policy checks. */ + public boolean isCheckEnabled() { return checkEnabled; } - /** - * Whether this mode blocks violating requests. - * - * @return {@code true} if violations throw exceptions - */ - public boolean isBlockEnabled() { - return blockEnabled; - } + /** Whether this mode blocks violating requests. */ + public boolean isBlockEnabled() { return blockEnabled; } - /** - * Returns the default enforcement mode ({@link #AUDIT}). - * - * @return the default mode - */ + /** Returns the default enforcement mode ({@link #DISABLED}). */ @NonNull - public static PolicyMode defaultMode() { - return AUDIT; - } + public static PolicyMode defaultMode() { return DISABLED; } - /** - * Parses a mode from a string value (case-insensitive). - * - * @param value - * the string to parse - * @return the matching mode - * @throws IllegalArgumentException - * if the value does not match any mode - */ + /** Parses a mode from a string value (case-insensitive). */ @NonNull public static PolicyMode fromString(@NonNull String value) { return valueOf(value.toUpperCase(Locale.ROOT)); } + + /** Returns the more restrictive of two modes (ratchet principle). */ + @NonNull + public static PolicyMode mostRestrictive(@NonNull PolicyMode a, @NonNull PolicyMode b) { + return a.ordinal() >= b.ordinal() ? a : b; + } } ``` --- -### Task 1.2: Create AccessViolationException +### Task 1.2: Create SymlinkPolicy and CaseSensitivity Enums **Files:** -- Create: `core/src/main/java/dev/metaschema/core/model/policy/AccessViolationException.java` -- Test: `core/src/test/java/dev/metaschema/core/model/policy/AccessViolationExceptionTest.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/SymlinkPolicy.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/CaseSensitivity.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/CaseSensitivityTest.java` **Test first:** @@ -167,92 +170,117 @@ import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; -import java.net.URI; - -class AccessViolationExceptionTest { +class CaseSensitivityTest { @Test - void testExceptionContainsUriAndReason() { - URI uri = URI.create("file:///etc/passwd"); - String reason = "Denied by pattern: !/etc/**"; - - AccessViolationException ex = new AccessViolationException(uri, reason); + void testSystemDefaultDetectsOs() { + boolean isWindows = System.getProperty("os.name") + .toLowerCase(java.util.Locale.ROOT).contains("win"); + CaseSensitivity systemDefault = CaseSensitivity.SYSTEM_DEFAULT; - assertEquals(uri, ex.getUri()); - assertEquals(reason, ex.getReason()); - assertTrue(ex.getMessage().contains(uri.toString())); - assertTrue(ex.getMessage().contains(reason)); + assertEquals(!isWindows, systemDefault.isCaseSensitive()); } @Test - void testExtendsSecurityException() { - AccessViolationException ex = new AccessViolationException( - URI.create("http://localhost"), "denied"); - assertInstanceOf(SecurityException.class, ex); + void testExplicitModes() { + assertTrue(CaseSensitivity.CASE_SENSITIVE.isCaseSensitive()); + assertFalse(CaseSensitivity.CASE_INSENSITIVE.isCaseSensitive()); } } ``` **Implementation:** +```java +/** Policy for resolving symbolic links during file path checking. */ +public enum SymlinkPolicy { + /** Resolve symlinks via {@code Path.toRealPath()} before checking (default). */ + FOLLOW, + /** Check the path as-is without symlink resolution. */ + NOFOLLOW +} + +/** Case sensitivity mode for file path matching. */ +public enum CaseSensitivity { + /** Auto-detect from OS: case-insensitive on Windows, case-sensitive elsewhere. */ + SYSTEM_DEFAULT, + /** Always case-sensitive matching. */ + CASE_SENSITIVE, + /** Always case-insensitive matching. */ + CASE_INSENSITIVE; + + /** Whether this mode uses case-sensitive matching. */ + public boolean isCaseSensitive() { + return switch (this) { + case CASE_SENSITIVE -> true; + case CASE_INSENSITIVE -> false; + case SYSTEM_DEFAULT -> !System.getProperty("os.name") + .toLowerCase(java.util.Locale.ROOT).contains("win"); + }; + } +} +``` + +--- + +### Task 1.3: Create AccessViolationException + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/AccessViolationException.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/AccessViolationExceptionTest.java` + +**Test first:** + ```java package dev.metaschema.core.model.policy; -import java.net.URI; +import static org.junit.jupiter.api.Assertions.*; -import edu.umd.cs.findbugs.annotations.NonNull; +import org.junit.jupiter.api.Test; -/** - * Exception thrown when a URI access violates the resource access policy - * in {@link PolicyMode#ENFORCE} mode. - */ -public class AccessViolationException extends SecurityException { - private static final long serialVersionUID = 1L; +import java.net.URI; - @NonNull - private final URI uri; - @NonNull - private final String reason; +class AccessViolationExceptionTest { - /** - * Constructs a new access violation exception. - * - * @param uri - * the URI that violated the policy - * @param reason - * human-readable explanation of the violation - */ - public AccessViolationException(@NonNull URI uri, @NonNull String reason) { - super(String.format("Resource access policy violation for '%s': %s", uri, reason)); - this.uri = uri; - this.reason = reason; - } + @Test + void testExceptionContainsStructuredFields() { + URI uri = URI.create("file:///etc/passwd"); + AccessViolationException ex = new AccessViolationException( + uri, "file-protections", "path not in allowed areas", + "bundled defaults", + "FileProtections.builder().includeDefaults().allow(\"/etc/passwd\").build()"); - /** - * Returns the URI that violated the policy. - * - * @return the violating URI - */ - @NonNull - public URI getUri() { - return uri; + assertEquals(uri, ex.getUri()); + assertEquals("file-protections", ex.getLayer()); + assertEquals("path not in allowed areas", ex.getDenialReason()); + assertEquals("bundled defaults", ex.getConfigSource()); + assertNotNull(ex.getRemediation()); + assertTrue(ex.getMessage().contains(uri.toString())); + assertTrue(ex.getMessage().contains("file-protections")); } - /** - * Returns the reason for the violation. - * - * @return the violation reason - */ - @NonNull - public String getReason() { - return reason; + @Test + void testExtendsSecurityException() { + AccessViolationException ex = new AccessViolationException( + URI.create("http://localhost"), "network-security", + "loopback denied", "bundled defaults", null); + assertInstanceOf(SecurityException.class, ex); } } ``` +**Implementation:** Exception with fields for `uri`, `layer`, `denialReason`, `configSource`, `remediation`. Message format matches PRD: + +```text +Resource access policy violation: '' was denied. + Denied by: () + Source: + To allow: +``` + --- -### Task 1.3: Create GlobMatcher +### Task 1.4: Create GlobMatcher **Files:** - Create: `core/src/main/java/dev/metaschema/core/model/policy/GlobMatcher.java` @@ -280,45 +308,392 @@ class GlobMatcherTest { "'/workspace/**', '/workspace/project/schema.xml', true", "'/workspace/*', '/workspace/schema.xml', true", "'/workspace/*', '/workspace/sub/schema.xml', false", + "'example.com/path/**', 'example.com/path', true", + "'example.com/path/**', 'example.com/path/', true", "'example.com/path/**', 'example.com/path/to/resource', true", "'example.com/path/**', 'example.com/other/resource', false", "'**/.ssh/**', '/home/user/.ssh/id_rsa', true", "'**/.ssh/**', '/home/user/projects/ssh-keys', false", - "'localhost/**', 'localhost:8080/api', true", - "'127.*/**', '127.0.0.1/secret', true", - "'127.*/**', '128.0.0.1/public', false", + "'localhost/**', 'localhost/api', true", }) void testPatternMatching(String pattern, String target, boolean expected) { - GlobMatcher matcher = GlobMatcher.compile(pattern); + GlobMatcher matcher = GlobMatcher.compile(pattern, true); assertEquals(expected, matcher.matches(target), () -> String.format("Pattern '%s' vs '%s'", pattern, target)); } + @Test + void testCaseInsensitiveMatching() { + GlobMatcher matcher = GlobMatcher.compile("/Workspace/**", false); + assertTrue(matcher.matches("/workspace/file.xml")); + assertTrue(matcher.matches("/WORKSPACE/file.xml")); + } + + @Test + void testCaseSensitiveMatching() { + GlobMatcher matcher = GlobMatcher.compile("/workspace/**", true); + assertTrue(matcher.matches("/workspace/file.xml")); + assertFalse(matcher.matches("/Workspace/file.xml")); + } + @Test void testNullSafety() { - GlobMatcher matcher = GlobMatcher.compile("**"); + GlobMatcher matcher = GlobMatcher.compile("**", true); assertThrows(NullPointerException.class, () -> matcher.matches(null)); } + @Test + void testDirectoryEquivalence() { + // path/** must also match path itself (without trailing slash or children) + GlobMatcher matcher = GlobMatcher.compile("/workspace/**", true); + assertTrue(matcher.matches("/workspace"), "directory itself without trailing slash"); + assertTrue(matcher.matches("/workspace/"), "directory with trailing slash"); + assertTrue(matcher.matches("/workspace/project/schema.xml"), "child path"); + assertFalse(matcher.matches("/workspaceX"), "must not match prefix that is not the directory"); + assertFalse(matcher.matches("/workspac"), "must not match shorter prefix"); + + // Same for host-style patterns + GlobMatcher hostMatcher = GlobMatcher.compile("pages.nist.gov/**", true); + assertTrue(hostMatcher.matches("pages.nist.gov"), "host directory itself"); + assertTrue(hostMatcher.matches("pages.nist.gov/"), "host directory with trailing slash"); + assertTrue(hostMatcher.matches("pages.nist.gov/schemas/foo.xml"), "host child path"); + } + @Test void testEmptyPattern() { - GlobMatcher matcher = GlobMatcher.compile(""); + GlobMatcher matcher = GlobMatcher.compile("", true); assertTrue(matcher.matches("")); assertFalse(matcher.matches("anything")); } + + @Test + void testReDoSResistance() { + // Crafted pattern that would cause catastrophic backtracking with naive regex + String malicious = "**/**/**/**/**/**/**/**/**/**"; + GlobMatcher matcher = GlobMatcher.compile(malicious, true); + String longPath = "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z"; + // Should complete in reasonable time (< 1 second), not hang + assertTimeout(java.time.Duration.ofSeconds(1), + () -> matcher.matches(longPath)); + } } ``` **Implementation:** Compile glob patterns to `java.util.regex.Pattern`: -- `*` → matches any characters except `/` -- `**` → matches any characters including `/` -- `?` → matches single character except `/` +- `*` → `[^/]*+` (possessive quantifier to prevent backtracking) +- `**` → `.*+` (possessive quantifier) +- `?` → `[^/]` - Escape regex special characters -- Case-insensitive matching on Windows for file paths +- Accept `caseSensitive` parameter for `Pattern.CASE_INSENSITIVE` flag +- Use possessive quantifiers or atomic groups to prevent ReDoS +- Validate pattern length (max 500 chars) +- **Directory equivalence:** When a pattern ends with `/**`, compile it to also match the directory prefix itself. For pattern `P/**`, the compiled regex matches `P`, `P/`, and `P/`. Implementation: detect trailing `/**`, strip it to get prefix `P`, compile as `P(/.*+)?` (optional `/` followed by anything). This ensures `path/**` ≡ `path` in allow lists. --- -### Task 1.4: Create SchemePatternSet +### Task 1.5: Create UriNormalizer + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/UriNormalizer.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/UriNormalizerTest.java` + +**Test first:** + +```java +package dev.metaschema.core.model.policy; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.net.URI; + +class UriNormalizerTest { + + // --- Path normalization --- + + @ParameterizedTest + @CsvSource({ + "file:///workspace/../etc/passwd, /etc/passwd", + "file:///workspace/./schema.xml, /workspace/schema.xml", + "file:///workspace/project/../../etc/passwd, /etc/passwd", + }) + void testPathTraversalNormalization(String rawUri, String expectedPath) { + URI uri = URI.create(rawUri); + String normalized = UriNormalizer.normalizeFilePath(uri, SymlinkPolicy.NOFOLLOW); + assertEquals(expectedPath, normalized); + } + + @Test + void testRejectPathWithDotsAfterNormalization() { + // Edge case: a path that still contains ".." after normalization + // (should not happen with Path.normalize() but tested for defense-in-depth) + URI uri = URI.create("file:///workspace/../etc/passwd"); + String normalized = UriNormalizer.normalizeFilePath(uri, SymlinkPolicy.NOFOLLOW); + assertFalse(normalized.contains(".."), "Normalized path must not contain '..'"); + } + + // --- Percent-decoding --- + + @ParameterizedTest + @CsvSource({ + "file:///etc/p%61sswd, /etc/passwd", + "file:///workspace%2F..%2F..%2Fetc%2Fpasswd, /etc/passwd", + }) + void testPercentDecoding(String rawUri, String expectedPath) { + URI uri = URI.create(rawUri); + String normalized = UriNormalizer.normalizeFilePath(uri, SymlinkPolicy.NOFOLLOW); + assertEquals(expectedPath, normalized); + } + + // --- Scheme normalization --- + + @Test + void testSchemeNormalization() { + assertEquals("file", UriNormalizer.normalizeScheme(URI.create("FILE:///path"))); + assertEquals("https", UriNormalizer.normalizeScheme(URI.create("HTTPS://host/path"))); + } + + // --- Host normalization (http/https) --- + + @ParameterizedTest + @CsvSource({ + "https://EXAMPLE.COM/path, example.com/path", + "https://Example.Com:443/path, example.com/path", + "https://example.com:8443/path, example.com/path", + "http://LOCALHOST:8080/api, localhost/api", + "http://localhost:80/api, localhost/api", + }) + void testHostNormalization(String rawUri, String expectedTarget) { + URI uri = URI.create(rawUri); + String target = UriNormalizer.normalizeNetworkTarget(uri); + assertEquals(expectedTarget, target); + } + + // --- JAR scheme parsing --- + + @Test + void testJarSchemeInnerUri() { + URI jarUri = URI.create("jar:http://evil.com/mal.jar!/schema/x.xsd"); + URI innerUri = UriNormalizer.extractJarInnerUri(jarUri); + assertEquals(URI.create("http://evil.com/mal.jar"), innerUri); + } + + @Test + void testJarSchemeInternalPath() { + URI jarUri = URI.create("jar:file:///lib.jar!/schema/x.xsd"); + String internalPath = UriNormalizer.extractJarInternalPath(jarUri); + assertEquals("/schema/x.xsd", internalPath); + } + + @Test + void testMalformedJarUri() { + URI jarUri = URI.create("jar:file:///lib.jar"); + assertThrows(IllegalArgumentException.class, + () -> UriNormalizer.extractJarInternalPath(jarUri)); + } +} +``` + +**Implementation:** Static utility class with methods: +- `normalizeScheme(URI)` → lowercase scheme string +- `normalizeFilePath(URI, SymlinkPolicy)` → decode, normalize path, optionally resolve symlinks +- `normalizeNetworkTarget(URI)` → lowercase host, strip default ports, return `host/path` +- `extractJarInnerUri(URI)` → parse inner URI before `!` +- `extractJarInternalPath(URI)` → parse path after `!` +- Reject paths containing `..` after normalization (defense-in-depth) + +--- + +### Task 1.6: Create NetworkSecurityChecker + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/NetworkSecurityChecker.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/NetworkSecurityConfig.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/NetworkSecurityCheckerTest.java` + +**Test first — IP CIDR boundary tests:** + +```java +package dev.metaschema.core.model.policy; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class NetworkSecurityCheckerTest { + + private final NetworkSecurityChecker checker + = new NetworkSecurityChecker(NetworkSecurityConfig.defaults()); + + // --- IPv4 CIDR boundary tests --- + + @ParameterizedTest + @CsvSource({ + // 127.0.0.0/8 (loopback) + "126.255.255.255, true", + "127.0.0.0, false", + "127.0.0.1, false", + "127.255.255.255, false", + "128.0.0.0, true", + + // 10.0.0.0/8 (private Class A) + "9.255.255.255, true", + "10.0.0.0, false", + "10.128.0.1, false", + "10.255.255.255, false", + "11.0.0.0, true", + + // 172.16.0.0/12 (private Class B) + "172.15.255.255, true", + "172.16.0.0, false", + "172.20.0.1, false", + "172.31.255.255, false", + "172.32.0.0, true", + + // 192.168.0.0/16 (private Class C) + "192.167.255.255, true", + "192.168.0.0, false", + "192.168.1.100, false", + "192.168.255.255, false", + "192.169.0.0, true", + + // 169.254.0.0/16 (link-local / cloud metadata) + "169.253.255.255, true", + "169.254.0.0, false", + "169.254.169.254, false", + "169.254.255.255, false", + "169.255.0.0, true", + + // 100.64.0.0/10 (CGNAT / shared address space) + "100.63.255.255, true", + "100.64.0.0, false", + "100.100.0.1, false", + "100.127.255.255, false", + "100.128.0.0, true", + + // 0.0.0.0/8 (unspecified) + "0.0.0.0, false", + "0.255.255.255, false", + "1.0.0.0, true", + + // Public IPs (should be allowed) + "8.8.8.8, true", + "1.1.1.1, true", + "93.184.216.34, true", + }) + void testIpv4CidrBoundaries(String ip, boolean allowed) { + assertEquals(allowed, checker.isAllowed(ip), + () -> "IP " + ip + " should be " + (allowed ? "allowed" : "blocked")); + } + + // --- IPv6 CIDR boundary tests --- + + @ParameterizedTest + @CsvSource({ + // ::1/128 (IPv6 loopback) + "::1, false", + "::2, true", + + // fe80::/10 (IPv6 link-local) + "fe80::1, false", + "fe80::ffff, false", + "febf::1, false", + "fec0::1, true", + + // fc00::/7 (IPv6 ULA) + "fc00::1, false", + "fd00::1, false", + "fdff::ffff, false", + "fe00::1, true", + + // ::ffff:0:0/96 (IPv4-mapped IPv6 — checked after mapping) + "::ffff:127.0.0.1, false", + "::ffff:10.0.0.1, false", + "::ffff:192.168.1.1, false", + "::ffff:8.8.8.8, true", + "::ffff:1.1.1.1, true", + + // Public IPv6 (should be allowed) + "2001:4860:4860::8888, true", + }) + void testIpv6CidrBoundaries(String ip, boolean allowed) { + assertEquals(allowed, checker.isAllowed(ip), + () -> "IP " + ip + " should be " + (allowed ? "allowed" : "blocked")); + } + + // --- Alternate IP encoding tests --- + + @ParameterizedTest + @CsvSource({ + "2130706433, false", // decimal 127.0.0.1 + "0x7f000001, false", // hex 127.0.0.1 + "0177.0.0.1, false", // octal 127.0.0.1 + "127.1, false", // shorthand 127.0.0.1 + }) + void testAlternateIpEncodings(String host, boolean allowed) { + assertEquals(allowed, checker.isAllowed(host), + () -> "Host " + host + " should be " + (allowed ? "allowed" : "blocked")); + } + + // --- Hostname resolution --- + + @Test + void testLocalhostResolution() { + assertFalse(checker.isAllowed("localhost")); + } + + // --- Custom config --- + + @Test + void testAllowLoopback() { + NetworkSecurityChecker devChecker = new NetworkSecurityChecker( + NetworkSecurityConfig.builder() + .allowLoopback(true) + .build()); + + assertTrue(devChecker.isAllowed("127.0.0.1")); + assertTrue(devChecker.isAllowed("localhost")); + assertFalse(devChecker.isAllowed("10.0.0.1")); // still blocked + } + + @Test + void testAllowSpecificCidr() { + NetworkSecurityChecker customChecker = new NetworkSecurityChecker( + NetworkSecurityConfig.builder() + .allowCidr("10.0.0.0/24") + .build()); + + assertTrue(customChecker.isAllowed("10.0.0.1")); + assertFalse(customChecker.isAllowed("10.0.1.1")); + assertFalse(customChecker.isAllowed("192.168.1.1")); + } + + // --- Denial reason --- + + @Test + void testDenialReasonIncludesCidr() { + String reason = checker.getDenialReason("10.0.0.1"); + assertNotNull(reason); + assertTrue(reason.contains("10.0.0.0/8")); + } +} +``` + +**Implementation:** +- `NetworkSecurityChecker` accepts a `NetworkSecurityConfig` +- Uses `com.github.seancfoley:ipaddress` library for CIDR matching +- `isAllowed(String hostOrIp)` — resolves hostname to `InetAddress`, checks against blocked CIDR ranges +- `getDenialReason(String hostOrIp)` — returns human-readable reason with the matching CIDR block +- `NetworkSecurityConfig` — builder with `allowLoopback(boolean)`, `allowCidr(String)`, `defaults()` factory + +--- + +### Task 1.7: Create SchemePatternSet **Files:** - Create: `core/src/main/java/dev/metaschema/core/model/policy/SchemePatternSet.java` @@ -343,9 +718,10 @@ class SchemePatternSetTest { } @Test - void testNoPatternsAllowsAll() { + void testNoPatternsUsesDefaultPolicy() { + // enabled + no patterns should NOT allow all — uses default deny SchemePatternSet set = SchemePatternSet.enabled("https"); - assertTrue(set.isAllowed("example.com/anything")); + assertFalse(set.isAllowed("example.com/anything")); } @Test @@ -372,9 +748,9 @@ class SchemePatternSetTest { @Test void testLastMatchWins() { SchemePatternSet set = SchemePatternSet.builder("file") - .allow("**") // allow everything - .deny("/etc/**") // except /etc - .allow("/etc/motd") // but re-allow /etc/motd + .allow("**") + .deny("/etc/**") + .allow("/etc/motd") .build(); assertTrue(set.isAllowed("/workspace/file.xml")); @@ -383,8 +759,7 @@ class SchemePatternSetTest { } @Test - void testNoMatchUsesDefault() { - // With default deny (no patterns match) + void testNoMatchDenies() { SchemePatternSet set = SchemePatternSet.builder("https") .allow("nist.gov/**") .build(); @@ -392,14 +767,75 @@ class SchemePatternSetTest { assertTrue(set.isAllowed("nist.gov/schemas/x.xml")); assertFalse(set.isAllowed("evil.com/attack")); } + + @Test + void testAllowAll() { + SchemePatternSet set = SchemePatternSet.builder("jar") + .allowAll() + .build(); + assertTrue(set.isAllowed("/any/path")); + } + + @Test + void testDenyAll() { + SchemePatternSet set = SchemePatternSet.builder("http") + .denyAll() + .build(); + assertFalse(set.isAllowed("example.com/api")); + } + + @Test + void testCaseInsensitiveMatching() { + SchemePatternSet set = SchemePatternSet.builder("file") + .caseSensitive(false) + .allow("/Workspace/**") + .build(); + + assertTrue(set.isAllowed("/workspace/file.xml")); + assertTrue(set.isAllowed("/WORKSPACE/file.xml")); + } } ``` -**Implementation:** Holds an ordered list of `(GlobMatcher, boolean isAllow)` entries. Evaluates last-match-wins. +**Implementation:** Holds an ordered list of `(GlobMatcher, boolean isAllow)` entries. Evaluates last-match-wins. `enabled: true` with no patterns returns `false` (deny, matching default-scheme-policy behavior). Accepts `caseSensitive` flag passed to `GlobMatcher.compile()`. --- -### Task 1.5: Create IResourceAccessPolicy Interface +### Task 1.8: Create PolicyDecision and EvaluationStep + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/PolicyDecision.java` +- Create: `core/src/main/java/dev/metaschema/core/model/policy/EvaluationStep.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/PolicyDecisionTest.java` + +**Implementation:** Simple immutable data classes: + +```java +/** Diagnostic result from {@link ResourceAccessPolicy#explain(URI)}. */ +public final class PolicyDecision { + private final boolean allowed; + private final String layer; + private final String denialReason; + private final String matchingPattern; + private final String configSource; + private final String remediation; + private final List evaluationTrace; + // constructor, getters, toString +} + +/** Single step in the policy evaluation trace. */ +public final class EvaluationStep { + private final String layer; + private final String description; + private final boolean matched; + private final boolean resultIfMatched; + // constructor, getters +} +``` + +--- + +### Task 1.9: Create IResourceAccessPolicy Interface **Files:** - Create: `core/src/main/java/dev/metaschema/core/model/policy/IResourceAccessPolicy.java` @@ -415,30 +851,16 @@ import edu.umd.cs.findbugs.annotations.NonNull; /** * Policy that controls which URIs can be accessed during resource loading. - *

- * Implementations evaluate URIs against configured rules and take action - * based on the {@link PolicyMode}: log violations (audit), block violations - * (enforce), or skip checking entirely (disabled). * * @see ResourceAccessPolicy */ public interface IResourceAccessPolicy { - /** - * A policy that allows all access without checking. - */ + /** A policy that allows all access without checking. */ IResourceAccessPolicy ALLOW_ALL = uri -> { /* no-op */ }; /** * Checks whether the given URI is allowed by this policy. - *

- * Depending on the {@link PolicyMode}: - *

    - *
  • {@code DISABLED}: No checking, always returns
  • - *
  • {@code AUDIT}: Checks and logs violations, always returns
  • - *
  • {@code ENFORCE}: Checks and throws - * {@link AccessViolationException} on violation
  • - *
* * @param uri * the URI to check @@ -451,7 +873,160 @@ public interface IResourceAccessPolicy { --- -### Task 1.6: Create ResourceAccessPolicy and Builder +### Task 1.10: Create FileProtections + +**Files:** +- Create: `core/src/main/java/dev/metaschema/core/model/policy/FileProtections.java` +- Test: `core/src/test/java/dev/metaschema/core/model/policy/FileProtectionsTest.java` + +**Test first:** + +```java +package dev.metaschema.core.model.policy; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; + +class FileProtectionsTest { + + @TempDir + Path cwd; + + @ParameterizedTest + @ValueSource(strings = { + "/etc/passwd", + "/proc/self/environ", + "/sys/kernel/debug", + "/dev/null", + "/root/.bashrc", + "/var/run/secrets/kubernetes.io/token", + "C:/Windows/System32/config/SAM", + }) + void testDefaultDeniesPathsOutsideSafeAreas(String path) { + FileProtections protections = FileProtections.withDefaults(cwd, + CaseSensitivity.CASE_SENSITIVE); + assertFalse(protections.isAllowed(path), + "Should deny (outside safe areas): " + path); + } + + @Test + void testDefaultAllowsCwdSubtree() { + FileProtections protections = FileProtections.withDefaults(cwd, + CaseSensitivity.CASE_SENSITIVE); + String cwdPath = cwd.resolve("project/schema.xml").toString(); + assertTrue(protections.isAllowed(cwdPath), "Should allow CWD subtree"); + } + + @Test + void testDefaultDeniesDotDirsInHome() { + Path home = Path.of(System.getProperty("user.home")); + FileProtections protections = FileProtections.withDefaults(cwd, + CaseSensitivity.CASE_SENSITIVE); + + String sshKey = home.resolve(".ssh/id_rsa").toString(); + assertFalse(protections.isAllowed(sshKey), + "Should deny ~/.ssh (blanket dot-dir exclusion)"); + + String kubeCfg = home.resolve(".kube/config").toString(); + assertFalse(protections.isAllowed(kubeCfg), + "Should deny ~/.kube (blanket dot-dir exclusion)"); + + String normalFile = home.resolve("projects/schema.xml").toString(); + assertTrue(protections.isAllowed(normalFile), + "Should allow normal files in home"); + } + + @Test + void testBuilderIncludeDefaults() { + FileProtections protections = FileProtections.builder(cwd, + CaseSensitivity.CASE_SENSITIVE) + .includeDefaults() + .allow("/opt/metaschema/**") + .build(); + + String cwdFile = cwd.resolve("schema.xml").toString(); + assertTrue(protections.isAllowed(cwdFile)); + assertTrue(protections.isAllowed("/opt/metaschema/x")); + assertFalse(protections.isAllowed("/etc/passwd")); + } + + @Test + void testBuilderRemoveDefault() { + FileProtections protections = FileProtections.builder(cwd, + CaseSensitivity.CASE_SENSITIVE) + .includeDefaults() + .remove("/**") + .build(); + + Path home = Path.of(System.getProperty("user.home")); + assertFalse(protections.isAllowed(home.resolve("file.txt").toString())); + assertTrue(protections.isAllowed(cwd.resolve("file.txt").toString())); + } + + @Test + void testBuilderFullyCustom() { + FileProtections protections = FileProtections.builder(cwd, + CaseSensitivity.CASE_SENSITIVE) + .allow("/opt/app/**") + .build(); + + assertTrue(protections.isAllowed("/opt/app/schema.xml")); + assertFalse(protections.isAllowed("/etc/passwd")); + assertFalse(protections.isAllowed(cwd.resolve("file.txt").toString())); + } + + @Test + void testDisabledAllowsEverythingAndLogsWarning() { + FileProtections protections = FileProtections.disabled(); + assertTrue(protections.isAllowed("/etc/passwd")); + assertTrue(protections.isAllowed("/home/user/.ssh/key")); + } + + @Test + void testDefaultPatternsAreInspectable() { + assertFalse(FileProtections.defaultAllowPatterns().isEmpty()); + } + + @Test + void testCaseInsensitiveOnWindows() { + FileProtections protections = FileProtections.withDefaults(cwd, + CaseSensitivity.CASE_INSENSITIVE); + String cwdUpper = cwd.resolve("Schema.XML").toString().toUpperCase(); + // Should match CWD subtree case-insensitively + assertTrue(protections.isAllowed( + cwd.resolve("Schema.XML").toString())); + } + + @Test + void testCwdRootWarning() { + // When CWD is filesystem root, should log a warning + Path root = Path.of("/"); + // This should succeed but log a WARNING + FileProtections protections = FileProtections.withDefaults(root, + CaseSensitivity.CASE_SENSITIVE); + // Root allows everything via /** + assertTrue(protections.isAllowed("/etc/passwd")); + } +} +``` + +**Implementation:** +- `withDefaults(Path cwd, CaseSensitivity cs)` — default patterns with CWD + home minus dot-dirs +- `disabled()` — allows everything, logs WARN, Javadoc security warning +- `builder(Path cwd, CaseSensitivity cs)` — customizable builder +- `defaultAllowPatterns()` — static inspection method +- `isAllowed(String path)` — check path against patterns +- Warn if CWD is root (`/` or drive root) + +--- + +### Task 1.11: Create ResourceAccessPolicy and Builder **Files:** - Create: `core/src/main/java/dev/metaschema/core/model/policy/ResourceAccessPolicy.java` @@ -478,7 +1053,6 @@ class ResourceAccessPolicyTest { .forScheme("file").denyAll() .build(); - // Should not throw even though file scheme is denied assertDoesNotThrow(() -> policy.checkAccess(URI.create("file:///etc/passwd"))); } @@ -487,10 +1061,9 @@ class ResourceAccessPolicyTest { ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.AUDIT) .forScheme("http").denyAll() - .defaultDeny() + .denyUnlistedSchemes() .build(); - // Should not throw even though http is denied assertDoesNotThrow(() -> policy.checkAccess(URI.create("http://localhost/admin"))); } @@ -499,7 +1072,7 @@ class ResourceAccessPolicyTest { ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) .forScheme("http").denyAll() - .defaultDeny() + .denyUnlistedSchemes() .build(); assertThrows(AccessViolationException.class, @@ -510,17 +1083,12 @@ class ResourceAccessPolicyTest { void testEnforceModeAllowsMatching() { ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) - .forScheme("https") - .allow("nist.gov/**") - .forScheme("file") - .allow("/workspace/**") - .defaultDeny() + .forScheme("https").allow("nist.gov/**") + .denyUnlistedSchemes() .build(); assertDoesNotThrow(() -> policy.checkAccess( URI.create("https://nist.gov/schemas/x.xml"))); - assertDoesNotThrow(() -> policy.checkAccess( - URI.create("file:///workspace/project/module.xml"))); } @Test @@ -530,7 +1098,8 @@ class ResourceAccessPolicyTest { .forScheme("file") .allow("**") .deny("**/.ssh/**") - .defaultDeny() + .fileProtections(FileProtections.disabled()) + .denyUnlistedSchemes() .build(); assertDoesNotThrow(() -> policy.checkAccess( @@ -541,11 +1110,11 @@ class ResourceAccessPolicyTest { } @Test - void testDefaultDenyBlocksUnknownSchemes() { + void testDenyUnlistedSchemesBlocksUnknown() { ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) .forScheme("https").allowAll() - .defaultDeny() + .denyUnlistedSchemes() .build(); assertThrows(AccessViolationException.class, @@ -557,202 +1126,210 @@ class ResourceAccessPolicyTest { ResourceAccessPolicy audit = ResourceAccessPolicy.builder() .mode(PolicyMode.AUDIT) .forScheme("http").denyAll() - .defaultDeny() + .denyUnlistedSchemes() .build(); - // Audit mode allows assertDoesNotThrow(() -> audit.checkAccess( URI.create("http://localhost/admin"))); - // Enforce mode blocks ResourceAccessPolicy enforced = audit.withMode(PolicyMode.ENFORCE); assertThrows(AccessViolationException.class, () -> enforced.checkAccess(URI.create("http://localhost/admin"))); } @Test - void testUriSchemeExtraction() { - ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + void testToBuilder() { + ResourceAccessPolicy original = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) - .forScheme("file") - .allow("/workspace/**") - .defaultDeny() + .forScheme("https").allow("nist.gov/**") + .denyUnlistedSchemes() .build(); - // file:///workspace/x → matches file scheme, path /workspace/x - assertDoesNotThrow(() -> policy.checkAccess( - URI.create("file:///workspace/x.xml"))); + ResourceAccessPolicy modified = original.toBuilder() + .forScheme("https").allow("github.com/**") + .build(); - // https not configured, default deny - assertThrows(AccessViolationException.class, - () -> policy.checkAccess(URI.create("https://example.com"))); + assertDoesNotThrow(() -> modified.checkAccess( + URI.create("https://nist.gov/x.xml"))); + assertDoesNotThrow(() -> modified.checkAccess( + URI.create("https://github.com/x.xml"))); } @Test - void testJarSchemeExtraction() { + void testExplainReturnsDecision() { ResourceAccessPolicy policy = ResourceAccessPolicy.builder() .mode(PolicyMode.ENFORCE) - .forScheme("jar") - .allow("/schema/**") - .defaultDeny() + .forScheme("https").allow("nist.gov/**") + .denyUnlistedSchemes() .build(); - // jar:file:///lib.jar!/schema/x.xsd → matches jar scheme, path /schema/x.xsd - assertDoesNotThrow(() -> policy.checkAccess( - URI.create("jar:file:///lib.jar!/schema/x.xsd"))); - } -} -``` + PolicyDecision allowed = policy.explain(URI.create("https://nist.gov/x.xml")); + assertTrue(allowed.isAllowed()); -**Implementation:** Main policy class that: -1. Extracts scheme from URI -2. Looks up `SchemePatternSet` for that scheme -3. Extracts scheme-specific match target from URI -4. Evaluates patterns -5. Applies mode behavior (log/block/ignore) - ---- - -### Task 1.7: Create FileProtections - -**Files:** -- Create: `core/src/main/java/dev/metaschema/core/model/policy/FileProtections.java` -- Test: `core/src/test/java/dev/metaschema/core/model/policy/FileProtectionsTest.java` - -**Test first:** - -```java -package dev.metaschema.core.model.policy; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import java.nio.file.Path; + PolicyDecision denied = policy.explain(URI.create("https://evil.com/x.xml")); + assertFalse(denied.isAllowed()); + assertNotNull(denied.getDenialReason()); + assertNotNull(denied.getLayer()); + assertNotNull(denied.getRemediation()); + assertFalse(denied.getEvaluationTrace().isEmpty()); + } -class FileProtectionsTest { + @Test + void testDescribeEffectiveRules() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("https").allow("nist.gov/**") + .forScheme("file").allow("/workspace/**") + .denyUnlistedSchemes() + .build(); - @TempDir - Path cwd; + String description = policy.describeEffectiveRules(); + assertNotNull(description); + assertTrue(description.contains("ENFORCE")); + assertTrue(description.contains("https")); + assertTrue(description.contains("file")); + } - @ParameterizedTest - @ValueSource(strings = { - "/etc/passwd", - "/proc/self/environ", - "/sys/kernel/debug", - "/dev/null", - "/root/.bashrc", - "/var/run/secrets/kubernetes.io/token", - "C:/Windows/System32/config/SAM", - }) - void testDefaultDeniesPathsOutsideSafeAreas(String path) { - FileProtections protections = FileProtections.withDefaults(cwd); - assertFalse(protections.isAllowed(path), - "Should deny (outside safe areas): " + path); + @Test + void testBundledDefaultsFactory() { + ResourceAccessPolicy defaults = ResourceAccessPolicy.bundledDefaults(); + assertNotNull(defaults); + // Bundled defaults are AUDIT mode — should not throw + assertDoesNotThrow(() -> defaults.checkAccess( + URI.create("https://example.com/test"))); } @Test - void testDefaultAllowsCwdSubtree() { - FileProtections protections = FileProtections.withDefaults(cwd); - String cwdPath = cwd.resolve("project/schema.xml").toString(); - assertTrue(protections.isAllowed(cwdPath), - "Should allow CWD subtree"); + void testDevelopmentFactory() { + ResourceAccessPolicy dev = ResourceAccessPolicy.development(); + assertNotNull(dev); + // Dev mode should allow localhost + assertDoesNotThrow(() -> dev.checkAccess( + URI.create("http://localhost/api"))); } @Test - void testDefaultDeniesSensitiveDotDirsInHome() { - // Home dir subtree is allowed, but sensitive dot-dirs are excluded - Path home = Path.of(System.getProperty("user.home")); - FileProtections protections = FileProtections.withDefaults(cwd); + void testDisabledFactory() { + ResourceAccessPolicy disabled = ResourceAccessPolicy.disabled(); + assertDoesNotThrow(() -> disabled.checkAccess( + URI.create("file:///etc/passwd"))); + } - String sshKey = home.resolve(".ssh/id_rsa").toString(); - assertFalse(protections.isAllowed(sshKey), - "Should deny ~/.ssh even though home is allowed"); + @Test + void testJarSchemeRecursiveCheck() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file").allow("/lib/**") + .forScheme("jar").allowAll() + .fileProtections(FileProtections.disabled()) + .denyUnlistedSchemes() + .build(); - String awsCreds = home.resolve(".aws/credentials").toString(); - assertFalse(protections.isAllowed(awsCreds), - "Should deny ~/.aws even though home is allowed"); + // jar: with file: inner URI pointing to allowed path + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("jar:file:///lib/app.jar!/schema/x.xsd"))); - String normalFile = home.resolve("projects/schema.xml").toString(); - assertTrue(protections.isAllowed(normalFile), - "Should allow normal files in home"); + // jar: with http: inner URI — http not configured, default deny + assertThrows(AccessViolationException.class, + () -> policy.checkAccess( + URI.create("jar:http://evil.com/mal.jar!/schema/x.xsd"))); } @Test - void testBuilderIncludeDefaults() { - FileProtections protections = FileProtections.builder(cwd) - .includeDefaults() - .allow("/opt/metaschema/**") + void testPathNormalizationPreventsTraversal() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file").allow("/workspace/**") + .fileProtections(FileProtections.disabled()) + .denyUnlistedSchemes() .build(); - String cwdFile = cwd.resolve("schema.xml").toString(); - assertTrue(protections.isAllowed(cwdFile)); // from defaults - assertTrue(protections.isAllowed("/opt/metaschema/x")); // custom addition - assertFalse(protections.isAllowed("/etc/passwd")); // not allowed + // Path traversal should be caught after normalization + assertThrows(AccessViolationException.class, + () -> policy.checkAccess( + URI.create("file:///workspace/../etc/passwd"))); } @Test - void testBuilderRemoveDefault() { - FileProtections protections = FileProtections.builder(cwd) - .includeDefaults() - .remove("/**") // remove home dir access + void testSchemeNormalization() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file").allow("/workspace/**") + .fileProtections(FileProtections.disabled()) + .denyUnlistedSchemes() .build(); - Path home = Path.of(System.getProperty("user.home")); - String homeFile = home.resolve("file.txt").toString(); - assertFalse(protections.isAllowed(homeFile)); // removed + // Uppercase scheme should still match + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("FILE:///workspace/schema.xml"))); + } - String cwdFile = cwd.resolve("file.txt").toString(); - assertTrue(protections.isAllowed(cwdFile)); // CWD still allowed + @Test + void testFileProtectionsConflictDetection() { + // /opt/data/ is outside CWD and home — should throw at build time + assertThrows(IllegalStateException.class, + () -> ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .forScheme("file") + .allow("/opt/data/**") + .denyUnlistedSchemes() + .build()); } @Test - void testBuilderFullyCustom() { - FileProtections protections = FileProtections.builder(cwd) - .allow("/opt/app/**") + void testEnabledNoPatternsUsesDeny() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.ENFORCE) + .denyUnlistedSchemes() .build(); - assertTrue(protections.isAllowed("/opt/app/schema.xml")); - assertFalse(protections.isAllowed("/etc/passwd")); - // CWD not included since we didn't call includeDefaults() - String cwdFile = cwd.resolve("file.txt").toString(); - assertFalse(protections.isAllowed(cwdFile)); + // No schemes configured — should use default-scheme-policy (deny) + assertThrows(AccessViolationException.class, + () -> policy.checkAccess(URI.create("https://example.com"))); } @Test - void testNoneAllowsEverything() { - FileProtections protections = FileProtections.none(); - assertTrue(protections.isAllowed("/etc/passwd")); - assertTrue(protections.isAllowed("/home/user/.ssh/key")); - } + void testImmutability() { + ResourceAccessPolicy policy = ResourceAccessPolicy.builder() + .mode(PolicyMode.AUDIT) + .forScheme("https").allow("nist.gov/**") + .build(); - @Test - void testDefaultPatternsAreInspectable() { - assertFalse(FileProtections.defaultAllowPatterns().isEmpty()); + ResourceAccessPolicy withEnforce = policy.withMode(PolicyMode.ENFORCE); + + // Original should not be affected + assertDoesNotThrow(() -> policy.checkAccess( + URI.create("https://evil.com"))); + // New instance should enforce + assertThrows(AccessViolationException.class, + () -> withEnforce.checkAccess(URI.create("https://evil.com"))); } } ``` -**Implementation:** `FileProtections` holds an ordered list of allow/deny patterns (with `!` negation) checked against file paths. Provides: -- `withDefaults(Path cwd)` — shipped allow patterns (CWD + home minus sensitive dot-dirs) -- `none()` — no protections (allows everything) -- `builder(Path cwd)` — customizable with `includeDefaults()`, `allow()`, `remove()` -- `defaultAllowPatterns()` — static method to inspect defaults -- `isAllowed(String path)` — check if a path is allowed +**Implementation:** +- `ResourceAccessPolicy` is a **final, immutable** class +- All internal collections are unmodifiable copies +- `checkAccess(URI)` — full evaluation pipeline (normalize → network check → file protections → scheme patterns → mode behavior) +- `explain(URI)` — same pipeline but returns `PolicyDecision` instead of throwing +- `withMode(PolicyMode)` — returns new instance with different mode +- `toBuilder()` — returns pre-populated builder +- Factory methods: `bundledDefaults()`, `development()`, `disabled()` +- `describeEffectiveRules()` — returns human-readable summary +- `ResourceAccessPolicyBuilder` uses nested `SchemeConfigBuilder` pattern +- `.forScheme()` twice for same scheme appends patterns +- `.build()` runs conflict detection (file scheme patterns vs FileProtections) --- -### Task 1.8: Add package-info.java +### Task 1.12: Add package-info.java **Files:** - Create: `core/src/main/java/dev/metaschema/core/model/policy/package-info.java` --- -### Task 1.9: Verify PR1 Build +### Task 1.13: Verify PR1 Build ```bash mvn -pl core clean install @@ -763,24 +1340,24 @@ mvn -pl core checkstyle:check ## PR2: Configuration Model and Bundled Defaults -**Goal:** Define the Metaschema configuration module, implement config loading, and ship bundled restrictive defaults. +**Goal:** Define the Metaschema configuration module, implement config loading with ratcheting, and ship bundled restrictive defaults. ### Task 2.1: Create Metaschema Configuration Module **Files:** -- Create: `core/src/main/metaschema/resource-access-policy_metaschema.yaml` +- Create: `core/src/main/metaschema/resource-access-policy-config_metaschema.yaml` -The Metaschema module definition for the resource access policy configuration model. See PRD for full module definition. +The Metaschema module definition for the resource access policy configuration model. See PRD for full module definition. Root assembly is `resource-access-policy-config` to avoid naming collision with the hand-written `ResourceAccessPolicy` class. --- ### Task 2.2: Configure Maven Code Generation **Files:** -- Modify: `core/pom.xml` (if needed - verify if metaschema-maven-plugin is already configured for `src/main/metaschema`) +- Modify: `core/pom.xml` (add dependency on `ipaddress` library, verify metaschema-maven-plugin config) Verify generated binding classes compile and contain expected fields: -- `ResourceAccessPolicy` (root assembly) +- `ResourceAccessPolicyConfig` (root assembly) - `SchemeConfig` (scheme configuration) - `Pattern` (access pattern field) @@ -811,7 +1388,7 @@ class BundledDefaultsTest { @Test void testDefaultModeIsAudit() { - // Audit mode: should log but not throw + // AUDIT mode: should log but not throw assertDoesNotThrow(() -> defaults.checkAccess( URI.create("http://localhost/admin"))); } @@ -820,7 +1397,6 @@ class BundledDefaultsTest { @ValueSource(strings = { "https://pages.nist.gov/schemas/x.xml", "https://example.com/api", - "file:///workspace/schema.xml", "jar:file:///lib.jar!/schema/x.xsd", }) void testDefaultAllowedInEnforceMode(String uriString) { @@ -852,35 +1428,25 @@ class BundledDefaultsTest { assertThrows(AccessViolationException.class, () -> enforced.checkAccess(URI.create(uriString))); } - - @ParameterizedTest - @ValueSource(strings = { - "file:///etc/passwd", - "file:///proc/self/environ", - "file:///home/user/.ssh/id_rsa", - "file:///home/user/.aws/credentials", - }) - void testDefaultDeniedFilePathsInEnforceMode(String uriString) { - ResourceAccessPolicy enforced = defaults.withMode(PolicyMode.ENFORCE); - assertThrows(AccessViolationException.class, - () -> enforced.checkAccess(URI.create(uriString))); - } } ``` -**Implementation:** Load bundled YAML from classpath resource and parse into `ResourceAccessPolicy`. - --- -### Task 2.4: Implement Configuration Loading +### Task 2.4: Implement Configuration Loading with Ratcheting **Files:** - Create: `core/src/main/java/dev/metaschema/core/model/policy/ResourceAccessPolicyLoader.java` - Test: `core/src/test/java/dev/metaschema/core/model/policy/ResourceAccessPolicyLoaderTest.java` -**Test first:** Verify loading from YAML, JSON, and XML config files. Verify configuration layering with merge semantics. - -**Implementation:** Uses `IBoundLoader` to load the generated binding classes, then converts to `ResourceAccessPolicy`. +**Test first:** Verify: +- Loading from YAML, JSON, and XML config files +- Configuration layering with merge semantics +- Ratchet enforcement (can only tighten, never loosen mode) +- `locked: true` prevents overrides +- `inherit: true` appends patterns instead of replacing +- Scheme name validation (warn on unrecognized schemes like "htps") +- YAML `!` pattern validation (detect unquoted `!` patterns) --- @@ -902,17 +1468,18 @@ mvn -pl core checkstyle:check **Files:** - Modify: `core/src/main/java/dev/metaschema/core/model/IModuleLoader.java` -Add method to set resource access policy: +Add method: ```java /** * Sets the resource access policy for this loader. *

* When set, all URIs resolved by this loader are checked against the policy - * before loading. + * before loading. Use {@link ResourceAccessPolicy#bundledDefaults()} for + * recommended defaults. * * @param policy - * the policy to enforce, or {@code null} to disable + * the policy to enforce, or {@code null} to disable policy checking */ void setResourceAccessPolicy(@Nullable IResourceAccessPolicy policy); ``` @@ -925,18 +1492,9 @@ void setResourceAccessPolicy(@Nullable IResourceAccessPolicy policy); - Modify: `core/src/main/java/dev/metaschema/core/model/AbstractModuleLoader.java` - Test: `core/src/test/java/dev/metaschema/core/model/AbstractModuleLoaderPolicyTest.java` -**Test first:** Verify module import URIs are checked against policy. +**Test first:** Verify module import URIs are checked against policy. Verify relative URIs are resolved to absolute before checking. -**Implementation:** Add policy field and check before URI resolution: - -```java -// In resolveImport or similar method: -URI resolvedResource = ObjectUtils.notNull(resource.resolve(importedResource)); -IResourceAccessPolicy policy = getResourceAccessPolicy(); -if (policy != null) { - policy.checkAccess(resolvedResource); -} -``` +**Implementation:** Add `volatile IResourceAccessPolicy` field. Check before URI resolution. Resolve relative URIs to absolute before calling `checkAccess()`. --- @@ -966,7 +1524,7 @@ if (policy != null) { - Modify: `databind/src/main/java/dev/metaschema/databind/io/xml/DefaultXmlDeserializer.java` - Test: `databind/src/test/java/dev/metaschema/databind/io/xml/DefaultXmlDeserializerPolicyTest.java` -**Test first:** Verify XML entity resolution URIs are checked against policy. +**Test first:** Verify XML entity resolution URIs are checked against policy. Document HTTP redirect re-checking requirement. --- @@ -980,34 +1538,54 @@ mvn clean install -PCI -Prelease ## PR4: CLI Integration and Documentation -**Goal:** Add CLI flags for policy mode control and documentation. +**Goal:** Add CLI flags for policy control, diagnostic commands, and documentation. -### Task 4.1: Add CLI Flags +### Task 4.1: Add Global CLI Flags **Files:** -- Modify: `metaschema-cli/src/main/java/dev/metaschema/cli/CLI.java` (or relevant command classes) - -Add flags: -- `--resource-policy-mode=` - Override enforcement mode -- `--resource-policy=` - Load custom policy configuration file +- Modify: `metaschema-cli/src/main/java/dev/metaschema/cli/commands/MetaschemaCommands.java` (shared options) +- Modify: Resource-loading commands (validate, validate-content, convert, generate-schema) to accept policy flags ---- +Add global flags available on all resource-loading commands: +- `--resource-policy-mode=` — Override enforcement mode +- `--resource-policy=` — Load custom policy configuration file ### Task 4.2: Add Environment Variable Support Support `METASCHEMA_RESOURCE_POLICY_MODE` environment variable for mode override. ---- +### Task 4.3: Create ResourcePolicyCommand (Parent Command) + +**Files:** +- Create: `metaschema-cli/src/main/java/dev/metaschema/cli/commands/resourcepolicy/ResourcePolicyCommand.java` +- Modify: `metaschema-cli/src/main/java/dev/metaschema/cli/commands/MetaschemaCommands.java` (register command) -### Task 4.3: Documentation +Create a new `AbstractParentCommand` with `dump` and `check` subcommands. Follows the same pattern as `MetapathCommand`. Register in `MetaschemaCommands.COMMANDS`. + +### Task 4.4: Implement `resource-policy dump` Subcommand + +**Files:** +- Create: `metaschema-cli/src/main/java/dev/metaschema/cli/commands/resourcepolicy/DumpSubcommand.java` + +`AbstractTerminalCommand` that prints the effective merged policy (after all config layers) as YAML to stdout. Uses `policy.describeEffectiveRules()`. Accepts `--resource-policy` and `--resource-policy-mode` flags. + +### Task 4.5: Implement `resource-policy check` Subcommand + +**Files:** +- Create: `metaschema-cli/src/main/java/dev/metaschema/cli/commands/resourcepolicy/CheckSubcommand.java` + +`AbstractTerminalCommand` that takes a URI as a positional argument, runs it through the policy, and prints the `PolicyDecision` evaluation trace. Uses `policy.explain(URI)`. Accepts `--resource-policy` and `--resource-policy-mode` flags. + +### Task 4.6: Documentation **Files:** - Update: Website documentation with resource access policy guide - Update: CLI help text +- Include: Migration guide (AUDIT → ENFORCE transition steps) +- Include: YAML `!` quoting warning +- Include: Explicit note that `!` means DENY (contrast with `.gitignore`) ---- - -### Task 4.4: Final Verification +### Task 4.7: Final Verification ```bash mvn clean install -PCI -Prelease @@ -1018,38 +1596,59 @@ mvn clean install -PCI -Prelease ## Completion Checklist **Phase 1: Policy Engine Core (PR1)** -- [ ] `PolicyMode` enum with DISABLED/AUDIT/ENFORCE -- [ ] `AccessViolationException` for ENFORCE mode -- [ ] `GlobMatcher` with `.gitignore`-style glob matching -- [ ] `SchemePatternSet` with ordered pattern evaluation and `!` negation +- [ ] `PolicyMode` enum with DISABLED/AUDIT/ENFORCE and `mostRestrictive()` +- [ ] `SymlinkPolicy` enum with FOLLOW/NOFOLLOW +- [ ] `CaseSensitivity` enum with SYSTEM_DEFAULT/CASE_SENSITIVE/CASE_INSENSITIVE +- [ ] `AccessViolationException` with structured fields (layer, reason, source, remediation) +- [ ] `GlobMatcher` with case sensitivity, possessive quantifiers, pattern length limit +- [ ] `UriNormalizer` with path normalization, percent-decoding, symlink resolution, scheme/host normalization, JAR parsing +- [ ] `NetworkSecurityChecker` with CIDR block matching via IP library, alternate encoding support +- [ ] `NetworkSecurityConfig` with builder and `allowLoopback()`, `allowCidr()` +- [ ] `SchemePatternSet` with ordered pattern evaluation, case sensitivity, updated empty-patterns semantics +- [ ] `PolicyDecision` and `EvaluationStep` for diagnostics - [ ] `IResourceAccessPolicy` interface -- [ ] `ResourceAccessPolicy` with builder -- [ ] `FileProtections` with defaults, builder, and customization API +- [ ] `ResourceAccessPolicy` (immutable) with builder, factory methods, `toBuilder()`, `explain()`, `describeEffectiveRules()` +- [ ] `FileProtections` with `disabled()` (renamed from `none()`), blanket dot-dir exclusion, CWD root warning, conflict detection - [ ] `package-info.java` +- [ ] IP boundary value tests for all private CIDR blocks +- [ ] Alternate IP encoding tests (decimal, hex, octal, shorthand, IPv4-mapped IPv6) +- [ ] Path traversal normalization tests +- [ ] Symlink traversal tests +- [ ] Case sensitivity tests +- [ ] ReDoS resistance tests +- [ ] JAR recursive checking tests - [ ] All tests passing **Phase 2: Configuration Model (PR2)** -- [ ] Metaschema module definition (`resource-access-policy_metaschema.yaml`) -- [ ] Maven code generation verified -- [ ] Bundled default policy (restrictive, audit mode) -- [ ] `ResourceAccessPolicyLoader` for config file loading -- [ ] Configuration layering with merge semantics +- [ ] Metaschema module (`resource-access-policy-config_metaschema.yaml`) +- [ ] Maven code generation verified (no naming collision) +- [ ] IP address library dependency added (`com.github.seancfoley:ipaddress`) +- [ ] Bundled default policy (restrictive, AUDIT mode) +- [ ] `ResourceAccessPolicyLoader` with ratchet enforcement, `locked` flag, `inherit` merge +- [ ] Scheme name validation (warn on unrecognized) +- [ ] YAML `!` pattern validation +- [ ] Pattern complexity limits (count, length) +- [ ] Configuration layering tests - [ ] All tests passing **Phase 3: Loader Integration (PR3)** - [ ] `IModuleLoader.setResourceAccessPolicy()` method -- [ ] `AbstractModuleLoader` policy integration +- [ ] `AbstractModuleLoader` policy integration (with relative URI resolution) - [ ] `DefaultBoundLoader` policy integration - [ ] `BindingConstraintLoader` policy integration - [ ] `DefaultXmlDeserializer` policy integration +- [ ] HTTP redirect re-checking documented as integration requirement - [ ] Integration tests for each loader type - [ ] All tests passing **Phase 4: CLI Integration (PR4)** - [ ] `--resource-policy-mode` CLI flag - [ ] `--resource-policy` CLI flag +- [ ] `ResourcePolicyCommand` parent command (extends `AbstractParentCommand`) +- [ ] `resource-policy dump` subcommand +- [ ] `resource-policy check ` subcommand - [ ] `METASCHEMA_RESOURCE_POLICY_MODE` env var -- [ ] Documentation +- [ ] Documentation (migration guide, YAML warnings, `!` semantics) - [ ] Full CI build passing **Final Verification:**