Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/mypy/mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,6 @@ ignore_missing_imports = True

[mypy-ghidra.*]
ignore_missing_imports = True

[mypy-tree_sitter.*]
ignore_missing_imports = True
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from PyInstaller.utils.hooks import collect_data_files


# Tree-sitter signature lookups use importlib.resources, so PyInstaller must
# bundle the JSON files alongside the package.
datas = collect_data_files("capa.features.extractors.ts.signatures")
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: build

on:
pull_request:
branches: [ master ]
branches: [ master, feature/script-analysis ]
paths-ignore:
- 'web/**'
- 'doc/**'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ruff-format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: ruff auto-format

on:
pull_request:
branches: [ master ]
branches: [ master, feature/script-analysis ]
paths-ignore:
- 'web/**'
- 'doc/**'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ name: CI

on:
push:
branches: [ master ]
branches: [ master, feature/script-analysis ]
paths-ignore:
- 'web/**'
- 'doc/**'
- '**.md'
pull_request:
branches: [ master ]
branches: [ master, feature/script-analysis ]
paths-ignore:
- 'web/**'
- 'doc/**'
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ This release includes Ghidra PyGhidra support, performance improvements, depende

### New Features

- Tree-Sitter Script Analysis @adamstorek #1080 @EdoardoAllegrini #2931 @saniyafatima07 #3102
- ghidra: support PyGhidra @mike-hunhoff #2788
- vmray: extract number features from whitelisted void_ptr parameters (hKey, hKeyRoot) @adeboyedn #2835

