mirror of
https://github.com/trailofbits/algo.git
synced 2026-08-17 21:25:50 +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>
257 lines
8.6 KiB
Python
Executable File
257 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Validate all Jinja2 templates in the Algo codebase.
|
|
This script checks for:
|
|
1. Syntax errors (including inline comments in expressions)
|
|
2. Undefined variables
|
|
3. Common anti-patterns
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateSyntaxError, meta
|
|
|
|
|
|
def find_jinja2_templates(root_dir: str = ".") -> list[Path]:
|
|
"""Find all Jinja2 template files in the project."""
|
|
templates = []
|
|
patterns = ["**/*.j2", "**/*.jinja2", "**/*.yml.j2", "**/*.conf.j2"]
|
|
|
|
# Skip these directories
|
|
skip_dirs = {".git", ".venv", "venv", ".env", "configs", "__pycache__", ".cache"}
|
|
|
|
for pattern in patterns:
|
|
for path in Path(root_dir).glob(pattern):
|
|
# Skip if in a directory we want to ignore
|
|
if not any(skip_dir in path.parts for skip_dir in skip_dirs):
|
|
templates.append(path)
|
|
|
|
return sorted(templates)
|
|
|
|
|
|
def check_inline_comments_in_expressions(template_content: str, template_path: Path) -> list[str]:
|
|
"""
|
|
Check for inline comments (#) within Jinja2 expressions.
|
|
This is the error we just fixed in openssl.yml.
|
|
"""
|
|
errors = []
|
|
|
|
# Pattern to find Jinja2 expressions
|
|
jinja_pattern = re.compile(r"\{\{.*?\}\}|\{%.*?%\}", re.DOTALL)
|
|
|
|
for match in jinja_pattern.finditer(template_content):
|
|
expression = match.group()
|
|
lines = expression.split("\n")
|
|
|
|
for i, line in enumerate(lines):
|
|
# Check for # that's not in a string
|
|
# Simple heuristic: if # appears after non-whitespace and not in quotes
|
|
if "#" in line:
|
|
# Remove quoted strings to avoid false positives
|
|
cleaned = re.sub(r'"[^"]*"', "", line)
|
|
cleaned = re.sub(r"'[^']*'", "", cleaned)
|
|
|
|
if "#" in cleaned:
|
|
# Check if it's likely a comment (has text after it)
|
|
hash_pos = cleaned.index("#")
|
|
if hash_pos > 0 and cleaned[hash_pos - 1 : hash_pos] != "\\":
|
|
line_num = template_content[: match.start()].count("\n") + i + 1
|
|
errors.append(
|
|
f"{template_path}:{line_num}: Inline comment (#) found in Jinja2 expression. "
|
|
f"Move comments outside the expression."
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def check_undefined_variables(template_path: Path) -> list[str]:
|
|
"""
|
|
Parse template and extract all undefined variables.
|
|
This helps identify what variables need to be provided.
|
|
"""
|
|
errors = []
|
|
|
|
try:
|
|
with open(template_path) as f:
|
|
template_content = f.read()
|
|
|
|
env = Environment(undefined=StrictUndefined)
|
|
ast = env.parse(template_content)
|
|
undefined_vars = meta.find_undeclared_variables(ast)
|
|
|
|
# Common Ansible variables that are always available
|
|
ansible_builtins = {
|
|
"ansible_default_ipv4",
|
|
"ansible_default_ipv6",
|
|
"ansible_hostname",
|
|
"ansible_distribution",
|
|
"ansible_distribution_version",
|
|
"ansible_facts",
|
|
"inventory_hostname",
|
|
"hostvars",
|
|
"groups",
|
|
"group_names",
|
|
"play_hosts",
|
|
"ansible_version",
|
|
"ansible_user",
|
|
"ansible_host",
|
|
"item",
|
|
"ansible_loop",
|
|
"ansible_index",
|
|
"lookup",
|
|
}
|
|
|
|
# Filter out known Ansible variables
|
|
unknown_vars = undefined_vars - ansible_builtins
|
|
|
|
# Only report if there are truly unknown variables
|
|
if unknown_vars and len(unknown_vars) < 20: # Avoid noise from templates with many vars
|
|
errors.append(f"{template_path}: Uses undefined variables: {', '.join(sorted(unknown_vars))}")
|
|
|
|
except Exception:
|
|
# Don't report parse errors here, they're handled elsewhere
|
|
pass
|
|
|
|
return errors
|
|
|
|
|
|
def validate_template_syntax(template_path: Path) -> tuple[bool, list[str]]:
|
|
"""
|
|
Validate a single template for syntax errors.
|
|
Returns (is_valid, list_of_errors)
|
|
"""
|
|
errors = []
|
|
|
|
# Skip full parsing for templates that use Ansible-specific features heavily
|
|
# We still check for inline comments but skip full template parsing
|
|
ansible_specific_templates = {
|
|
"dnscrypt-proxy.toml.j2", # Uses |bool filter
|
|
"mobileconfig.j2", # Uses |to_uuid filter and complex item structures
|
|
"vpn-dict.j2", # Uses |to_uuid filter
|
|
}
|
|
|
|
if template_path.name in ansible_specific_templates:
|
|
# Still check for inline comments but skip full parsing
|
|
try:
|
|
with open(template_path) as f:
|
|
template_content = f.read()
|
|
errors.extend(check_inline_comments_in_expressions(template_content, template_path))
|
|
except Exception:
|
|
pass
|
|
return len(errors) == 0, errors
|
|
|
|
try:
|
|
with open(template_path) as f:
|
|
template_content = f.read()
|
|
|
|
# Check for inline comments first (our custom check)
|
|
errors.extend(check_inline_comments_in_expressions(template_content, template_path))
|
|
|
|
# Try to parse the template
|
|
env = Environment(loader=FileSystemLoader(template_path.parent), undefined=StrictUndefined)
|
|
|
|
# Add mock Ansible filters to avoid syntax errors
|
|
env.filters["bool"] = lambda x: x
|
|
env.filters["to_uuid"] = lambda x: x
|
|
env.filters["b64encode"] = lambda x: x
|
|
env.filters["b64decode"] = lambda x: x
|
|
env.filters["regex_replace"] = lambda x, y, z: x
|
|
env.filters["default"] = lambda x, d: x if x else d
|
|
|
|
# This will raise TemplateSyntaxError if there's a syntax problem
|
|
env.get_template(template_path.name)
|
|
|
|
# Also check for undefined variables (informational)
|
|
# Commenting out for now as it's too noisy, but useful for debugging
|
|
# errors.extend(check_undefined_variables(template_path))
|
|
|
|
except TemplateSyntaxError as e:
|
|
errors.append(f"{template_path}:{e.lineno}: Syntax error: {e.message}")
|
|
except UnicodeDecodeError:
|
|
errors.append(f"{template_path}: Unable to decode file (not UTF-8)")
|
|
except Exception as e:
|
|
errors.append(f"{template_path}: Error: {e!s}")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def check_common_antipatterns(template_path: Path) -> list[str]:
|
|
"""Check for common Jinja2 anti-patterns."""
|
|
warnings = []
|
|
|
|
try:
|
|
with open(template_path) as f:
|
|
content = f.read()
|
|
|
|
# Check for missing spaces around filters
|
|
if re.search(r"\{\{[^}]+\|[^ ]", content):
|
|
warnings.append(f"{template_path}: Missing space after filter pipe (|)")
|
|
|
|
# Check for deprecated 'when' in Jinja2 (should use if)
|
|
if re.search(r"\{%\s*when\s+", content):
|
|
warnings.append(f"{template_path}: Use 'if' instead of 'when' in Jinja2 templates")
|
|
|
|
# Check for extremely long expressions (harder to debug)
|
|
for match in re.finditer(r"\{\{(.+?)\}\}", content, re.DOTALL):
|
|
if len(match.group(1)) > 200:
|
|
line_num = content[: match.start()].count("\n") + 1
|
|
warnings.append(
|
|
f"{template_path}:{line_num}: Very long expression (>200 chars), consider breaking it up"
|
|
)
|
|
|
|
except Exception:
|
|
pass # Ignore errors in anti-pattern checking
|
|
|
|
return warnings
|
|
|
|
|
|
def main():
|
|
"""Main validation function."""
|
|
print("🔍 Validating Jinja2 templates in Algo...\n")
|
|
|
|
# Find all templates
|
|
templates = find_jinja2_templates()
|
|
print(f"Found {len(templates)} Jinja2 templates\n")
|
|
|
|
all_errors = []
|
|
all_warnings = []
|
|
valid_count = 0
|
|
|
|
# Validate each template
|
|
for template in templates:
|
|
is_valid, errors = validate_template_syntax(template)
|
|
warnings = check_common_antipatterns(template)
|
|
|
|
if is_valid:
|
|
valid_count += 1
|
|
else:
|
|
all_errors.extend(errors)
|
|
|
|
all_warnings.extend(warnings)
|
|
|
|
# Report results
|
|
print(f"✅ {valid_count}/{len(templates)} templates have valid syntax")
|
|
|
|
if all_errors:
|
|
print(f"\n❌ Found {len(all_errors)} errors:\n")
|
|
for error in all_errors:
|
|
print(f" ERROR: {error}")
|
|
|
|
if all_warnings:
|
|
print(f"\n⚠️ Found {len(all_warnings)} warnings:\n")
|
|
for warning in all_warnings:
|
|
print(f" WARN: {warning}")
|
|
|
|
if all_errors:
|
|
print("\n❌ Template validation FAILED")
|
|
return 1
|
|
else:
|
|
print("\n✅ All templates validated successfully!")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|