mirror of
https://github.com/trailofbits/algo.git
synced 2026-08-23 16:12:30 +02:00
* ci: modernize tooling with prek, ty, and security scanning Migrate from pre-commit to prek (Rust-native, faster hooks) and add comprehensive CI improvements for code quality and security. Changes: - Replace pre-commit with prek for git hooks - Add ty type checker (Rust-based, replaces mypy) - Expand ruff rules: security (S), simplify (SIM), commented code (ERA) - Add pip-audit workflow for Python dependency CVE scanning - Add actionlint and zizmor for GitHub Actions linting/security - Add ruff format check to CI - Enable stricter ansible-lint rules (no-changed-when, risky-file-permissions) - Remove obsolete Claude workflow files - Apply ruff formatting fixes to test files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): resolve actionlint install and ty type errors - Use actionlint's official install script instead of broken URL pattern - Exclude test mock modules from ty type checking - Run workflows on push only for main/master to avoid duplicate PR runs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): use glob pattern for actionlint, exclude all tests from ty - actionlint requires *.yml glob, not directory path - Exclude all tests from ty type checking (test code has looser typing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): quote shell variables to fix shellcheck warnings Fix SC2046/SC2086 warnings in workflow scripts: - Quote $(uname -r) in apt-get install - Quote $(pwd) in docker volume mount - Quote $existing in gh issue comment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ci): move key-order[task] to warn_list Too many existing violations in the codebase to enable as error. Move to warn_list for gradual fixes over time. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
313 lines
13 KiB
Python
313 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test suite to prevent Ansible 12+ boolean type errors in Algo VPN codebase.
|
|
|
|
Background:
|
|
-----------
|
|
Ansible 12.0.0 introduced strict boolean type checking that breaks deployments
|
|
when string values like "true" or "false" are used in conditionals. This causes
|
|
errors like: "Conditional result (True) was derived from value of type 'str'"
|
|
|
|
What This Test Protects Against:
|
|
---------------------------------
|
|
1. String literals "true"/"false" being used instead of actual booleans
|
|
2. Bare true/false in Jinja2 else clauses (should be {{ true }}/{{ false }})
|
|
3. String comparisons in when: conditions (e.g., var == "true")
|
|
4. Variables being set to string booleans instead of actual booleans
|
|
|
|
Test Scope:
|
|
-----------
|
|
- Only tests Algo's own code (roles/, playbooks/, etc.)
|
|
- Excludes external dependencies (.env/, ansible_collections/)
|
|
- Excludes CloudFormation templates which require string booleans
|
|
- Excludes test files which may use different patterns
|
|
|
|
Mutation Testing Verified:
|
|
--------------------------
|
|
All tests have been verified to catch their target issues through mutation testing:
|
|
- Introducing bare 'false' in else clause → caught by test_no_bare_false_in_jinja_else
|
|
- Using string boolean in facts.yml → caught by test_verify_our_fixes_are_correct
|
|
- Adding string boolean assignments → caught by test_no_other_problematic_patterns
|
|
|
|
Related Issues:
|
|
---------------
|
|
- PR #14834: Fixed initial boolean type issues for Ansible 12
|
|
- Issue #14835: Fixed double-templating issues exposed by Ansible 12
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
class TestComprehensiveBooleanScan:
|
|
"""Scan entire codebase for potential string boolean issues."""
|
|
|
|
def get_yaml_files(self):
|
|
"""Get all YAML files in the Algo project, excluding external dependencies."""
|
|
root = Path(__file__).parent.parent.parent
|
|
yaml_files = []
|
|
|
|
# Define directories to scan (Algo's actual code)
|
|
algo_dirs = [
|
|
"roles",
|
|
"playbooks",
|
|
"library",
|
|
"files/cloud-init", # Include cloud-init templates but not CloudFormation
|
|
]
|
|
|
|
# Add root-level YAML files
|
|
yaml_files.extend(root.glob("*.yml"))
|
|
yaml_files.extend(root.glob("*.yaml"))
|
|
|
|
# Add YAML files from Algo directories
|
|
for dir_name in algo_dirs:
|
|
dir_path = root / dir_name
|
|
if dir_path.exists():
|
|
yaml_files.extend(dir_path.glob("**/*.yml"))
|
|
yaml_files.extend(dir_path.glob("**/*.yaml"))
|
|
|
|
# Exclude patterns
|
|
excluded = [
|
|
".venv", # Virtual environment
|
|
".env", # Another virtual environment pattern
|
|
"venv", # Yet another virtual environment
|
|
"test", # Test files (but keep our own tests)
|
|
"molecule", # Molecule test files
|
|
"site-packages", # Python packages
|
|
"ansible_collections", # External Ansible collections
|
|
"stack.yaml", # CloudFormation templates (use string booleans by design)
|
|
"stack.yml", # CloudFormation templates
|
|
".git", # Git directory
|
|
"__pycache__", # Python cache
|
|
]
|
|
|
|
# Filter out excluded paths and CloudFormation templates
|
|
filtered = []
|
|
for f in yaml_files:
|
|
path_str = str(f)
|
|
# Skip if path contains any excluded pattern
|
|
if any(exc in path_str for exc in excluded):
|
|
continue
|
|
# Skip CloudFormation templates in files/ directories
|
|
if "/files/" in path_str and f.name in ["stack.yaml", "stack.yml"]:
|
|
continue
|
|
filtered.append(f)
|
|
|
|
return filtered
|
|
|
|
def test_no_string_true_false_in_set_fact(self):
|
|
"""Scan all YAML files for set_fact with string 'true'/'false'."""
|
|
issues = []
|
|
pattern = re.compile(r'set_fact:.*?\n.*?:\s*".*\}(true|false)\{.*"', re.MULTILINE | re.DOTALL)
|
|
|
|
for yaml_file in self.get_yaml_files():
|
|
with open(yaml_file) as f:
|
|
content = f.read()
|
|
|
|
matches = pattern.findall(content)
|
|
if matches:
|
|
issues.append(f"{yaml_file.name}: Found string boolean in set_fact: {matches}")
|
|
|
|
assert not issues, "Found string booleans in set_fact:\n" + "\n".join(issues)
|
|
|
|
def test_no_bare_false_in_jinja_else(self):
|
|
"""Check for bare 'false' after else in Jinja expressions."""
|
|
issues = []
|
|
# Pattern for {%- else %}false{% (should be {{ false }})
|
|
pattern = re.compile(r"\{%-?\s*else\s*%\}(true|false)\{%")
|
|
|
|
for yaml_file in self.get_yaml_files():
|
|
with open(yaml_file) as f:
|
|
content = f.read()
|
|
|
|
matches = pattern.findall(content)
|
|
if matches:
|
|
issues.append(f"{yaml_file.name}: Found bare '{matches[0]}' after else")
|
|
|
|
assert not issues, "Found bare true/false in else clauses:\n" + "\n".join(issues)
|
|
|
|
def test_when_conditions_use_booleans(self):
|
|
"""Verify 'when:' conditions that use our variables."""
|
|
boolean_vars = [
|
|
"ipv6_support",
|
|
"algo_dns_adblocking",
|
|
"algo_ssh_tunneling",
|
|
"algo_ondemand_cellular",
|
|
"algo_ondemand_wifi",
|
|
"algo_store_pki",
|
|
]
|
|
|
|
potential_issues = []
|
|
|
|
for yaml_file in self.get_yaml_files():
|
|
with open(yaml_file) as f:
|
|
lines = f.readlines()
|
|
|
|
for i, line in enumerate(lines):
|
|
if "when:" in line:
|
|
for var in boolean_vars:
|
|
if var in line:
|
|
# Check if it's a simple condition (good) or comparing to string (bad)
|
|
if (
|
|
f'{var} == "true"' in line
|
|
or f'{var} == "false"' in line
|
|
or f'{var} != "true"' in line
|
|
or f'{var} != "false"' in line
|
|
):
|
|
potential_issues.append(
|
|
f"{yaml_file.name}:{i + 1}: Comparing {var} to string in when condition"
|
|
)
|
|
|
|
assert not potential_issues, "Found string comparisons in when conditions:\n" + "\n".join(potential_issues)
|
|
|
|
def test_template_files_boolean_usage(self):
|
|
"""Check Jinja2 template files for boolean usage."""
|
|
root = Path(__file__).parent.parent.parent
|
|
template_files = list(root.glob("**/*.j2"))
|
|
|
|
issues = []
|
|
|
|
for template_file in template_files:
|
|
if ".venv" in str(template_file):
|
|
continue
|
|
|
|
with open(template_file) as f:
|
|
content = f.read()
|
|
|
|
# Check for conditionals using our boolean variables
|
|
if "ipv6_support" in content:
|
|
# Look for string comparisons
|
|
if 'ipv6_support == "true"' in content or 'ipv6_support == "false"' in content:
|
|
issues.append(f"{template_file.name}: Comparing ipv6_support to string")
|
|
|
|
# Check it's used correctly in if statements
|
|
if re.search(r'{%\s*if\s+ipv6_support\s*==\s*["\']true["\']', content):
|
|
issues.append(f"{template_file.name}: String comparison with ipv6_support")
|
|
|
|
assert not issues, "Found issues in template files:\n" + "\n".join(issues)
|
|
|
|
def test_all_when_conditions_would_work(self):
|
|
"""Test that all when: conditions in the codebase would work with boolean types."""
|
|
root = Path(__file__).parent.parent.parent
|
|
test_files = [
|
|
root / "roles/common/tasks/iptables.yml",
|
|
root / "server.yml",
|
|
root / "users.yml",
|
|
root / "roles/dns/tasks/main.yml",
|
|
]
|
|
|
|
for test_file in test_files:
|
|
if not test_file.exists():
|
|
continue
|
|
|
|
with open(test_file) as f:
|
|
content = f.read()
|
|
|
|
# Find all when: conditions
|
|
when_lines = re.findall(r"when:\s*([^\n]+)", content)
|
|
|
|
for when_line in when_lines:
|
|
# Check if it's using one of our boolean variables
|
|
if any(var in when_line for var in ["ipv6_support", "algo_dns_adblocking", "algo_ssh_tunneling"]):
|
|
# Ensure it's not comparing to strings
|
|
assert '"true"' not in when_line, f"String comparison in {test_file.name}: {when_line}"
|
|
assert '"false"' not in when_line, f"String comparison in {test_file.name}: {when_line}"
|
|
assert "'true'" not in when_line, f"String comparison in {test_file.name}: {when_line}"
|
|
assert "'false'" not in when_line, f"String comparison in {test_file.name}: {when_line}"
|
|
|
|
def test_no_other_problematic_patterns(self):
|
|
"""Look for patterns that would cause Ansible 12 boolean type issues in Algo code."""
|
|
# These patterns would break Ansible 12's strict boolean checking
|
|
problematic_patterns = [
|
|
(r':\s*["\']true["\']$', "Assigning string 'true' to variable"),
|
|
(r':\s*["\']false["\']$', "Assigning string 'false' to variable"),
|
|
(r'default\(["\']true["\']\)', "Using string 'true' as default"),
|
|
(r'default\(["\']false["\']\)', "Using string 'false' as default"),
|
|
]
|
|
|
|
# Known safe exceptions in Algo
|
|
safe_patterns = [
|
|
"booleans_map", # This maps string inputs to booleans
|
|
"test_", # Test files may use different patterns
|
|
"molecule", # Molecule tests
|
|
"ANSIBLE_", # Environment variables are strings
|
|
"validate_certs", # Some modules accept string booleans
|
|
"Default:", # CloudFormation parameter defaults
|
|
]
|
|
|
|
issues = []
|
|
|
|
for yaml_file in self.get_yaml_files():
|
|
# Skip files that aren't Ansible playbooks/tasks/vars
|
|
parts_to_check = ["tasks", "vars", "defaults", "handlers", "meta", "playbooks"]
|
|
main_files = ["main.yml", "users.yml", "server.yml", "input.yml"]
|
|
if not any(part in str(yaml_file) for part in parts_to_check) and yaml_file.name not in main_files:
|
|
continue
|
|
|
|
with open(yaml_file) as f:
|
|
lines = f.readlines()
|
|
|
|
for i, line in enumerate(lines):
|
|
# Skip comments and empty lines
|
|
stripped_line = line.strip()
|
|
if not stripped_line or stripped_line.startswith("#"):
|
|
continue
|
|
|
|
for pattern, description in problematic_patterns:
|
|
if re.search(pattern, line):
|
|
# Check if it's a known safe pattern
|
|
if not any(safe in line for safe in safe_patterns):
|
|
# This is a real issue that would break Ansible 12
|
|
rel_path = yaml_file.relative_to(Path(__file__).parent.parent.parent)
|
|
issues.append(f"{rel_path}:{i + 1}: {description} - {stripped_line}")
|
|
|
|
# All Algo code should be fixed
|
|
assert not issues, "Found boolean type issues that would break Ansible 12:\n" + "\n".join(issues[:10])
|
|
|
|
def test_verify_our_fixes_are_correct(self):
|
|
"""Verify our specific fixes are in place and correct."""
|
|
# Check facts.yml
|
|
facts_file = Path(__file__).parent.parent.parent / "roles/common/tasks/facts.yml"
|
|
with open(facts_file) as f:
|
|
content = f.read()
|
|
|
|
# Should use 'is defined', not string literals
|
|
assert "is defined" in content, "facts.yml should use 'is defined'"
|
|
old_pattern = "ipv6_support: \"{% if ansible_default_ipv6['gateway'] is defined %}"
|
|
old_pattern += 'true{% else %}false{% endif %}"'
|
|
assert old_pattern not in content, "facts.yml still has the old string boolean pattern"
|
|
|
|
# Check input.yml
|
|
input_file = Path(__file__).parent.parent.parent / "input.yml"
|
|
with open(input_file) as f:
|
|
content = f.read()
|
|
|
|
# Count occurrences of the fix
|
|
assert content.count("{{ false }}") >= 5, "input.yml should have at least 5 instances of {{ false }}"
|
|
assert "{%- else %}false{% endif %}" not in content, "input.yml still has bare 'false'"
|
|
|
|
def test_templates_handle_booleans_correctly(self):
|
|
"""Test that template files handle boolean variables correctly."""
|
|
templates_to_check = [
|
|
("roles/wireguard/templates/server.conf.j2", "ipv6_support"),
|
|
("roles/strongswan/templates/ipsec.conf.j2", "ipv6_support"),
|
|
("roles/dns/templates/dnscrypt-proxy.toml.j2", "ipv6_support"),
|
|
]
|
|
|
|
for template_path, var_name in templates_to_check:
|
|
template_file = Path(__file__).parent.parent.parent / template_path
|
|
if not template_file.exists():
|
|
continue
|
|
|
|
with open(template_file) as f:
|
|
content = f.read()
|
|
|
|
if var_name in content:
|
|
# Verify it's used in conditionals, not compared to strings
|
|
assert f'{var_name} == "true"' not in content, f"{template_path} compares {var_name} to string 'true'"
|
|
assert f'{var_name} == "false"' not in content, f"{template_path} compares {var_name} to string 'false'"
|
|
|
|
# It should be used directly in if statements or with | bool filter
|
|
if f"if {var_name}" in content or f"{var_name} |" in content:
|
|
pass # Good - using it as a boolean
|