Expand Down
2 changes: 1 addition & 1 deletion capa/capabilities/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def find_static_capabilities(
match_count += len(matches_)

logger.debug(
"analyzed function 0x%x and extracted %d features, %d matches in %0.02fs",
"analyzed function %s and extracted %d features, %d matches in %0.02fs",
f.address,
code_capabilities.feature_count,
match_count,
Expand Down
24 changes: 24 additions & 0 deletions capa/features/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,30 @@ def __hash__(self):
return int.__hash__(self)


class FileOffsetRangeAddress(Address):
"""an address range relative to the start of a file"""

def __init__(self, start_byte, end_byte):
self.start_byte = start_byte
self.end_byte = end_byte

def __eq__(self, other):
if not isinstance(other, FileOffsetRangeAddress):
return NotImplemented
return (self.start_byte, self.end_byte) == (other.start_byte, other.end_byte)

def __lt__(self, other):
if not isinstance(other, FileOffsetRangeAddress):
return NotImplemented
return (self.start_byte, self.end_byte) < (other.start_byte, other.end_byte)

def __hash__(self):
return hash((self.start_byte, self.end_byte))

def __repr__(self):
return f"file(0x{self.start_byte:x}, 0x{self.end_byte:x})"


class DNTokenAddress(int, Address):
"""a .NET token"""

Expand Down
10 changes: 9 additions & 1 deletion capa/features/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,17 @@ def evaluate(self, features: "capa.engine.FeatureSet", short_circuit=True):
return Result(False, self, [])


class ScriptLanguage(Feature):
def __init__(self, value: str, description=None):
super().__init__(value, description=description)
self.name = "script language"


FORMAT_PE = "pe"
FORMAT_ELF = "elf"
FORMAT_DOTNET = "dotnet"
VALID_FORMAT = (FORMAT_PE, FORMAT_ELF, FORMAT_DOTNET)
FORMAT_SCRIPT = "script"
VALID_FORMAT = (FORMAT_PE, FORMAT_ELF, FORMAT_DOTNET, FORMAT_SCRIPT)
# internal only, not to be used in rules
FORMAT_AUTO = "auto"
FORMAT_SC32 = "sc32"
Expand All @@ -508,6 +515,7 @@ def evaluate(self, features: "capa.engine.FeatureSet", short_circuit=True):
FORMAT_PE,
FORMAT_ELF,
FORMAT_DOTNET,
FORMAT_SCRIPT,
FORMAT_FREEZE,
FORMAT_RESULT,
FORMAT_BINEXPORT2,
Expand Down
4 changes: 4 additions & 0 deletions capa/features/extractors/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@
VALID_ARCH,
FORMAT_FREEZE,
FORMAT_RESULT,
FORMAT_SCRIPT,
Arch,
Format,
String,
Feature,
)
from capa.features.freeze import is_freeze
from capa.features.address import NO_ADDRESS, Address, FileOffsetAddress
from capa.features.extractors.ts.autodetect import is_script

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -78,6 +80,8 @@ def extract_format(buf: bytes) -> Iterator[tuple[Feature, Address]]:
# we don't know what it is exactly, but may support it (e.g. a dynamic CAPE sandbox report)
# skip verdict here and let subsequent code analyze this further
return
elif is_script(buf):
yield Format(FORMAT_SCRIPT), NO_ADDRESS
else:
# we likely end up here:
# 1. handling a file format (e.g. macho)
Expand Down
58 changes: 58 additions & 0 deletions capa/features/extractors/script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Tuple, Iterator

from capa.features.common import OS, OS_ANY, ARCH_ANY, FORMAT_SCRIPT, Arch, Format, Feature, ScriptLanguage
from capa.features.address import NO_ADDRESS, Address, FileOffsetRangeAddress

# Can be used to instantiate tree_sitter Language objects (see ts/query.py)
LANG_BASH = "bash"
LANG_CS = "c_sharp"
LANG_HTML = "html"
LANG_JS = "javascript"
LANG_PY = "python"
LANG_TEM = "embedded_template"

EXT_ASPX = (".aspx", ".aspx_")
EXT_BASH = (".bash", ".sh", ".bash_", ".sh_")
EXT_CS = (".cs", ".cs_")
EXT_HTML = (".html", ".html_")
EXT_PY = (".py", ".py_")


LANGUAGE_FEATURE_FORMAT = {
LANG_BASH: "Bash",
LANG_CS: "C#",
LANG_HTML: "HTML",
LANG_JS: "JavaScript",
LANG_PY: "Python",
LANG_TEM: "Embedded Template",
}


def extract_arch() -> Iterator[Tuple[Feature, Address]]:
yield Arch(ARCH_ANY), NO_ADDRESS


def extract_language(language: str, addr: FileOffsetRangeAddress) -> Iterator[Tuple[Feature, Address]]:
yield ScriptLanguage(LANGUAGE_FEATURE_FORMAT[language]), addr


def extract_os() -> Iterator[Tuple[Feature, Address]]:
yield OS(OS_ANY), NO_ADDRESS


def extract_format() -> Iterator[Tuple[Feature, Address]]:
yield Format(FORMAT_SCRIPT), NO_ADDRESS
Empty file.
95 changes: 95 additions & 0 deletions capa/features/extractors/ts/autodetect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Optional
from pathlib import Path

from tree_sitter import Node, Tree, Query, Parser, Language, QueryCursor

from capa.features.extractors.script import (
EXT_CS,
EXT_PY,
LANG_CS,
LANG_PY,
EXT_ASPX,
EXT_BASH,
EXT_HTML,
LANG_TEM,
LANG_BASH,
LANG_HTML,
)
from capa.features.extractors.ts.query import TS_LANGUAGES


def is_script(buf: bytes) -> bool:
try:
return bool(get_language_ts(buf))
except ValueError:
return False


def _parse(ts_language: Language, buf: bytes) -> Optional[Tree]:
try:
parser = Parser(ts_language)
return parser.parse(buf)
except ValueError:
return None


def _contains_errors(ts_language, node: Node) -> bool:
query = Query(ts_language, "(ERROR) @error")
return bool(QueryCursor(query).captures(node))


def get_language_ts(buf: bytes) -> str:
for language, ts_language in TS_LANGUAGES.items():
if language == LANG_BASH:
continue
Comment on lines +57 to +58

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide context for this change.

tree = _parse(ts_language, buf)
if tree and not _contains_errors(ts_language, tree.root_node):
return language
raise ValueError("failed to parse the language")


def get_template_language_ts(buf: bytes) -> str:
for language, ts_language in TS_LANGUAGES.items():
if language in [LANG_TEM, LANG_HTML]:
continue
tree = _parse(ts_language, buf)
if tree and not _contains_errors(ts_language, tree.root_node):
return language
raise ValueError("failed to parse the language")


def get_language_from_ext(path: str) -> str:
if path.endswith(EXT_ASPX):
return LANG_TEM
if path.endswith(EXT_BASH):
return LANG_BASH
if path.endswith(EXT_CS):
return LANG_CS
if path.endswith(EXT_HTML):
return LANG_HTML
if path.endswith(EXT_PY):
return LANG_PY
raise ValueError(f"{path} has an unrecognized or an unsupported extension.")


def get_language(path: Path) -> str:
try:
return get_language_from_ext(str(path))
except ValueError:
with path.open("rb") as f:
buf = f.read()
return get_language_ts(buf)
Loading
Loading