Zero-Shot Classification
Transformers
Safetensors
qwen3_5
feature-extraction
security
code-review
ai-agents
decision-model
d3
system-one
pre-release
custom_code
Instructions to use stonefish-labs/d3-mini-agentcode with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use stonefish-labs/d3-mini-agentcode with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("zero-shot-classification", model="stonefish-labs/d3-mini-agentcode", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModel processor = AutoProcessor.from_pretrained("stonefish-labs/d3-mini-agentcode", trust_remote_code=True) model = AutoModel.from_pretrained("stonefish-labs/d3-mini-agentcode", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download extractor/behaviour/pyjs.py from stonefish-labs/d3-mini-agentcode: direct link, hf CLI and curl.
- Browser
- Download file 61.8 kB
-
https://huggingface.co/stonefish-labs/d3-mini-agentcode/resolve/main/extractor/behaviour/pyjs.py
- Command line
-
hf download hf://stonefish-labs/d3-mini-agentcode/extractor/behaviour/pyjs.py
-
curl -L -o pyjs.py https://huggingface.co/stonefish-labs/d3-mini-agentcode/resolve/main/extractor/behaviour/pyjs.py
61.8 kB
| """Tree-sitter extractor for Python and JavaScript/TypeScript. | |
| Tree-sitter is error tolerant, so diff hunks and snippets that are not complete programs still yield call sites. | |
| Per file it builds: import aliases, simple constant/expression bindings, constructor types, function definitions and | |
| call sites. Rules map normalised callees to behaviours. A small flow-insensitive label propagation (names, function | |
| returns, one level of call-site arguments) connects sources (network content, decoders, env, credential files, | |
| archives) to sinks (exec/eval, spawns, egress bodies). Nothing is executed. | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import re | |
| import warnings | |
| from .model import Behaviour, clip | |
| from . import patterns as P | |
| from . import shell | |
| try: | |
| import tree_sitter_language_pack as _tslp | |
| except Exception: # pragma: no cover - fallback handled by caller | |
| _tslp = None | |
| _PARSERS = {} | |
| def available(): | |
| return _tslp is not None | |
| def parser(lang): | |
| key = {"javascript": "javascript", "typescript": "typescript", "tsx": "tsx", "python": "python"}[lang] | |
| if key not in _PARSERS: | |
| _PARSERS[key] = _tslp.get_parser(key) | |
| return _PARSERS[key] | |
| HTTP_VERBS = {"get", "post", "put", "patch", "delete", "head", "options", "request", "stream", "send"} | |
| # ---------------------------------------------------------------- rule tables (normalised callee -> kind, info) | |
| PY_SPAWN = {f"subprocess.{f}" for f in ("run", "call", "check_call", "check_output", "Popen", "getoutput", | |
| "getstatusoutput")} | \ | |
| {f"os.{f}" for f in ("system", "popen", "execv", "execve", "execl", "execle", "execlp", "execlpe", "execvp", | |
| "execvpe", "spawnl", "spawnle", "spawnlp", "spawnv", "spawnve", "spawnvp", "posix_spawn", | |
| "posix_spawnp", "startfile")} | \ | |
| {"pty.spawn", "asyncio.create_subprocess_exec", "asyncio.create_subprocess_shell", "commands.getoutput", | |
| "sh.Command", "pexpect.spawn", "pexpect.run"} | |
| PY_NET_PREFIX = ("requests.", "httpx.", "aiohttp.", "urllib3.", "treq.", "grequests.") | |
| PY_NET = {"urllib.request.urlopen", "urllib.request.urlretrieve", "urllib.urlopen", | |
| "urllib.urlretrieve", "urllib2.urlopen", "http.client.HTTPConnection", | |
| "http.client.HTTPSConnection", "httplib.HTTPConnection", "httplib.HTTPSConnection", | |
| "socket.create_connection", "socket.socket.connect", "socket.socket.connect_ex", "socket.socket.sendto", | |
| "ftplib.FTP", "ftplib.FTP_TLS", "smtplib.SMTP", "smtplib.SMTP_SSL", "telnetlib.Telnet", | |
| "paramiko.SSHClient.connect", "websocket.create_connection", "websockets.connect", | |
| "xmlrpc.client.ServerProxy", "socket.gethostbyname", "socket.getaddrinfo", "dns.resolver.resolve", | |
| "dns.resolver.query", "pycurl.Curl", "webbrowser.open", "aiohttp.ClientSession", "urllib.request.build_opener"} | |
| PY_NET_SKIP = {"requests.exceptions", "requests.adapters", "httpx.Timeout", "requests.Response", "httpx.Response", | |
| "requests.codes", "httpx.HTTPError", "requests.HTTPError", "aiohttp.ClientTimeout", | |
| "requests.RequestException", "httpx.HTTPStatusError", "requests.utils", "requests.structures", | |
| "requests.auth", "httpx.Limits", "urllib3.util", "urllib3.exceptions", "urllib3.disable_warnings", | |
| "requests.Session", "httpx.Client", "httpx.AsyncClient", "urllib3.PoolManager", "requests.packages", | |
| "httpx.URL", "httpx.Headers", "requests.models", "requests.compat", "requests.status_codes", | |
| "aiohttp.web", "aiohttp.TCPConnector", "httpx.MockTransport", "httpx.HTTPTransport", "aiohttp.ClientError", | |
| "httpx.Request", "requests.Request", "requests.PreparedRequest", "httpx.BasicAuth"} | |
| PY_DECODE = {f"base64.{f}" for f in ("b64decode", "b32decode", "b16decode", "a85decode", "b85decode", | |
| "urlsafe_b64decode", "decodebytes", "decodestring", "standard_b64decode")} | \ | |
| {"binascii.unhexlify", "binascii.a2b_base64", "binascii.a2b_hex", "bytes.fromhex", "bytearray.fromhex", | |
| "codecs.decode", "zlib.decompress", "gzip.decompress", "bz2.decompress", "lzma.decompress", "marshal.loads"} | |
| PY_EXEC = {"exec", "eval", "compile", "builtins.exec", "builtins.eval", "builtins.compile", "execfile"} | |
| PY_DYN = {"__import__", "importlib.import_module", "importlib.util.spec_from_file_location", | |
| "importlib.util.module_from_spec", "runpy.run_path", "runpy.run_module", "imp.load_source", | |
| "imp.load_module", "importlib.machinery.SourceFileLoader", "types.FunctionType", "types.CodeType"} | |
| PY_DESER = {"pickle.loads", "pickle.load", "cPickle.loads", "cPickle.load", "marshal.loads", "marshal.load", | |
| "dill.loads", "dill.load", "cloudpickle.loads", "jsonpickle.decode", "yaml.unsafe_load", "shelve.open", | |
| "pandas.read_pickle", "torch.load", "joblib.load"} | |
| PY_NATIVE = {"ctypes.CDLL", "ctypes.cdll.LoadLibrary", "ctypes.WinDLL", "ctypes.windll.LoadLibrary", "ctypes.PyDLL", | |
| "cffi.FFI.dlopen"} | |
| PY_DELETE = {"os.remove", "os.unlink", "os.rmdir", "os.removedirs", "shutil.rmtree", "send2trash.send2trash"} | |
| PY_COPY = {"shutil.copy", "shutil.copy2", "shutil.copyfile", "shutil.copytree", "shutil.move", "os.rename", | |
| "os.replace", "os.renames"} | |
| PY_ARCHIVE_NEW = {"tarfile.open", "tarfile.TarFile", "zipfile.ZipFile", "shutil.make_archive", "gzip.open", | |
| "zipfile.PyZipFile", "py7zr.SevenZipFile"} | |
| JS_SPAWN = {f"child_process.{f}" for f in ("exec", "execSync", "spawn", "spawnSync", "execFile", "execFileSync", | |
| "fork")} | \ | |
| {"execa", "execa.execa", "execa.command", "execa.commandSync", "execa.execaSync", "execa.execaCommand", | |
| "execa.execaCommandSync", "execa.$", "shelljs.exec", "Bun.spawn", "Bun.spawnSync", "Deno.run", "Deno.Command", | |
| "cross-spawn", "cross-spawn.sync", "zx.$", "Bun.$"} | |
| JS_BARE_SPAWN = {"execSync", "spawnSync", "execFileSync", "execFile"} | |
| JS_NET = {"fetch", "axios", "got", "superagent", "ky", "undici.request", "undici.fetch", "http.request", "http.get", | |
| "https.request", "https.get", "http2.connect", "net.connect", "net.createConnection", "tls.connect", | |
| "dgram.createSocket", "WebSocket", "ws", "EventSource", "navigator.sendBeacon", "dns.lookup", | |
| "dns.resolve", "dns.resolve4", "dns.resolveTxt", "dns.promises.lookup", "request", "needle", | |
| "XMLHttpRequest", "net.Socket", "jQuery.ajax", "$.ajax", "$.get", "$.post", "$.getJSON", "Deno.connect", | |
| "io", "socket.io-client", "socket.io-client.io", "phin", "bent", "node-fetch", "urllib.request", "ofetch", | |
| "$fetch", "wretch"} | |
| JS_NET_PREFIX = ("axios.", "got.", "superagent.", "ky.", "needle.", "request.") | |
| JS_WRITE = {f"fs.{f}": 0 for f in ("writeFile", "writeFileSync", "appendFile", "appendFileSync", "createWriteStream", | |
| "outputFile", "outputFileSync", "writeJson", "writeJsonSync", "outputJson", | |
| "outputJsonSync", "chmod", "chmodSync", "truncate", "truncateSync", "mkdtemp")} | |
| JS_WRITE.update({f"fs.{f}": 1 for f in ("copyFile", "copyFileSync", "cp", "cpSync", "rename", "renameSync", "symlink", | |
| "symlinkSync", "link", "linkSync", "copy", "copySync", "move", "moveSync", | |
| "ensureSymlink")}) | |
| JS_WRITE.update({"Deno.writeTextFile": 0, "Deno.writeFile": 0, "Bun.write": 0}) | |
| JS_BARE_WRITE = {"writeFileSync", "appendFileSync", "writeFile", "appendFile", "createWriteStream", "copyFileSync", | |
| "renameSync", "symlinkSync", "chmodSync"} | |
| JS_DELETE = {f"fs.{f}" for f in ("unlink", "unlinkSync", "rm", "rmSync", "rmdir", "rmdirSync", "remove", | |
| "removeSync", "emptyDir", "emptyDirSync")} | \ | |
| {"rimraf", "rimraf.sync", "rimraf.rimraf", "rimraf.rimrafSync", "del", "del.deleteAsync", "del.deleteSync", | |
| "Deno.remove", "trash"} | |
| JS_BARE_DELETE = {"unlinkSync", "rmSync", "rmdirSync"} | |
| JS_EXEC = {"eval", "Function", "vm.runInNewContext", "vm.runInThisContext", "vm.runInContext", "vm.Script", | |
| "vm.compileFunction", "vm.SourceTextModule", "globalThis.eval", "window.eval", "global.eval"} | |
| JS_DECODE = {"atob", "String.fromCharCode", "zlib.inflateSync", "zlib.gunzipSync", "zlib.inflateRawSync", | |
| "zlib.brotliDecompressSync", "zlib.unzipSync", "zlib.inflate", "zlib.gunzip", "Buffer.from.base64"} | |
| JS_ARCHIVE_NEW = {"archiver", "tar.c", "tar.create", "adm-zip", "jszip", "JSZip", "yazl.ZipFile", "zlib.gzipSync"} | |
| JS_MOD_NORM = {"node-fetch": "fetch", "cross-fetch": "fetch", "isomorphic-fetch": "fetch", "fs-extra": "fs", | |
| "fs/promises": "fs", "graceful-fs": "fs", "child-process-promise": "child_process", "jquery": "jQuery", | |
| "dns/promises": "dns", "shelljs": "shelljs", "execa": "execa", "zx": "zx", "rimraf": "rimraf"} | |
| def _t(n): | |
| return n.text.decode("utf-8", errors="replace") if n is not None else "" | |
| def _line(n): | |
| return n.start_point[0] + 1 | |
| def _end(n): | |
| return n.end_point[0] + 1 | |
| class Call: | |
| __slots__ = ("node", "callee", "args", "kwargs", "recv", "func", "top", "line", "end", "kind_labels", "new") | |
| def __init__(self, node, callee, args, kwargs, recv, func, top, new=False): | |
| self.node, self.callee, self.args, self.kwargs, self.recv = node, callee, args, kwargs, recv | |
| self.func, self.top, self.new = func, top, new | |
| self.line, self.end = _line(node), _end(node) | |
| self.kind_labels = set() | |
| class FileAnalysis: | |
| def __init__(self, unit, lang, context=""): | |
| self.u = unit | |
| self.lang = lang | |
| self.py = lang == "python" | |
| self.context = context | |
| self.text = unit.text | |
| self.src = unit.text.encode("utf-8", errors="replace") | |
| self.lines = unit.text.split("\n") | |
| self.alias, self.types, self.consts, self.exprs = {}, {}, {}, {} | |
| self.funcs = {} # name -> (node, [params]) | |
| self.calls = [] | |
| self.name_labels, self.func_labels = {}, {} | |
| self.callgraph = {} # func -> set(local funcs called) | |
| self.top_called = set() # local funcs called at module level | |
| self.out = [] | |
| self.consumed = set() # string literal byte ranges already analysed as commands | |
| self.withs, self.fors, self.returns = [], [], [] | |
| self.idents_cache = {} | |
| # ------------------------------------------------------------------ helpers | |
| def lit(self, n): | |
| if n is None: | |
| return None | |
| t = n.type | |
| if self.py: | |
| if t == "string": | |
| if any(c.type == "interpolation" for c in n.children): | |
| return None | |
| try: | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| v = ast.literal_eval(_t(n)) | |
| return v.decode("latin-1") if isinstance(v, bytes) else v | |
| except Exception: | |
| s = _t(n) | |
| m = re.match(r"""^[rRbBuU]{0,2}('''|\"\"\"|'|")(.*)\1$""", s, re.S) | |
| return m.group(2) if m else None | |
| if t == "concatenated_string": | |
| parts = [self.lit(c) for c in n.named_children] | |
| return "".join(parts) if all(p is not None for p in parts) else None | |
| if t == "binary_operator" and _t(n.child_by_field_name("operator")) == "+": | |
| a, b = self.lit(n.child_by_field_name("left")), self.lit(n.child_by_field_name("right")) | |
| return a + b if a is not None and b is not None else None | |
| if t == "identifier" and _t(n) in self.consts: | |
| return self.consts[_t(n)] | |
| if t == "parenthesized_expression" and n.named_children: | |
| return self.lit(n.named_children[0]) | |
| return None | |
| if t == "string": | |
| s = _t(n) | |
| body = s[1:-1] | |
| try: | |
| if "\\" not in body: | |
| return body | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| return ast.literal_eval('"' + body.replace('"', '\\"') + '"') | |
| except Exception: | |
| return body | |
| if t == "template_string": | |
| if any(c.type == "template_substitution" for c in n.children): | |
| return None | |
| return _t(n)[1:-1] | |
| if t == "binary_expression" and _t(n.child_by_field_name("operator")) == "+": | |
| a, b = self.lit(n.child_by_field_name("left")), self.lit(n.child_by_field_name("right")) | |
| return a + b if a is not None and b is not None else None | |
| if t == "identifier" and _t(n) in self.consts: | |
| return self.consts[_t(n)] | |
| if t == "parenthesized_expression" and n.named_children: | |
| return self.lit(n.named_children[0]) | |
| return None | |
| def show(self, n, depth=3): | |
| """Display an operand: literal value, else the expression with single-definition names substituted.""" | |
| if n is None: | |
| return "dynamic" | |
| v = self.lit(n) | |
| if v is not None: | |
| return v | |
| s = _t(n) | |
| if depth > 0: | |
| idents = [c for c in self._idents(n) if _t(c) not in self.alias and ( | |
| _t(c) in self.exprs and len(self.exprs[_t(c)]) == 1 or _t(c) in dict(self.fors) and _t(c) not in self.exprs)] | |
| for c in sorted(idents, key=lambda c: -c.start_byte): | |
| if _t(c) in self.exprs: | |
| rhs = self.exprs[_t(c)][0] | |
| if rhs.start_byte <= n.start_byte <= rhs.end_byte: | |
| continue | |
| rep = self.show(rhs, depth - 1) | |
| rep = repr(rep) if self.lit(rhs) is not None else rep | |
| else: | |
| rep = f"<each of {self.show(dict(self.fors)[_t(c)], depth - 1)}>" | |
| a, b = c.start_byte - n.start_byte, c.end_byte - n.start_byte | |
| bs = s.encode() | |
| s = (bs[:a] + rep.encode() + bs[b:]).decode(errors="replace") | |
| if n.type in ("call", "call_expression"): | |
| fn = n.child_by_field_name("function") | |
| if fn is not None and fn.type == "identifier" and _t(fn) in self.funcs: | |
| rets = [r for r, f in self.returns if f == _t(fn) and r.type == "return_statement" and r.named_children] | |
| if 0 < len(rets) <= 2: | |
| s += " → returns " + " | ".join(self.show(r.named_children[0], depth - 1) for r in rets) | |
| return clip(s, 140) | |
| def _idents(self, n): | |
| key = (n.start_byte, n.end_byte) | |
| if key in self.idents_cache: | |
| return self.idents_cache[key] | |
| out, stack = [], [n] | |
| while stack: | |
| x = stack.pop() | |
| if x.type in ("identifier", "shorthand_property_identifier"): | |
| p = x.parent | |
| if p is not None and (p.type in ("attribute", "member_expression") and | |
| p.child_by_field_name("object") is not None and | |
| p.child_by_field_name("object") != x and | |
| (p.child_by_field_name("object").start_byte != x.start_byte)): | |
| continue | |
| if p is not None and p.type in ("keyword_argument",) and p.child_by_field_name("name") == x: | |
| continue | |
| out.append(x) | |
| stack.extend(x.children) | |
| self.idents_cache[key] = out | |
| return out | |
| def ev(self, line): | |
| return self.lines[line - 1].strip() if 0 < line <= len(self.lines) else "" | |
| def add(self, kind, line, operand, detail="", source="ast", context=None, end=None): | |
| if not self.u.in_focus(line, end or line): | |
| return | |
| ln = line | |
| if self.u.focus is not None and end and end > line: | |
| ln = next((x for x in range(line, end + 1) if x in self.u.focus), line) | |
| self.out.append(Behaviour(kind, self.u.path, ln, clip(str(operand), 140) or "dynamic", self.ev(ln), detail, | |
| self.context if context is None else context, source)) | |
| # ------------------------------------------------------------------ pass 1: bindings | |
| def collect(self, root): | |
| stack = [(root, None, True)] | |
| while stack: | |
| n, func, top = stack.pop() | |
| t = n.type | |
| nfunc, ntop = func, top | |
| if t in ("function_definition", "function_declaration", "method_definition", "generator_function_declaration"): | |
| name = _t(n.child_by_field_name("name")) | |
| params = self._params(n) | |
| if name: | |
| self.funcs.setdefault(name, (n, params)) | |
| nfunc, ntop = name or "<fn>", False | |
| elif t in ("class_definition", "class_declaration"): | |
| ntop = False | |
| elif t in ("arrow_function", "function_expression", "function", "lambda"): | |
| p = n.parent | |
| name = _t(p.child_by_field_name("name")) if p is not None and p.type == "variable_declarator" else "" | |
| if name: | |
| self.funcs.setdefault(name, (n, self._params(n))) | |
| nfunc, ntop = name or func or "<fn>", False | |
| elif t == "if_statement" and self.py and re.search(r"__name__\s*==\s*['\"]__main__['\"]", | |
| _t(n.child_by_field_name("condition"))): | |
| ntop = False | |
| if self.py: | |
| self._py_binding(n) | |
| else: | |
| self._js_binding(n) | |
| if t in ("call", "call_expression", "new_expression"): | |
| c = self._call(n, func, top) | |
| if c is not None: | |
| self.calls.append(c) | |
| if t in ("return_statement", "yield"): | |
| self.returns.append((n, func)) | |
| for ch in reversed(n.children): | |
| stack.append((ch, nfunc, ntop)) | |
| def _params(self, n): | |
| p = n.child_by_field_name("parameters") | |
| if p is None: | |
| return [] | |
| out = [] | |
| for c in p.named_children: | |
| if c.type == "identifier": | |
| out.append(_t(c)) | |
| else: | |
| idn = c.child_by_field_name("name") or c.child_by_field_name("pattern") or \ | |
| next((x for x in c.named_children if x.type == "identifier"), None) | |
| out.append(_t(idn) if idn is not None else "") | |
| if out and out[0] in ("self", "cls"): | |
| out = out[1:] | |
| return out | |
| def _bind(self, name, rhs): | |
| if not name or rhs is None: | |
| return | |
| self.exprs.setdefault(name, []).append(rhs) | |
| v = self.lit(rhs) | |
| if v is not None and isinstance(v, str): | |
| self.consts.setdefault(name, v) | |
| if rhs.type in ("call", "call_expression", "new_expression", "await_expression", "await"): | |
| inner = rhs | |
| if rhs.type in ("await_expression", "await") and rhs.named_children: | |
| inner = rhs.named_children[0] | |
| fn = inner.child_by_field_name("function") or inner.child_by_field_name("constructor") | |
| cal = self._callee(fn) if fn is not None else None | |
| if cal: | |
| self.types[name] = cal | |
| def _py_binding(self, n): | |
| t = n.type | |
| if t == "import_statement": | |
| for c in n.named_children: | |
| if c.type == "dotted_name": | |
| first = _t(c).split(".")[0] | |
| self.alias[first] = first | |
| elif c.type == "aliased_import": | |
| self.alias[_t(c.child_by_field_name("alias"))] = _t(c.child_by_field_name("name")) | |
| elif t == "import_from_statement": | |
| mod = _t(n.child_by_field_name("module_name")) | |
| seen_mod = False | |
| for c in n.named_children: | |
| if c == n.child_by_field_name("module_name") or not seen_mod and c.type in ("dotted_name", "relative_import") and _t(c) == mod: | |
| seen_mod = True | |
| continue | |
| if c.type == "dotted_name": | |
| self.alias[_t(c)] = f"{mod}.{_t(c)}" | |
| elif c.type == "aliased_import": | |
| self.alias[_t(c.child_by_field_name("alias"))] = f"{mod}.{_t(c.child_by_field_name('name'))}" | |
| elif t == "assignment": | |
| left, right = n.child_by_field_name("left"), n.child_by_field_name("right") | |
| if left is not None and left.type == "identifier": | |
| self._bind(_t(left), right) | |
| elif left is not None and left.type == "attribute" and _t(left).startswith("self."): | |
| self._bind(_t(left), right) | |
| elif t == "with_item": | |
| v = n.child_by_field_name("value") | |
| if v is not None and v.type == "as_pattern": | |
| target = v.child_by_field_name("alias") | |
| src = v.named_children[0] if v.named_children else None | |
| if target is not None and src is not None: | |
| name = _t(target).strip() | |
| self.withs.append((name, src)) | |
| self._bind(name, src) | |
| elif t == "for_statement": | |
| left, right = n.child_by_field_name("left"), n.child_by_field_name("right") | |
| if left is not None and right is not None: | |
| for idn in ([left] if left.type == "identifier" else [x for x in left.named_children if x.type == "identifier"]): | |
| self.fors.append((_t(idn), right)) | |
| def _js_binding(self, n): | |
| t = n.type | |
| if t == "import_statement": | |
| srcn = n.child_by_field_name("source") | |
| mod = self._jsmod(self.lit(srcn) or "") | |
| for c in n.named_children: | |
| if c.type != "import_clause": | |
| continue | |
| for d in c.named_children: | |
| if d.type == "identifier": | |
| self.alias[_t(d)] = mod | |
| elif d.type == "namespace_import": | |
| idn = next((x for x in d.named_children if x.type == "identifier"), None) | |
| if idn is not None: | |
| self.alias[_t(idn)] = mod | |
| elif d.type == "named_imports": | |
| for s in d.named_children: | |
| if s.type == "import_specifier": | |
| nm, al = s.child_by_field_name("name"), s.child_by_field_name("alias") | |
| self.alias[_t(al or nm)] = f"{mod}.{_t(nm)}" if _t(nm) != "default" else mod | |
| elif t in ("variable_declarator", "assignment_expression"): | |
| name = n.child_by_field_name("name") if t == "variable_declarator" else n.child_by_field_name("left") | |
| val = n.child_by_field_name("value") if t == "variable_declarator" else n.child_by_field_name("right") | |
| if name is None or val is None: | |
| return | |
| mod = self._require_mod(val) | |
| if name.type == "identifier": | |
| if mod is not None: | |
| self.alias[_t(name)] = mod | |
| self._bind(_t(name), val) | |
| elif name.type == "member_expression" and _t(name).startswith("this."): | |
| self._bind(_t(name), val) | |
| elif name.type == "object_pattern" and mod is not None: | |
| for c in name.named_children: | |
| if c.type == "shorthand_property_identifier_pattern": | |
| self.alias[_t(c)] = f"{mod}.{_t(c)}" | |
| elif c.type == "pair_pattern": | |
| k, v = c.child_by_field_name("key"), c.child_by_field_name("value") | |
| if v is not None and v.type == "identifier": | |
| self.alias[_t(v)] = f"{mod}.{_t(k)}" | |
| elif t in ("for_in_statement",): | |
| left, right = n.child_by_field_name("left"), n.child_by_field_name("right") | |
| if left is not None and right is not None: | |
| idn = left if left.type == "identifier" else next((x for x in left.named_children if x.type == "identifier"), None) | |
| if idn is not None: | |
| self.fors.append((_t(idn), right)) | |
| def _jsmod(self, m): | |
| m = m.removeprefix("node:") | |
| if m in JS_MOD_NORM: | |
| return JS_MOD_NORM[m] | |
| return m | |
| def _require_mod(self, val): | |
| v = val | |
| if v.type in ("await_expression",) and v.named_children: | |
| v = v.named_children[0] | |
| suffix = "" | |
| while v.type == "member_expression": | |
| suffix = "." + _t(v.child_by_field_name("property")) + suffix | |
| v = v.child_by_field_name("object") | |
| if v.type == "call_expression": | |
| fn = v.child_by_field_name("function") | |
| if fn is not None and _t(fn) in ("require", "import"): | |
| args = v.child_by_field_name("arguments") | |
| a0 = args.named_children[0] if args is not None and args.named_children else None | |
| s = self.lit(a0) | |
| if s: | |
| return self._jsmod(s) + suffix | |
| return None | |
| def _dotted(self, n): | |
| if n.type in ("identifier", "property_identifier", "this"): | |
| return _t(n) | |
| if n.type in ("attribute", "member_expression"): | |
| o = n.child_by_field_name("object") | |
| a = n.child_by_field_name("attribute") or n.child_by_field_name("property") | |
| if o is None or a is None: | |
| return None | |
| od = self._dotted(o) | |
| return f"{od}.{_t(a)}" if od is not None else None | |
| return None | |
| def _callee(self, fn): | |
| if fn is None: | |
| return None | |
| if fn.type in ("identifier",): | |
| n = _t(fn) | |
| return self.alias.get(n, n) | |
| if fn.type == "import": | |
| return "import" | |
| if fn.type in ("attribute", "member_expression"): | |
| d = self._dotted(fn) | |
| attr = _t(fn.child_by_field_name("attribute") or fn.child_by_field_name("property")) | |
| if d is not None: | |
| first, _, rest = d.partition(".") | |
| if first in self.alias: | |
| d = self.alias[first] + ("." + rest if rest else "") | |
| elif first in self.types and rest: | |
| d = self.types[first] + "()." + rest | |
| elif first in ("self", "this") and rest: | |
| head, _, tail = rest.partition(".") | |
| key = f"{first}.{head}" | |
| if key in self.types and tail: | |
| d = self.types[key] + "()." + tail | |
| if not self.py: | |
| d = re.sub(r"^fs\.promises\.", "fs.", d) | |
| return d | |
| o = fn.child_by_field_name("object") | |
| if o is not None and o.type in ("call", "call_expression", "new_expression"): | |
| inner = o.child_by_field_name("function") or o.child_by_field_name("constructor") | |
| m = self._require_mod(o) if not self.py else None | |
| if m: | |
| return f"{m}.{attr}" | |
| ic = self._callee(inner) | |
| if ic: | |
| return f"{ic}().{attr}" | |
| if o is not None and o.type in ("parenthesized_expression", "await_expression", "await"): | |
| return f"?.{attr}" | |
| return f"?.{attr}" | |
| return None | |
| def _call(self, n, func, top): | |
| new = n.type == "new_expression" | |
| fn = n.child_by_field_name("constructor" if new else "function") | |
| if fn is None: | |
| return None | |
| if fn.type == "new_expression": | |
| return None # `new F(x)()`: the new_expression is its own call site | |
| callee = self._callee(fn) | |
| if callee is None: | |
| return None | |
| recv = None | |
| if fn.type in ("attribute", "member_expression"): | |
| recv = fn.child_by_field_name("object") | |
| argn = n.child_by_field_name("arguments") | |
| args, kwargs = [], {} | |
| if argn is not None: | |
| if argn.type == "template_string": | |
| args = [argn] | |
| else: | |
| for c in argn.named_children: | |
| if c.type == "keyword_argument": | |
| kwargs[_t(c.child_by_field_name("name"))] = c.child_by_field_name("value") | |
| elif c.type in ("comment",): | |
| continue | |
| else: | |
| args.append(c) | |
| return Call(n, callee, args, kwargs, recv, func, top, new) | |
| # ------------------------------------------------------------------ labels | |
| def call_labels(self, c): | |
| k, labs = c.callee, set() | |
| last = k.rsplit(".", 1)[-1] | |
| if self._is_net(c): | |
| labs.add("network") | |
| if self._is_decode(c): | |
| labs.add("decode") | |
| if self._archive_new(c): | |
| labs.add("archive") | |
| if self.py and (k in ("open", "io.open", "codecs.open") and not self._write_mode(c)) or \ | |
| last in ("read_text", "read_bytes", "readFileSync", "readFile") or k in ("fs.readFileSync", "fs.readFile"): | |
| labs.add("file") | |
| if k in self.funcs or last in self.funcs and k.startswith(("?.", "self.", "this.")): | |
| labs |= self.func_labels.get(k if k in self.funcs else last, set()) | |
| return labs | |
| def node_labels(self, n): | |
| labs = set() | |
| s = _t(n) | |
| if re.search(r"\bos\.environ\b|\bgetenv\s*\(|\bprocess\.env\b|\benviron\.(items|get)\b|Deno\.env|import\.meta\.env", s): | |
| labs.add("env") | |
| if P.secret_hits(s): | |
| labs.add("credential") | |
| a, b = n.start_byte, n.end_byte | |
| for c in self.calls: | |
| if a <= c.node.start_byte and c.node.end_byte <= b: | |
| labs |= c.kind_labels | |
| for idn in self._idents(n): | |
| labs |= self.name_labels.get(_t(idn), set()) | |
| return labs | |
| def propagate(self): | |
| for c in self.calls: | |
| c.kind_labels = self.call_labels(c) | |
| for _ in range(3): | |
| changed = False | |
| for name, rhss in list(self.exprs.items()): | |
| labs = set() | |
| for r in rhss: | |
| labs |= self.node_labels(r) | |
| if labs - self.name_labels.get(name, set()): | |
| self.name_labels.setdefault(name, set()).update(labs) | |
| changed = True | |
| for name, src in self.fors: | |
| labs = self.node_labels(src) | |
| if labs - self.name_labels.get(name, set()): | |
| self.name_labels.setdefault(name, set()).update(labs) | |
| changed = True | |
| for c in self.calls: | |
| if "archive" in c.kind_labels and self._archive_new(c): | |
| for kw in ("fileobj",): | |
| v = c.kwargs.get(kw) | |
| if v is not None and v.type == "identifier" and "archive" not in self.name_labels.get(_t(v), set()): | |
| self.name_labels.setdefault(_t(v), set()).add("archive") | |
| changed = True | |
| fl = {} | |
| for rn, func in self.returns: | |
| if func and func in self.funcs: | |
| fl.setdefault(func, set()).update(self.node_labels(rn)) | |
| for f, (fnode, _) in self.funcs.items(): | |
| body = _t(fnode) | |
| if P.secret_hits(body) and re.search(r"read_text|read_bytes|open\(|readFile", body): | |
| fl.setdefault(f, set()).add("credential") | |
| for f, labs in fl.items(): | |
| if labs - self.func_labels.get(f, set()): | |
| self.func_labels.setdefault(f, set()).update(labs) | |
| changed = True | |
| for c in self.calls: | |
| c.kind_labels = self.call_labels(c) | |
| if not changed: | |
| break | |
| # call graph for import-time reachability | |
| for c in self.calls: | |
| name = c.callee if c.callee in self.funcs else None | |
| if name is None: | |
| continue | |
| if c.top: | |
| self.top_called.add(name) | |
| elif c.func: | |
| self.callgraph.setdefault(c.func, set()).add(name) | |
| def reachable_at_import(self): | |
| seen, todo = set(), list(self.top_called) | |
| while todo: | |
| f = todo.pop() | |
| if f in seen: | |
| continue | |
| seen.add(f) | |
| todo.extend(self.callgraph.get(f, ())) | |
| return seen | |
| # ------------------------------------------------------------------ classifiers | |
| def _is_net(self, c): | |
| k = c.callee | |
| if self.py: | |
| if k in PY_NET: | |
| return True | |
| if k.startswith(PY_NET_PREFIX) and "()" not in k and not any(k == s or k.startswith(s + ".") for s in PY_NET_SKIP): | |
| last = k.rsplit(".", 1)[-1] | |
| return last in HTTP_VERBS or last in ("urlopen", "request_encode_body", "request") or \ | |
| k.count(".") == 1 and last in HTTP_VERBS | |
| if re.match(r"^(requests\.Session|httpx\.(Async)?Client|aiohttp\.ClientSession|urllib3\.PoolManager)(\(\))?\.(get|post|put|patch|delete|head|options|request|stream|send|urlopen)$", k): | |
| return True | |
| if k in ("urlopen", "urlretrieve"): | |
| return True | |
| return False | |
| if k in JS_NET: | |
| return k not in ("request",) or "request" in self.alias | |
| if k.startswith(JS_NET_PREFIX) and "()" not in k: | |
| return k.rsplit(".", 1)[-1] in HTTP_VERBS | |
| if re.match(r"^(axios|got|ky|superagent)(\(\))?\.\w+$", k) and k.rsplit(".", 1)[-1] in HTTP_VERBS: | |
| return True | |
| return bool(re.match(r"^(net\.Socket|WebSocket|dgram\.createSocket)(\(\))?\.(connect|send)$", k)) | |
| def _is_decode(self, c): | |
| k = c.callee | |
| if self.py: | |
| if k in PY_DECODE or k in ("b64decode", "unhexlify", "a2b_base64", "urlsafe_b64decode", "decompress"): | |
| return True | |
| if k.endswith(".decode") and c.args and (self.lit(c.args[0]) or "").lower() in ("base64", "hex", "rot13", "rot_13", "zlib", "bz2"): | |
| return True | |
| if k == "?.join" and c.args and re.search(r"\bchr\(", _t(c.args[0])): | |
| return True | |
| return k == "codecs.decode" | |
| if k in JS_DECODE: | |
| return True | |
| if k in ("Buffer.from", "buffer.Buffer.from") and len(c.args) > 1 and (self.lit(c.args[1]) or "") in ("base64", "hex", "base64url"): | |
| return True | |
| return False | |
| def _archive_new(self, c): | |
| k = c.callee | |
| if self.py: | |
| if k in ("tarfile.open", "tarfile.TarFile", "zipfile.ZipFile", "gzip.open", "zipfile.PyZipFile"): | |
| mode = c.kwargs.get("mode") | |
| mv = self.lit(mode) if mode is not None else (self.lit(c.args[1]) if len(c.args) > 1 else None) | |
| return bool(mv) and mv[:1] in ("w", "x", "a") | |
| return k in ("shutil.make_archive", "py7zr.SevenZipFile") | |
| return k in JS_ARCHIVE_NEW | |
| def _write_mode(self, c): | |
| mode = c.kwargs.get("mode") | |
| if mode is None and len(c.args) > 1: | |
| mode = c.args[1] | |
| mv = self.lit(mode) if mode is not None else None | |
| return bool(mv) and any(ch in mv for ch in "wax+") | |
| # ------------------------------------------------------------------ pass 2: rules | |
| def apply(self): | |
| imp = self.reachable_at_import() if self.py else set() | |
| params = {f: p for f, (_, p) in self.funcs.items()} | |
| sites = {} | |
| for c in self.calls: | |
| nm = c.callee if c.callee in self.funcs else (c.callee.rsplit(".", 1)[-1] if c.callee.startswith(("?.", "self.", "this.")) else None) | |
| if nm in self.funcs: | |
| sites.setdefault(nm, []).append(c) | |
| for c in self.calls: | |
| ctx = self.context | |
| if self.py and (c.top or c.func in imp) and self.u.path.rsplit("/", 1)[-1] != "setup.py": | |
| via = "" if c.top else f" via {sorted(f for f in imp if f == c.func)[0]}()" | |
| ctx = "; ".join(x for x in (ctx, "runs at import" + via) if x) | |
| self._rules(c, ctx, params, sites) | |
| self._hooks() | |
| def _arg(self, c, i, *kw): | |
| for k in kw: | |
| if k in c.kwargs: | |
| return c.kwargs[k] | |
| return c.args[i] if len(c.args) > i else None | |
| def _operand_via_sites(self, c, node, params, sites): | |
| """If node is a parameter of the enclosing function, show what call sites pass.""" | |
| if node is None or node.type != "identifier" or not c.func or c.func not in params: | |
| return None | |
| name = _t(node) | |
| ps = params[c.func] | |
| if name not in ps: | |
| return None | |
| i = ps.index(name) | |
| shown = [] | |
| for s in sites.get(c.func, []): | |
| a = s.kwargs.get(name) or (s.args[i] if len(s.args) > i else None) | |
| if a is not None: | |
| shown.append(self.show(a)) | |
| return ", ".join(dict.fromkeys(shown)) if shown else None | |
| def _site_labels(self, c, params, sites): | |
| if not c.func or c.func not in params: | |
| return set() | |
| labs = set() | |
| body_args = c.args + list(c.kwargs.values()) | |
| used = {(_t(i)) for a in body_args for i in self._idents(a)} | |
| for i, p in enumerate(params[c.func]): | |
| if p in used: | |
| for s in sites.get(c.func, []): | |
| a = s.kwargs.get(p) or (s.args[i] if len(s.args) > i else None) | |
| if a is not None: | |
| labs |= self.node_labels(a) | |
| return labs | |
| def _rules(self, c, ctx, params, sites): | |
| k = c.callee | |
| last = k.rsplit(".", 1)[-1] | |
| L, E = c.line, c.end | |
| add = lambda kind, operand, detail="": self.add(kind, L, operand, detail, context=ctx, end=E) | |
| arglabs = set() | |
| for a in c.args + list(c.kwargs.values()): | |
| if a is not None: | |
| arglabs |= self.node_labels(a) | |
| arglabs |= self._site_labels(c, params, sites) | |
| # ---- process spawn | |
| is_spawn = (k in PY_SPAWN or k in ("check_output", "check_call", "Popen", "getoutput")) if self.py else \ | |
| (k in JS_SPAWN or k in JS_BARE_SPAWN or (k in ("exec", "spawn") and k not in self.funcs and k not in self.alias and | |
| not self.alias)) | |
| if is_spawn: | |
| a0 = self._arg(c, 0, "args", "cmd", "command") | |
| cmd = self._command_text(a0) | |
| det = [] | |
| if self.py and "shell" in c.kwargs and _t(c.kwargs["shell"]) == "True" or not self.py and last in ("exec", "execSync"): | |
| det.append("via shell") | |
| if "network" in arglabs: | |
| self.add("download_then_exec", L, cmd, "network content reaches a process", context=ctx, end=E) | |
| if "decode" in arglabs: | |
| self.add("decode_then_exec", L, cmd, "decoded data reaches a process", context=ctx, end=E) | |
| add("process_spawn", cmd, "; ".join(det)) | |
| if a0 is not None: | |
| full = self._command_text(a0, literal_only=True) | |
| if full: | |
| self._mark_consumed(a0) | |
| for b in shell.analyse(full, self.u.path, base_line=_line(a0), in_focus=lambda a, b: self.u.in_focus(L, E), | |
| context=ctx, embedded=f"via {k}", emit_spawn=False): | |
| b.line = L if self.u.focus is None or L in self.u.focus else b.line | |
| b.evidence = self.ev(b.line) | |
| self.out.append(b) | |
| return | |
| # ---- network egress | |
| if self._is_net(c): | |
| urln = self._arg(c, 0, "url", "host", "address") | |
| reqcall = self._request_obj(urln) | |
| if reqcall is not None: | |
| urln = self._arg(reqcall, 0, "url") | |
| for kk, vv in reqcall.kwargs.items(): | |
| c.kwargs.setdefault(kk, vv) | |
| if len(reqcall.args) > 1: | |
| c.kwargs.setdefault("data", reqcall.args[1]) | |
| for a in [*reqcall.args, *reqcall.kwargs.values()]: | |
| arglabs |= self.node_labels(a) | |
| if self.py and k.endswith("socket.connect") and c.args and c.args[0].type == "tuple": | |
| urln = c.args[0] | |
| op = self.show(urln) if urln is not None else "dynamic" | |
| if urln is not None and self.lit(urln) is None: | |
| via = self._operand_via_sites(c, urln, params, sites) | |
| if via: | |
| op = f"{op} ← {via}" | |
| elif not re.search(r"://|^\(?['\"]?\d+\.\d+\.\d+\.\d+|^\(?['\"]?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d+)?(/|['\"]|$)", op, re.I): | |
| op = f"dynamic ({clip(op, 60)})" | |
| det = [] | |
| if last.upper() in ("POST", "PUT", "PATCH", "DELETE"): | |
| det.append(last.upper()) | |
| body_kw = [x for x in ("data", "json", "files", "content", "body") if x in c.kwargs] | |
| if self.py and k.endswith("Request") and (len(c.args) > 1 or "data" in c.kwargs): | |
| body_kw.append("data") | |
| if not self.py: | |
| txt = _t(c.node) | |
| m = re.search(r"method\s*:\s*['\"](\w+)['\"]", txt) | |
| if m: | |
| det.append(m.group(1).upper()) | |
| if re.search(r"\bbody\s*:", txt) or last in ("post", "put", "patch") and len(c.args) > 1 or last == "sendBeacon": | |
| body_kw.append("body") | |
| if body_kw: | |
| det.append("sends body") | |
| src = sorted(arglabs & {"env", "credential", "archive", "file"}) | |
| if src: | |
| det.append("data from: " + ", ".join(src)) | |
| if k.startswith("socket.get") or k.startswith("dns"): | |
| det.append("DNS lookup") | |
| add("network_egress", op, "; ".join(det)) | |
| return | |
| # ---- dynamic code / eval | |
| exec_like = (k in PY_EXEC) if self.py else (k in JS_EXEC or k in ("setTimeout", "setInterval") and c.args and | |
| c.args[0].type in ("string", "template_string")) | |
| if self.py and k in ("eval", "exec", "compile") and (c.recv is not None): | |
| exec_like = False | |
| if exec_like and last == "compile" and self._inside_exec(c): | |
| return | |
| if exec_like: | |
| a0 = self._arg(c, 0, "source", "code") | |
| op = clip(_t(a0), 80) if a0 is not None else "dynamic" | |
| if "network" in arglabs: | |
| add("download_then_exec", op, f"network content reaches {last}") | |
| if "decode" in arglabs: | |
| add("decode_then_exec", op, f"decoded data reaches {last}") | |
| add("dynamic_import_or_eval", op, last + ("" if a0 is None or self.lit(a0) is None else " of literal")) | |
| return | |
| dyn = None | |
| if self.py: | |
| if k in PY_DYN: | |
| a0 = self._arg(c, 0, "name", "path", "location") | |
| dyn = (self.show(a0) if a0 is not None else "dynamic", k.rsplit(".", 1)[-1] + | |
| (" (literal)" if a0 is not None and self.lit(a0) is not None else "")) | |
| elif k.endswith(".exec_module") or k == "?.exec_module": | |
| dyn = (clip(_t(c.node), 80), "loader.exec_module") | |
| elif k in PY_DESER or k == "yaml.load" and not re.search(r"Safe|BaseLoader", _t(c.node)): | |
| dyn = (self.show(self._arg(c, 0)), "deserialisation " + k) | |
| elif k in PY_NATIVE or k.startswith("ctypes.") and last in ("LoadLibrary", "CDLL", "WinDLL"): | |
| dyn = (self.show(self._arg(c, 0)), "native library load") | |
| else: | |
| a0 = self._arg(c, 0) | |
| if k in ("require", "import") and a0 is not None and self.lit(a0) is None: | |
| dyn = (clip(_t(a0), 80), f"non-literal {k}") | |
| elif k == "import" and a0 is not None and re.match(r"(https?:|data:)", self.lit(a0) or ""): | |
| dyn = (self.lit(a0), "remote/data URL import") | |
| if (self.lit(a0) or "").startswith("http"): | |
| add("download_then_exec", self.lit(a0), "remote module import") | |
| elif last == "_compile" or k in ("process.binding", "WebAssembly.instantiate", "WebAssembly.compile", | |
| "module.constructor._load", "Module._load"): | |
| dyn = (clip(_t(c.node), 80), k) | |
| elif k in ("process.dlopen",): | |
| dyn = (self.show(a0), "native addon load") | |
| if dyn: | |
| if "network" in arglabs: | |
| add("download_then_exec", dyn[0], "network content reaches dynamic import") | |
| if "decode" in arglabs: | |
| add("decode_then_exec", dyn[0], "decoded data reaches dynamic import") | |
| add("dynamic_import_or_eval", dyn[0], dyn[1]) | |
| return | |
| # ---- archives | |
| if self._archive_new(c): | |
| add("archive_or_bundle", self.show(self._arg(c, 0, "name", "base_name")), "creates archive (" + k + ")") | |
| if last in ("extractall", "extract", "unpack_archive") and ( | |
| k.startswith(("tarfile", "zipfile", "shutil")) or re.search(r"tar|zip|archive|bundle|whl|7z", _t(c.recv) if c.recv is not None else "", re.I)): | |
| tgt = self._arg(c, 0, "path", "extract_dir") if last != "unpack_archive" else self._arg(c, 1, "extract_dir") | |
| t = self.show(tgt) if tgt is not None else "." | |
| add("archive_or_bundle", t, "extracts archive") | |
| add("file_write", t, f"archive extraction; scope {P.path_scope(t)}") | |
| # ---- file writes / deletes | |
| w = self._file_write(c) | |
| if w: | |
| target, det = w | |
| labs = sorted(arglabs & {"network", "env", "credential", "decode"}) | |
| if labs: | |
| det += "; content from: " + ", ".join(labs) | |
| add("file_write", target, det) | |
| for label, rx in P.PERSIST_LOCATIONS: | |
| if rx.search(target): | |
| add("persistence", label, "write target " + clip(target, 80)) | |
| return | |
| d = self._file_delete(c) | |
| if d: | |
| add("file_delete", d, "scope " + P.path_scope(d)) | |
| return | |
| if self.py and k in ("os.environ.setdefault", "os.putenv") or k.endswith("environ.update"): | |
| pass | |
| def _inside_exec(self, c): | |
| p = c.node.parent | |
| while p is not None and p.type in ("argument_list", "parenthesized_expression", "keyword_argument"): | |
| p = p.parent | |
| if p is not None and p.type == "call": | |
| fn = p.child_by_field_name("function") | |
| return fn is not None and _t(fn) in ("exec", "eval", "builtins.exec", "builtins.eval") | |
| return False | |
| def _request_obj(self, n): | |
| """urlopen(req) where req = Request(url, data=...) -> the Request call.""" | |
| if n is None: | |
| return None | |
| if n.type == "identifier" and len(self.exprs.get(_t(n), [])) == 1: | |
| n = self.exprs[_t(n)][0] | |
| if n.type != "call": | |
| return None | |
| for c in self.calls: | |
| if c.node.start_byte == n.start_byte and c.node.end_byte == n.end_byte: | |
| return c if c.callee.endswith(("urllib.request.Request", "urllib2.Request")) or c.callee == "Request" else None | |
| return None | |
| def _command_text(self, n, literal_only=False): | |
| if n is None: | |
| return "dynamic" | |
| if _t(n) in ("sys.executable", "process.execPath", "process.argv0"): | |
| return "python" if self.py else "node" | |
| v = self.lit(n) | |
| if v is not None: | |
| return v | |
| if n.type in ("list", "array", "tuple"): | |
| parts = [("python" if self.py else "node") if _t(x) in ("sys.executable", "process.execPath") else self.lit(x) | |
| for x in n.named_children] | |
| if literal_only and any(p is None for p in parts): | |
| parts = [p if p is not None else "X" for p in parts] | |
| return " ".join(parts) if any(parts) else None | |
| return " ".join(p if p is not None else "{" + _t(x) + "}" for p, x in zip(parts, n.named_children)) | |
| if n.type == "template_string" and not literal_only: | |
| return clip(_t(n)[1:-1], 140) | |
| if n.type == "template_string": | |
| return re.sub(r"\$\{[^}]*\}", "X", _t(n)[1:-1]) | |
| if n.type == "identifier" and _t(n) in self.exprs and len(self.exprs[_t(n)]) == 1: | |
| return self._command_text(self.exprs[_t(n)][0], literal_only) | |
| return None if literal_only else self.show(n) | |
| def _mark_consumed(self, n): | |
| stack = [n] | |
| while stack: | |
| x = stack.pop() | |
| if x.type in ("string", "template_string"): | |
| self.consumed.add((x.start_byte, x.end_byte)) | |
| stack.extend(x.named_children) | |
| if n.type == "identifier" and _t(n) in self.exprs: | |
| for r in self.exprs[_t(n)]: | |
| self._mark_consumed(r) | |
| def _file_write(self, c): | |
| k = c.callee | |
| last = k.rsplit(".", 1)[-1] | |
| if self.py: | |
| if k in ("open", "io.open", "codecs.open", "builtins.open", "os.open"): | |
| if k == "os.open": | |
| flags = _t(self._arg(c, 1, "flags")) if self._arg(c, 1, "flags") is not None else "" | |
| if not re.search(r"O_(WRONLY|RDWR|CREAT|APPEND|TRUNC)", flags): | |
| return None | |
| elif not self._write_mode(c): | |
| return None | |
| t = self.show(self._arg(c, 0, "file")) | |
| mode = self.lit(c.kwargs.get("mode") or (c.args[1] if len(c.args) > 1 else None)) or "" | |
| return t, ("append; " if "a" in mode else "") + "scope " + P.path_scope(t) | |
| if last in ("write_text", "write_bytes") and c.recv is not None: | |
| t = self.show(c.recv) | |
| return t, f"{last}; scope {P.path_scope(t)}" | |
| if last == "open" and c.recv is not None and k not in ("open",) and not k.startswith(("tarfile", "zipfile", "gzip", "webbrowser", "os.")): | |
| mode = self.lit(self._arg(c, 0, "mode")) | |
| if mode and any(ch in mode for ch in "wax+"): | |
| t = self.show(c.recv) | |
| return t, ("append; " if "a" in mode else "") + "scope " + P.path_scope(t) | |
| if k in PY_COPY or last in ("copy", "copy2", "copyfile", "copytree", "move") and k.startswith("shutil"): | |
| t = self.show(self._arg(c, 1, "dst")) | |
| return t, f"{last}; scope {P.path_scope(t)}" | |
| if k in ("os.symlink", "os.link"): | |
| t = self.show(self._arg(c, 1, "dst")) | |
| return t, f"{last}; scope {P.path_scope(t)}" | |
| if k in ("os.chmod", "os.lchmod") or last == "chmod" and c.recv is not None and not k.startswith("os."): | |
| t = self.show(self._arg(c, 0, "path")) if k.startswith("os.") else self.show(c.recv) | |
| mode = _t(self._arg(c, 1, "mode") if k.startswith("os.") else self._arg(c, 0, "mode")) | |
| return t, f"chmod {clip(mode, 30)}; scope {P.path_scope(t)}" | |
| if last in ("symlink_to", "hardlink_to", "rename", "replace") and c.recv is not None and not k.startswith(("os.", "?.replace", "str")): | |
| if last in ("rename", "replace") and not re.search(r"Path|path|file|dir|target|dest", _t(c.recv)): | |
| return None | |
| t = self.show(c.recv) if last.endswith("_to") else self.show(self._arg(c, 0)) | |
| return t, f"{last}; scope {P.path_scope(t)}" | |
| return None | |
| if k in JS_WRITE or k in JS_BARE_WRITE: | |
| i = JS_WRITE.get(k, 1 if k in ("copyFileSync", "renameSync", "symlinkSync") else 0) | |
| t = self.show(self._arg(c, i)) | |
| return t, f"{last}; scope {P.path_scope(t)}" | |
| if k in ("fs.open", "fs.openSync") and len(c.args) > 1 and (self.lit(c.args[1]) or "")[:1] in ("w", "a"): | |
| t = self.show(c.args[0]) | |
| return t, f"{last}; scope {P.path_scope(t)}" | |
| return None | |
| def _file_delete(self, c): | |
| k = c.callee | |
| last = k.rsplit(".", 1)[-1] | |
| if self.py: | |
| if k in PY_DELETE or k in ("rmtree",): | |
| return self.show(self._arg(c, 0, "path")) | |
| if last in ("unlink", "rmdir") and c.recv is not None and not k.startswith("os."): | |
| return self.show(c.recv) | |
| return None | |
| if k in JS_DELETE or k in JS_BARE_DELETE: | |
| return self.show(self._arg(c, 0)) | |
| return None | |
| # ------------------------------------------------------------------ hooks, env, strings, regex layer | |
| def _hooks(self): | |
| name = self.u.path.rsplit("/", 1)[-1] | |
| if not self.py: | |
| return | |
| for f, (fnode, _) in self.funcs.items(): | |
| if re.match(r"pytest_(configure|sessionstart|sessionfinish|unconfigure|collection\w*|runtest\w*|" | |
| r"load_initial_conftests|addoption|plugin_registered|report\w*|terminal_summary)$", f): | |
| self.add("install_or_startup_hook", _line(fnode), f"pytest hook {f}()", "runs inside pytest", | |
| context=self.context) | |
| if f == "run" and re.search(r"class \w+\((\w+\.)?(install|develop|egg_info|build_py|build_ext|sdist|bdist_wheel)\)", self.text): | |
| pass | |
| for m in re.finditer(r"^class (\w+)\(([\w.]*\b(install|develop|egg_info|build_py|build_ext|sdist|bdist_wheel|" | |
| r"BuildHookInterface|MetadataHookInterface|BuildHook))\)", self.text, re.M): | |
| ln = self.text.count("\n", 0, m.start()) + 1 | |
| self.add("install_or_startup_hook", ln, f"class {m.group(1)}({m.group(2)})", "build/install hook class", | |
| source="regex") | |
| for i, l in enumerate(self.lines, 1): | |
| if re.search(r"\bautouse\s*=\s*True", l): | |
| nxt = next((self.lines[j] for j in range(i, min(i + 4, len(self.lines))) if "def " in self.lines[j]), "") | |
| fm = re.search(r"def (\w+)", nxt) | |
| self.add("install_or_startup_hook", i, f"autouse fixture {fm.group(1) if fm else '?'}", | |
| "runs for every test", source="regex") | |
| if re.search(r"\bcmdclass\s*=", l): | |
| self.add("install_or_startup_hook", i, "setup(cmdclass=…)", "custom install/build commands", source="regex") | |
| for c in self.calls: | |
| if c.top and c.callee in self.funcs and name != "setup.py": | |
| self.add("install_or_startup_hook", c.line, f"import-time call {c.callee}()", | |
| "module-level call of a local function", context=self.context) | |
| def strings_and_lines(self, root): | |
| # embedded command text in string literals | |
| stack = [root] | |
| while stack: | |
| n = stack.pop() | |
| if n.type in ("concatenated_string", "string", "template_string", "binary_expression") and _end(n) > _line(n): | |
| v = self.lit(n) | |
| if isinstance(v, str) and len(v) >= 200 and re.fullmatch(r"[A-Za-z0-9+/=_\-\s]+", v) and \ | |
| re.search(r"\d", v) and re.search(r"[A-Z]", v) and re.search(r"[a-z]", v): | |
| self.add("obfuscation_markers", _line(n), f"encoded string literal ({len(''.join(v.split()))} chars)", "long encoded literal across lines", source="ast", | |
| end=_end(n)) | |
| continue | |
| if n.type in ("string", "template_string") and (n.start_byte, n.end_byte) not in self.consumed: | |
| v = self.lit(n) | |
| if v is None and n.type == "template_string": | |
| v = re.sub(r"\$\{[^}]*\}", "X", _t(n)[1:-1]) | |
| if v is None and self.py and n.type == "string": | |
| v = re.sub(r"\{[^}]*\}", "X", re.sub(r"^[a-zA-Z]{0,2}('''|\"\"\"|'|\")|('''|\"\"\"|'|\")$", "", _t(n))) | |
| if isinstance(v, str) and shell.looks_like_shell(v) and self.u.in_focus(_line(n), _end(n)): | |
| for b in shell.analyse(v, self.u.path, base_line=_line(n), in_focus=self.u.in_focus, | |
| context=self.context, embedded="command text in string literal", | |
| emit_spawn=False, line_patterns=False): | |
| if b.kind in ("env_read",): | |
| continue | |
| b.evidence = self.ev(b.line) if b.line <= len(self.lines) else b.evidence | |
| self.out.append(b) | |
| continue | |
| stack.extend(n.children) | |
| # line regex layer | |
| comment = ("#",) if self.py else ("//", "/*", "*") | |
| env_names, bulk = {}, [] | |
| docs = set() | |
| if self.py: | |
| st = [root] | |
| while st: | |
| x = st.pop() | |
| if x.type == "string" and x.parent is not None and x.parent.type in ("module", "block") or \ | |
| x.type == "expression_statement" and len(x.named_children) == 1 and x.named_children[0].type == "string": | |
| docs.update(range(_line(x), _end(x) + 1)) | |
| elif x.type in ("module", "block", "function_definition", "class_definition", "decorated_definition"): | |
| st.extend(x.named_children) | |
| for i, l in enumerate(self.lines, 1): | |
| s = l.strip() | |
| if not s or s.startswith(comment) or i in docs or not self.u.in_focus(i): | |
| continue | |
| for label in P.secret_hits(s): | |
| self.add("secret_or_credential_path", i, label, source="regex") | |
| for rx, label in P.TLS_OFF: | |
| if rx.search(s): | |
| self.add("security_setting_change", i, label, "TLS verification off" if "warn" not in label else "", | |
| source="regex") | |
| for rx, label in P.PRIV_GRANT: | |
| if rx.search(s): | |
| self.add("security_setting_change", i, label, "privilege/permission", source="regex") | |
| for label, rx in P.PERSIST_LOCATIONS: | |
| if rx.search(s): | |
| self.add("persistence", i, label, "location referenced", source="regex") | |
| for op, det in P.obfuscation(l, self.lang): | |
| self.add("obfuscation_markers", i, op, det, source="regex") | |
| if self.py: | |
| for m in re.finditer(r"\b(?:os\.)?environ(?:b)?\s*\[\s*['\"]([^'\"]+)['\"]\s*\](\s*=(?!=))?|" | |
| r"\b(?:os\.)?(?:environ\.(?:get|pop|setdefault)|getenv)\(\s*['\"]([^'\"]+)['\"]", s): | |
| name = m.group(1) or m.group(3) | |
| if m.group(2): | |
| continue | |
| env_names.setdefault(name, i) | |
| if re.search(r"\bos\.environ\.(items|keys|values|copy)\(|dict\(os\.environ\)|\*\*os\.environ|" | |
| r"\bfor \w+(, *\w+)? in os\.environ\b|json\.dumps\(\s*(dict\()?os\.environ", s): | |
| bulk.append(i) | |
| elif re.search(r"\b(os\.)?getenv\(\s*[A-Za-z_]|\benviron\.get\(\s*[A-Za-z_]|\benviron\[\s*[A-Za-z_]", s): | |
| env_names.setdefault("dynamic name", i) | |
| else: | |
| for m in re.finditer(r"\bprocess\.env\.([A-Za-z_$][\w$]*)(\s*=(?!=))?|\bprocess\.env\[\s*['\"`]([^'\"`]+)['\"`]\s*\](\s*=(?!=))?|" | |
| r"\b(?:Deno\.env\.get|Bun\.env\.get)\(\s*['\"]([^'\"]+)|\bimport\.meta\.env\.([A-Za-z_]\w*)|\bBun\.env\.([A-Za-z_]\w*)", s): | |
| if m.group(2) or m.group(4): | |
| continue | |
| name = m.group(1) or m.group(3) or m.group(5) or m.group(6) or m.group(7) | |
| env_names.setdefault(name, i) | |
| if re.search(r"Object\.(keys|entries|values|assign)\(\s*\{?\s*(\.\.\.)?process\.env|JSON\.stringify\(\s*process\.env|" | |
| r"\.\.\.process\.env\b|[(,:]\s*process\.env\s*[),}]|=\s*process\.env\s*;?$|Deno\.env\.toObject", s): | |
| bulk.append(i) | |
| elif re.search(r"process\.env\[\s*[A-Za-z_]", s): | |
| env_names.setdefault("dynamic name", i) | |
| by_line = {} | |
| for name, ln in env_names.items(): | |
| by_line.setdefault(ln, []).append(name) | |
| for ln, names in sorted(by_line.items()): | |
| det = "secret-like name" if any(P.SECRET_ENV_NAME.search(n) for n in names) else "" | |
| self.add("env_read", ln, ", ".join(sorted(names)), det, source="regex") | |
| for ln in bulk: | |
| markers = sorted(set(m.group(1).upper() for m in P.SECRET_MARKER_LITERAL.finditer(self.text))) | |
| det = "enumerates all variables" + (f"; name filter literals: {', '.join(markers)}" if markers else "") | |
| self.add("env_read", ln, "* (all)", det, source="regex") | |
| if markers: | |
| self.add("secret_or_credential_path", ln, "env vars matching " + "|".join(markers), | |
| "secret-like environment selection", source="regex") | |
| def analyse(unit, lang, context=""): | |
| fa = FileAnalysis(unit, lang, context) | |
| tree = parser(lang).parse(fa.src) | |
| root = tree.root_node | |
| fa.collect(root) | |
| fa.propagate() | |
| fa.apply() | |
| fa.strings_and_lines(root) | |
| return fa.out | |