152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
from pathlib import Path
|
|
import ast
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
if __package__ in {None, ""}:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from scripts.run_secret_static_checks import run_secret_static_checks
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT_DIR = ROOT / "scripts"
|
|
|
|
FORBIDDEN_PATTERNS = (
|
|
"env " + "= env or " + "os.environ",
|
|
"env or " + "os.environ",
|
|
)
|
|
|
|
|
|
def run_script_static_checks(env=None, script_dir: Path = SCRIPT_DIR) -> dict:
|
|
env = os.environ if env is None else env
|
|
findings = []
|
|
for path in sorted(script_dir.glob("*.py")):
|
|
findings.extend(check_script_file(path))
|
|
findings.extend(run_secret_static_checks(env=env)["findings"])
|
|
return {
|
|
"passed": len(findings) == 0,
|
|
"script_count": len(list(script_dir.glob("*.py"))),
|
|
"findings": findings,
|
|
}
|
|
|
|
|
|
def check_script_file(path: Path) -> list[dict]:
|
|
findings = []
|
|
source = path.read_text(encoding="utf-8")
|
|
tree = None
|
|
try:
|
|
tree = ast.parse(source, filename=str(path))
|
|
except SyntaxError as exc:
|
|
findings.append(
|
|
{
|
|
"file": display_path(path),
|
|
"line": exc.lineno or 1,
|
|
"pattern": "python_syntax",
|
|
"reason": "script_python_syntax_error",
|
|
}
|
|
)
|
|
if tree is not None:
|
|
findings.extend(find_python39_incompatible_annotations(tree, path))
|
|
for line_number, line in enumerate(source.splitlines(), start=1):
|
|
for pattern in FORBIDDEN_PATTERNS:
|
|
if pattern in line:
|
|
findings.append(
|
|
{
|
|
"file": display_path(path),
|
|
"line": line_number,
|
|
"pattern": pattern,
|
|
"reason": forbidden_pattern_reason(pattern),
|
|
}
|
|
)
|
|
if tree is not None and has_main_function(tree) and "raise SystemExit(main(" not in source:
|
|
findings.append(
|
|
{
|
|
"file": display_path(path),
|
|
"line": 1,
|
|
"pattern": "raise SystemExit(main())",
|
|
"reason": "automation_entrypoint_must_preserve_exit_code",
|
|
}
|
|
)
|
|
return findings
|
|
|
|
|
|
def forbidden_pattern_reason(pattern: str) -> str:
|
|
return "empty_env_must_not_fall_back_to_ambient_environment"
|
|
|
|
|
|
def has_main_function(tree: ast.AST) -> bool:
|
|
return any(isinstance(node, ast.FunctionDef) and node.name == "main" for node in ast.walk(tree))
|
|
|
|
|
|
def find_python39_incompatible_annotations(tree: ast.AST, path: Path) -> list[dict]:
|
|
findings = []
|
|
|
|
def inspect_annotation(annotation) -> None:
|
|
if annotation is not None and has_pep604_optional_annotation(annotation):
|
|
findings.append(
|
|
{
|
|
"file": display_path(path),
|
|
"line": getattr(annotation, "lineno", 1),
|
|
"pattern": "PEP604 Optional",
|
|
"reason": "python39_incompatible_pep604_optional_annotation",
|
|
}
|
|
)
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
inspect_annotation(node.returns)
|
|
for arg in list(node.args.args) + list(node.args.kwonlyargs) + list(node.args.posonlyargs):
|
|
inspect_annotation(arg.annotation)
|
|
if node.args.vararg:
|
|
inspect_annotation(node.args.vararg.annotation)
|
|
if node.args.kwarg:
|
|
inspect_annotation(node.args.kwarg.annotation)
|
|
elif isinstance(node, ast.AnnAssign):
|
|
inspect_annotation(node.annotation)
|
|
return findings
|
|
|
|
|
|
def has_pep604_optional_annotation(annotation) -> bool:
|
|
if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
|
|
return is_none_annotation(annotation.left) or is_none_annotation(annotation.right)
|
|
return any(has_pep604_optional_annotation(child) for child in ast.iter_child_nodes(annotation))
|
|
|
|
|
|
def is_none_annotation(node) -> bool:
|
|
return isinstance(node, ast.Constant) and node.value is None
|
|
|
|
|
|
def display_path(path: Path) -> str:
|
|
try:
|
|
return str(path.relative_to(ROOT))
|
|
except ValueError:
|
|
return path.name
|
|
|
|
|
|
def main(env=None) -> int:
|
|
env = os.environ if env is None else env
|
|
result = run_script_static_checks(env=env)
|
|
if _get_bool(env, "SCRIPT_CHECKS_OUTPUT_JSON", False):
|
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
else:
|
|
print(f"Script static checks passed: {result['passed']}")
|
|
print(f"Script files checked: {result['script_count']}")
|
|
for finding in result["findings"]:
|
|
print(
|
|
f"{finding['file']}:{finding['line']} "
|
|
f"{finding['reason']} pattern={finding['pattern']}"
|
|
)
|
|
return 0 if result["passed"] else 2
|
|
|
|
|
|
def _get_bool(env, key: str, default: bool) -> bool:
|
|
value = env.get(key)
|
|
if value is None or value == "":
|
|
return default
|
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|