"""Behaviour records, the report container and the compact text rendering. A behaviour says what code does (kind, where, concrete operand). It never says whether that is acceptable; that judgement belongs to layer 2, which sees the task and the repository's normal behaviour. """ from __future__ import annotations from dataclasses import dataclass, field SCHEMA = "behaviour/1" # Order is the order of the text summary. KINDS = ( "install_or_startup_hook", "process_spawn", "network_egress", "download_then_exec", "decode_then_exec", "dynamic_import_or_eval", "env_read", "secret_or_credential_path", "file_write", "file_delete", "persistence", "security_setting_change", "obfuscation_markers", "archive_or_bundle", "binary_files", ) KIND_HELP = { "install_or_startup_hook": "code that runs automatically: npm lifecycle scripts, setup.py/cmdclass, build hooks, " ".pth/sitecustomize, conftest.py and pytest hooks, import-time calls, CI triggers", "process_spawn": "starts a process or shell command", "network_egress": "opens a network connection or request (host/URL if literal, else dynamic)", "download_then_exec": "content obtained from the network reaches eval/exec/import or a shell", "decode_then_exec": "decoded data (base64/hex/charcode/zlib) reaches eval/exec/Function/require or a shell", "dynamic_import_or_eval": "eval/exec/compile/Function/vm, non-literal import/require, unsafe deserialisation", "env_read": "reads environment variables (names; '*' = enumerates all)", "secret_or_credential_path": "references credential stores or secrets (~/.ssh, ~/.npmrc, ~/.aws, .env, keychain, " "CI secrets, token literals)", "file_write": "writes, copies, moves, links or chmods files (scope: workspace/home/system/temp/unknown)", "file_delete": "deletes files or directories", "persistence": "touches locations that make code run later: shell rc, cron, git hooks, systemd/launchd, " "autostart, sitecustomize/.pth", "security_setting_change": "TLS verification off, registry/index change, privilege or permission grants", "obfuscation_markers": "_0x identifiers, long encoded blobs, packed code, minified lines, bidi/zero-width chars", "archive_or_bundle": "archive or bundle files (size) and code that creates/extracts archives", "binary_files": "binary artefacts (type, size)", } EVIDENCE_MAX = 160 def clip(s: str, n: int = EVIDENCE_MAX) -> str: s = " ".join(str(s).split()) return s if len(s) <= n else s[: n - 1] + "…" @dataclass class Behaviour: kind: str path: str line: int operand: str evidence: str = "" detail: str = "" context: str = "" # execution context of the file/code: "pytest auto-load", "npm preinstall", ... source: str = "ast" # ast | shell | regex | manifest also: list = field(default_factory=list) # further lines with the same kind/operand/detail def key(self): return (self.kind, self.path, self.operand, self.detail) def to_json(self): d = {"kind": self.kind, "path": self.path, "line": self.line, "operand": self.operand, "evidence": clip(self.evidence)} if self.detail: d["detail"] = self.detail if self.context: d["context"] = self.context d["source"] = self.source if self.also: d["also_lines"] = sorted(set(self.also))[:50] return d def dedupe(items): out, seen = [], {} for b in sorted(items, key=lambda b: (b.path, b.line, KINDS.index(b.kind) if b.kind in KINDS else 99)): k = b.key() if k in seen: first = seen[k] if b.line != first.line: first.also.append(b.line) if not first.context and b.context: first.context = b.context continue seen[k] = b out.append(b) return out def render_text(report: dict, max_lines: int = 60) -> str: bs = report["behaviours"] st = report.get("stats", {}) head = [f"behaviour summary ({report['mode']}: {report.get('target', '')}) — " f"{len(bs)} behaviour(s) in {st.get('files', 0)} file(s), {st.get('lines', 0)} line(s) analysed. " f"Descriptive only; not a verdict."] by = {} for b in bs: by.setdefault(b["kind"], []).append(b) kinds = [k for k in KINDS if k in by] + [k for k in by if k not in KINDS] budget = max_lines - len(head) - len(kinds) - (1 if report.get("notes") else 0) # Fair share per kind, at least 2 lines each, remaining lines go to the largest kinds. alloc = {k: min(len(by[k]), 2) for k in kinds} spare = budget - sum(alloc.values()) while spare > 0: grew = False for k in sorted(kinds, key=lambda k: -len(by[k])): if spare > 0 and alloc[k] < len(by[k]): alloc[k] += 1 spare -= 1 grew = True if not grew: break lines = list(head) for k in kinds: items = by[k] lines.append(f"{k} ({len(items)})") shown = items[: max(alloc[k], 0)] if len(shown) < len(items) and shown: shown = shown[:-1] for b in shown: loc = f"{b['path']}:{b['line']}" if b.get("also_lines"): loc += f" (+{len(b['also_lines'])})" extra = "; ".join(x for x in (b.get("detail", ""), b.get("context", "")) if x) s = f"{loc} {b['operand']}" + (f" [{extra}]" if extra else "") if len(s) < 120 and b.get("evidence"): s += f" | {b['evidence']}" lines.append(" " + clip(s, 220)) if len(shown) < len(items): lines.append(f" … +{len(items) - len(shown)} more") if report.get("notes"): lines.append("notes: " + clip("; ".join(report["notes"]), 200)) return "\n".join(lines)