scripts/inventory_logging.py
scripts/inventory_logging.pyBrowse 5 files
2,640 tokens
11,946 bytes
Token encoding: o200k_base
Snapshot 1d17ca4
← Back to SKILL.md
1#!/usr/bin/env python32from __future__ import annotations3 4import argparse5import ast6import hashlib7import json8import re9import sys10from collections import Counter11from collections.abc import Iterable, Mapping, Sequence12from dataclasses import asdict, dataclass13from pathlib import Path14from typing import Any15 16LOG_METHODS = {17 "critical",18 "debug",19 "error",20 "exception",21 "fatal",22 "info",23 "log",24 "warn",25 "warning",26}27POLICY_HELPERS = {28 "log_model_action_debug",29 "log_model_action_error",30 "log_model_action_warning",31 "log_model_and_tool_action_debug",32 "log_model_and_tool_action_error",33 "log_model_and_tool_action_warning",34 "log_tool_action_debug",35 "log_tool_action_error",36 "log_tool_action_warning",37}38RAW_OUTPUT_METHODS = {39 "pp",40 "pprint",41 "print",42 "print_exc",43 "print_exception",44 "warn",45 "warn_explicit",46 "write",47 "writelines",48}49CALLBACK_KEYWORDS = {"callback", "handler"}50 51 52@dataclass(frozen=True)53class Candidate:54 fingerprint: str55 file: str56 line: int57 column: int58 kind: str59 method: str60 context: str61 call: str62 reason: str63 64 def to_dict(self) -> dict[str, Any]:65 return asdict(self)66 67 68def normalize_path(path: str | Path) -> str:69 return str(path).replace("\\", "/")70 71 72def collect_source_files(roots: Sequence[str | Path]) -> list[Path]:73 files: set[Path] = set()74 for root_value in roots:75 root = Path(root_value).resolve()76 if root.is_file():77 if root.suffix == ".py":78 files.add(root)79 continue80 if not root.is_dir():81 raise FileNotFoundError(f"Inventory root does not exist: {root_value}")82 for path in root.rglob("*.py"):83 relative_parts = path.relative_to(root).parts84 if any(part.startswith(".") or part == "__pycache__" for part in relative_parts):85 continue86 files.add(path.resolve())87 return sorted(files)88 89 90def normalize_node(node: ast.AST, source: str) -> str:91 segment = ast.get_source_segment(source, node)92 if segment is None:93 segment = ast.dump(node, annotate_fields=True, include_attributes=False)94 return re.sub(r"\s+", " ", segment).strip()95 96 97def dotted_name(node: ast.AST) -> str | None:98 if isinstance(node, ast.Name):99 return node.id100 if isinstance(node, ast.Attribute):101 receiver = dotted_name(node.value)102 return f"{receiver}.{node.attr}" if receiver else node.attr103 return None104 105 106def terminal_name(node: ast.AST) -> str | None:107 name = dotted_name(node)108 return name.rsplit(".", 1)[-1] if name else None109 110 111def make_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:112 return {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)}113 114 115def scope_context(node: ast.AST, parents: Mapping[ast.AST, ast.AST]) -> str:116 parts: list[str] = []117 current = parents.get(node)118 while current is not None:119 if isinstance(current, ast.ClassDef):120 parts.append(f"class:{current.name}")121 elif isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef):122 parts.append(f"function:{current.name}")123 elif isinstance(current, ast.Lambda):124 parts.append("lambda")125 current = parents.get(current)126 return ">".join(reversed(parts)) or "<module>"127 128 129def callback_arguments(call: ast.Call) -> Iterable[tuple[ast.AST, str | None]]:130 yield from ((argument, None) for argument in call.args)131 yield from (132 (keyword.value, keyword.arg) for keyword in call.keywords if keyword.arg is not None133 )134 135 136def looks_like_callback(node: ast.AST, keyword: str | None) -> bool:137 method = terminal_name(node)138 if method not in LOG_METHODS:139 return False140 if keyword is not None and (141 keyword.startswith("on_")142 or keyword.endswith(("_callback", "_handler"))143 or keyword in CALLBACK_KEYWORDS144 ):145 return True146 if not isinstance(node, ast.Attribute):147 return False148 receiver = dotted_name(node.value)149 receiver_name = receiver.rsplit(".", 1)[-1].lower() if receiver else ""150 return receiver_name in {"log", "logger"} or receiver_name.endswith(("_log", "_logger"))151 152 153def selected_getattr_method(call: ast.Call) -> str | None:154 if terminal_name(call.func) != "getattr" or len(call.args) < 2:155 return None156 attribute = call.args[1]157 if not isinstance(attribute, ast.Constant) or not isinstance(attribute.value, str):158 return None159 if attribute.value in LOG_METHODS | RAW_OUTPUT_METHODS:160 return attribute.value161 return None162 163 164def classify_call(call: ast.Call) -> tuple[str, str, str] | None:165 qualified_method = dotted_name(call.func)166 method = terminal_name(call.func)167 if method in POLICY_HELPERS:168 return (169 "policy-helper-call",170 method,171 "Known redaction helper; review the caller's data classification and fixed message.",172 )173 if method in LOG_METHODS and not (174 method == "warn" and qualified_method in {"warn", "warnings.warn"}175 ):176 return (177 "logging-call-candidate",178 method,179 "Logging-like method name; inspect the receiver and every attached value.",180 )181 if method in RAW_OUTPUT_METHODS:182 return (183 "raw-output-call-candidate",184 method,185 "Direct-output method name; verify its destination and whether values "186 "can be sensitive.",187 )188 selected = selected_getattr_method(call)189 if selected is not None:190 return (191 "getattr-sink-candidate",192 selected,193 "Constant getattr selects an output-like method; trace the receiver and later uses.",194 )195 return None196 197 198def inventory_source(source: str, file_path: str = "fixture.py") -> list[Candidate]:199 normalized_path = normalize_path(file_path)200 tree = ast.parse(source, filename=normalized_path)201 parents = make_parent_map(tree)202 candidates: list[Candidate] = []203 recorded: set[tuple[int, str, str]] = set()204 205 def record(node: ast.AST, kind: str, method: str, call: str, reason: str) -> None:206 key = (id(node), kind, method)207 if key in recorded:208 return209 recorded.add(key)210 line = getattr(node, "lineno", 1)211 column = getattr(node, "col_offset", 0) + 1212 context = scope_context(node, parents)213 fingerprint = hashlib.sha256(214 f"{normalized_path}\0{line}\0{column}\0{kind}\0{method}\0{call}".encode()215 ).hexdigest()[:12]216 candidates.append(217 Candidate(218 fingerprint=fingerprint,219 file=normalized_path,220 line=line,221 column=column,222 kind=kind,223 method=method,224 context=context,225 call=call,226 reason=reason,227 )228 )229 230 for node in ast.walk(tree):231 if not isinstance(node, ast.Call):232 continue233 classification = classify_call(node)234 if classification is not None:235 kind, method, reason = classification236 record(node, kind, method, normalize_node(node, source), reason)237 for argument, keyword in callback_arguments(node):238 if not looks_like_callback(argument, keyword):239 continue240 method = terminal_name(argument)241 if method is None:242 continue243 record(244 argument,245 "logging-callback-candidate",246 method,247 normalize_node(argument, source),248 "Logging-like callable passed to a callback-shaped argument; inspect "249 "registration and payloads.",250 )251 252 candidates.sort(key=lambda item: (item.file, item.line, item.column, item.kind, item.method))253 return candidates254 255 256def summarize(candidates: Sequence[Candidate]) -> dict[str, int]:257 kinds = Counter(candidate.kind for candidate in candidates)258 return {259 "totalCandidates": len(candidates),260 "loggingCalls": kinds["logging-call-candidate"],261 "rawOutputCalls": kinds["raw-output-call-candidate"],262 "policyHelperCalls": kinds["policy-helper-call"],263 "getattrSelections": kinds["getattr-sink-candidate"],264 "callbackReferences": kinds["logging-callback-candidate"],265 }266 267 268def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:269 parser = argparse.ArgumentParser(270 description=(271 "Collect syntactic Python logging and raw-output candidates for manual review."272 )273 )274 parser.add_argument("roots", nargs="*", default=["src/agents"])275 parser.add_argument("--format", choices=("json", "markdown"), default="markdown")276 parser.add_argument("--summary-only", action="store_true")277 parser.add_argument("--output", type=Path)278 return parser.parse_args(argv)279 280 281def build_report(args: argparse.Namespace) -> dict[str, Any]:282 cwd = Path.cwd().resolve()283 candidates: list[Candidate] = []284 for path in collect_source_files(args.roots):285 try:286 display_path = path.relative_to(cwd)287 except ValueError:288 display_path = path289 source = path.read_text(encoding="utf-8")290 try:291 candidates.extend(inventory_source(source, str(display_path)))292 except SyntaxError as error:293 raise SyntaxError(294 f"Failed to parse {display_path}:{error.lineno}: {error.msg}"295 ) from error296 297 report: dict[str, Any] = {298 "contract": (299 "Syntactic candidates only. Manual review and runtime tests are required; "300 "absence from this report is not proof of safety."301 ),302 "summary": summarize(candidates),303 }304 if not args.summary_only:305 report["candidates"] = [candidate.to_dict() for candidate in candidates]306 return report307 308 309def render_markdown(report: Mapping[str, Any], summary_only: bool) -> str:310 summary = report["summary"]311 lines = [312 "# Sensitive logging candidates",313 "",314 f"> {report['contract']}",315 "",316 f"- Total candidates: {summary['totalCandidates']}",317 f"- Logging calls: {summary['loggingCalls']}",318 f"- Raw-output calls: {summary['rawOutputCalls']}",319 f"- Policy-helper calls: {summary['policyHelperCalls']}",320 f"- Constant getattr selections: {summary['getattrSelections']}",321 f"- Callback references: {summary['callbackReferences']}",322 ]323 if not summary_only:324 lines.extend(325 [326 "",327 "| Location | Kind | Method | Context | Fingerprint |",328 "| --- | --- | --- | --- | --- |",329 ]330 )331 for candidate in report.get("candidates", []):332 location = f"{candidate['file']}:{candidate['line']}"333 lines.append(334 f"| {location} | {candidate['kind']} | {candidate['method']} | "335 f"{candidate['context']} | {candidate['fingerprint']} |"336 )337 return "\n".join(lines) + "\n"338 339 340def main(argv: Sequence[str] | None = None) -> int:341 args = parse_args(argv)342 try:343 report = build_report(args)344 output = (345 json.dumps(report, indent=2, sort_keys=True) + "\n"346 if args.format == "json"347 else render_markdown(report, args.summary_only)348 )349 if args.output:350 args.output.write_text(output, encoding="utf-8")351 else:352 sys.stdout.write(output)353 return 0354 except (OSError, SyntaxError, ValueError, json.JSONDecodeError) as error:355 print(f"Sensitive logging candidate collection failed: {error}", file=sys.stderr)356 return 1357 358 359if __name__ == "__main__":360 raise SystemExit(main())361