Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ Please refer to the community [README](https://github.com/oscal-compass/communit

Our project welcomes external contributions. Please consult [contributing](https://oscal-compass.github.io/compliance-trestle/latest/contributing/mkdocs_contributing/) to get started.

## Security

For information about security features, best practices, and how to report security vulnerabilities, please see our [Security Policy](SECURITY.md).

## Code of Conduct

Participation in the OSCAL Compass community is governed by the [Code of Conduct](https://github.com/oscal-compass/community/blob/main/CODE_OF_CONDUCT.md).
Expand Down
111 changes: 111 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Security Policy

## Reporting Security Vulnerabilities

For information about how to report security vulnerabilities, please see the [OSCAL Compass Community Security Policy](https://github.com/oscal-compass/community/blob/main/SECURITY.md).

## Security Features

### SSRF (Server-Side Request Forgery) Protection

Compliance-trestle implements comprehensive SSRF protection when fetching remote OSCAL content via HTTPS or SFTP. This protection uses a **two-tier defense system** to prevent malicious actors from exploiting the fetching mechanism to access internal resources or cloud metadata endpoints.

#### Tier 1: Always Blocked (Zero Tolerance)

The following address ranges and endpoints are **always blocked** regardless of configuration, as they have zero legitimate use for OSCAL content fetching:

- **Loopback addresses**: `127.0.0.0/8` (IPv4), `::1/128` (IPv6)
- **Link-local addresses**: `169.254.0.0/16` (IPv4), `fe80::/10` (IPv6)
- **Cloud metadata endpoints**:
- `169.254.169.254` (AWS, Azure, GCP)
- `metadata.google.internal` (GCP)
- `metadata.azure.com` (Azure alternative)
- `100.100.100.200` (Alibaba Cloud)

These ranges are blocked to prevent:

- Access to localhost services
- Exploitation of cloud metadata endpoints to steal credentials
- Access to link-local services

#### Tier 2: Optionally Blocked (Configurable)

RFC 1918 private IP ranges are **allowed by default** to support legitimate use cases such as private GitLab instances or internal OSCAL repositories:

- `10.0.0.0/8`
- `172.16.0.0/12`
- `192.168.0.0/16`
- `fc00::/7` (IPv6 unique local)

**To block private IP ranges**, set the environment variable:

```bash
export TRESTLE_BLOCK_PRIVATE_IPS=true
```

When private IPs are allowed (default), trestle logs a warning when accessing them to maintain visibility.

#### Domain Allowlist (Optional)

For additional security, you can restrict fetching to specific domains by configuring an allowed domains list. When configured, only URLs from the specified domains will be permitted.

### Path Traversal Protection

Trestle implements multiple layers of path traversal protection:

1. **URL Path Validation**: Blocks `..` sequences in URL paths to prevent directory traversal
1. **Cache Path Validation**: Ensures cached files remain within the designated cache directory
1. **Workspace Boundary Enforcement**: Validates that local file operations stay within the trestle workspace
1. **Sensitive File Protection**: Blocks access to sensitive system files even when outside-workspace access is allowed:
- `/etc/passwd`, `/etc/shadow`, `/etc/group`, `/etc/sudoers`
- SSH keys (`.ssh/`)
- Cloud credentials (`.aws/`, `.docker/`, `.kube/`)
- System logs (`/var/log/`)
- Database files (`/var/lib/mysql/`)
- Windows system files (`C:\Windows\System32\`, credentials)
- Process information (`/proc/self/environ`)

### Scheme Restrictions

Only HTTPS and SFTP schemes are allowed for remote URLs. HTTP, FTP, and other protocols are rejected to ensure encrypted transport.

### Port Restrictions

By default, only standard ports are allowed:

- HTTPS: port 443
- SFTP: port 22

Non-standard ports are blocked unless explicitly configured.

## Security Best Practices

When using compliance-trestle to fetch remote OSCAL content:

1. **Use HTTPS URLs** from trusted sources
1. **Enable private IP blocking** (`TRESTLE_BLOCK_PRIVATE_IPS=true`) in production environments unless you specifically need to access private repositories
1. **Configure domain allowlists** when fetching from a known set of trusted domains
1. **Monitor logs** for warnings about private IP access
1. **Keep trestle updated** to receive the latest security fixes
1. **Review fetched content** before using it in production compliance workflows

## Security Testing

The SSRF and path traversal protections are comprehensively tested with 100% code coverage. Tests include:

- Blocking of all Tier 1 addresses and endpoints
- Configurable blocking of Tier 2 private ranges
- Path traversal attack vectors
- Sensitive file access attempts
- Real-world attack scenarios from security advisories

## Version History

- **v4.x**: Introduced two-tier SSRF protection system (GHSA-w76h-q7c6-jpjp fix)
- **v3.x and earlier**: Limited SSRF protection (vulnerable)

## References

- [GHSA-w76h-q7c6-jpjp](https://github.com/oscal-compass/compliance-trestle/security/advisories/GHSA-w76h-q7c6-jpjp) - SSRF vulnerability advisory
- [OWASP SSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
- [CWE-918: Server-Side Request Forgery (SSRF)](https://cwe.mitre.org/data/definitions/918.html)
112 changes: 111 additions & 1 deletion tests/trestle/core/commands/author/jinja_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@
import os
import pathlib
import shutil
from types import SimpleNamespace

import pytest

from _pytest.monkeypatch import MonkeyPatch

from tests.test_utils import execute_command_and_assert, setup_for_ssp

from trestle.core.commands.author.jinja import _number_captions
from trestle.common.err import TrestleError
from trestle.core.commands.author.jinja import JinjaCmd, _number_captions
from trestle.core.commands.author.ssp import SSPGenerate
from trestle.core.markdown.docs_markdown_node import DocsMarkdownNode

Expand Down Expand Up @@ -295,3 +299,109 @@ def test_jinja_with_template_only(
node1 = tree.get_node_for_key('# A')
node2 = tree.get_node_for_key('# C')
assert node1.subnodes[0].key == node2.subnodes[0].key


def test_jinja_path_traversal_protection(
testdata_dir: pathlib.Path, tmp_trestle_dir: pathlib.Path, monkeypatch: MonkeyPatch
) -> None:
"""Test that path traversal attacks are blocked in jinja command."""
from trestle.core.remote.security import PathSecurityValidator

# Test path validation directly to ensure 100% coverage of the validation code
# Test 1: Path traversal with ../ should fail
with pytest.raises(TrestleError) as exc_info:
output_file = tmp_trestle_dir / '../../../etc/passwd'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)
assert 'Path traversal blocked' in str(exc_info.value)

# Test 2: Path traversal with multiple ../ should fail
with pytest.raises(TrestleError) as exc_info:
output_file = tmp_trestle_dir / 'subdir/../../poc.txt'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)

# Test 3: Absolute path should fail
with pytest.raises(TrestleError) as exc_info:
output_file = pathlib.Path('/tmp/attack.md')
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)

# Test 4: Complex traversal should fail
with pytest.raises(TrestleError) as exc_info:
output_file = tmp_trestle_dir / 'a/b/c/../../../../etc/passwd'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)

# Test 5: Valid relative path should succeed
output_file = tmp_trestle_dir / 'output/valid.md'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) # Should not raise


def test_jinja_docs_profile_path_traversal_protection(tmp_trestle_dir: pathlib.Path) -> None:
"""Test that path traversal attacks are blocked in jinja docs-profile mode."""
from trestle.core.remote.security import PathSecurityValidator

# Test validation for multi-file output paths
# Test 1: Path traversal in output directory should fail
with pytest.raises(TrestleError) as exc_info:
output_file = tmp_trestle_dir / '../../../etc/ac-1.md'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)
assert 'Path traversal blocked' in str(exc_info.value)

# Test 2: Complex path traversal should fail
with pytest.raises(TrestleError) as exc_info:
output_file = tmp_trestle_dir / 'controls/../../tmp/ac-1.md'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)

# Test 3: Directory creation path traversal should fail
with pytest.raises(TrestleError) as exc_info:
group_dir = tmp_trestle_dir / '../../../etc/malicious'
PathSecurityValidator.validate_local_path(group_dir, tmp_trestle_dir)
assert 'Security violation' in str(exc_info.value)

# Test 4: Valid relative path should succeed
output_file = tmp_trestle_dir / 'controls_output/ac/ac-1.md'
PathSecurityValidator.validate_local_path(output_file, tmp_trestle_dir) # Should not raise


def test_render_template_does_not_recursively_evaluate_untrusted_data(tmp_path: pathlib.Path) -> None:
"""Test that rendered attacker-controlled data is not re-evaluated as Jinja."""
template_path = tmp_path / 'template.j2'
template_path.write_text('Title: {{ ssp.metadata.title }}', encoding='utf-8')

jinja_env = JinjaCmd._create_jinja_environment(tmp_path)
template = jinja_env.get_template(template_path.name)

lut = {
'ssp': SimpleNamespace(
metadata=SimpleNamespace(title="{{ namespace.__init__.__globals__.os.system('touch poc.txt') }}")
)
}

output = JinjaCmd.render_template(template, lut, tmp_path)

assert output.startswith('Title: {{ namespace.__init__.__globals__.os.system(')
assert 'touch poc.txt' in output
assert '{{' in output
assert '}}' in output
assert '&' in output
assert not (tmp_path / 'poc.txt').exists()


def test_render_template_supports_trusted_include(tmp_path: pathlib.Path) -> None:
"""Test that trusted template includes continue to work."""
include_path = tmp_path / 'partial.j2'
include_path.write_text('World', encoding='utf-8')

template_path = tmp_path / 'template.j2'
template_path.write_text("Hello {% include 'partial.j2' %}", encoding='utf-8')

jinja_env = JinjaCmd._create_jinja_environment(tmp_path)
template = jinja_env.get_template(template_path.name)

output = JinjaCmd.render_template(template, {}, tmp_path)

assert output == 'Hello World'
Loading
Loading