diff --git a/config_auditor/rules.py b/config_auditor/rules.py new file mode 100644 index 0000000..5718d1d --- /dev/null +++ b/config_auditor/rules.py @@ -0,0 +1,265 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional +from packaging import version +import re + + +@dataclass +class Issue: + severity: str + category: str + message: str + file: Path + line: Optional[int] = None + suggestion: Optional[str] = None + rule_id: Optional[str] = None + + +@dataclass +class RuleResult: + passed: bool + message: str + severity: str + suggestion: Optional[str] = None + + +class Rule(ABC): + @property + @abstractmethod + def rule_id(self) -> str: + pass + + @property + @abstractmethod + def severity(self) -> str: + pass + + @abstractmethod + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + pass + + +class DeprecatedPackageRule(Rule): + DEPRECATED_PACKAGES = { + "request": "requests", + "urllib2": "urllib.request", + "httplib": "http.client", + "StringIO": "io.StringIO", + } + + @property + def rule_id(self) -> str: + return "deprecated-package" + + @property + def severity(self) -> str: + return "critical" + + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + deps = data.get("dependencies", {}) + dev_deps = data.get("devDependencies", {}) + + all_deps = {**deps, **dev_deps} + + for pkg in all_deps: + if pkg in self.DEPRECATED_PACKAGES: + return RuleResult( + passed=False, + message=f"Deprecated package '{pkg}' found. Use '{self.DEPRECATED_PACKAGES[pkg]}' instead.", + severity=self.severity, + suggestion=f"Replace '{pkg}' with '{self.DEPRECATED_PACKAGES[pkg]}' in dependencies" + ) + + return RuleResult(passed=True, message="No deprecated packages found", severity="info") + + +class OutdatedVersionRule(Rule): + @property + def rule_id(self) -> str: + return "outdated-version" + + @property + def severity(self) -> str: + return "warning" + + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + deps = data.get("dependencies", {}) + dev_deps = data.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + + outdated = [] + for pkg, ver in all_deps.items(): + if ver and ver.startswith("^"): + try: + v = version.parse(ver[1:]) + if v.major == 0: + outdated.append(f"{pkg}@{ver} (major version 0, may have breaking changes)") + except Exception: + pass + + if outdated: + return RuleResult( + passed=False, + message=f"Potentially outdated dependencies: {', '.join(outdated)}", + severity=self.severity, + suggestion="Review these dependencies for stability concerns" + ) + + return RuleResult(passed=True, message="Dependencies appear up to date", severity="info") + + +class MissingTypeCheckingRule(Rule): + @property + def rule_id(self) -> str: + return "missing-type-checking" + + @property + def severity(self) -> str: + return "warning" + + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + if data.get("compilerOptions", {}).get("strict") is not True: + return RuleResult( + passed=False, + message="TypeScript strict mode is not enabled", + severity=self.severity, + suggestion='Set "compilerOptions": { "strict": true } in tsconfig.json' + ) + + return RuleResult(passed=True, message="TypeScript strict mode is enabled", severity="info") + + +class SecurityVulnerabilityRule(Rule): + VULNERABLE_PATTERNS = [ + (r"\"version\":\s*\"0\.\d+\.\d+\"", "Versions starting with 0.x may have security issues"), + (r"\"private\":\s*false", "Package should not be public if marked private"), + ] + + @property + def rule_id(self) -> str: + return "security-vulnerability" + + @property + def severity(self) -> str: + return "critical" + + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + import json + content = json.dumps(data) + + for pattern, message in self.VULNERABLE_PATTERNS: + if re.search(pattern, content): + return RuleResult( + passed=False, + message=message, + severity=self.severity, + suggestion="Review and update the configuration" + ) + + return RuleResult(passed=True, message="No obvious security vulnerabilities detected", severity="info") + + +class MissingScriptsRule(Rule): + @property + def rule_id(self) -> str: + return "missing-scripts" + + @property + def severity(self) -> str: + return "warning" + + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + scripts = data.get("scripts", {}) + + required_scripts = ["test", "build"] + missing = [s for s in required_scripts if s not in scripts] + + if missing: + return RuleResult( + passed=False, + message=f"Missing recommended scripts: {', '.join(missing)}", + severity=self.severity, + suggestion=f"Add scripts for: {', '.join(missing)}" + ) + + return RuleResult(passed=True, message="Required scripts are present", severity="info") + + +class PythonProjectMetaRule(Rule): + @property + def rule_id(self) -> str: + return "python-project-meta" + + @property + def severity(self) -> str: + return "info" + + def evaluate(self, data: Dict[str, Any], file_path: Path) -> RuleResult: + issues = [] + + if "tool" in data: + if "poetry" in data.get("tool", {}): + poetry = data["tool"]["poetry"] + if not poetry.get("name"): + issues.append("Missing poetry project name") + if not poetry.get("version"): + issues.append("Missing poetry project version") + elif "pytest" in data.get("tool", {}): + if not data["tool"]["pytest"].get("testpaths"): + issues.append("Missing pytest testpaths") + + if issues: + return RuleResult( + passed=False, + message=f"Python project issues: {', '.join(issues)}", + severity=self.severity, + suggestion="Add missing project metadata" + ) + + return RuleResult(passed=True, message="Python project metadata is complete", severity="info") + + +class RuleRegistry: + RULES = { + "json": [ + DeprecatedPackageRule(), + OutdatedVersionRule(), + MissingTypeCheckingRule(), + SecurityVulnerabilityRule(), + MissingScriptsRule(), + ], + "yaml": [ + SecurityVulnerabilityRule(), + ], + "toml": [ + PythonProjectMetaRule(), + ], + } + + def __init__(self): + self._rules = {k: list(v) for k, v in self.RULES.items()} + + def evaluate(self, format_type: str, data: Dict[str, Any], file_path: Path) -> List[Issue]: + issues = [] + rules = self._rules.get(format_type, []) + + for rule in rules: + result = rule.evaluate(data, file_path) + if not result.passed: + issues.append(Issue( + severity=result.severity, + category=rule.rule_id, + message=result.message, + file=file_path, + suggestion=result.suggestion, + rule_id=rule.rule_id + )) + + return issues + + def add_rule(self, format_type: str, rule: Rule): + if format_type not in self._rules: + self._rules[format_type] = [] + self._rules[format_type].append(rule)