Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions desloppify/engine/detectors/coverage/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def _infer_lang_name(test_files: set[str], production_files: set[str]) -> str |
".js": "typescript",
".jsx": "typescript",
".cs": "csharp",
".php": "php",
".go": "go",
}
counts: dict[str, int] = {}
for file_path in paths:
Expand Down
14 changes: 13 additions & 1 deletion desloppify/engine/detectors/patterns/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
r"""(?:^|[\s,;(]) # start of line or delimiter
(?:const|let|var|export)? # optional JS/TS keyword
\s*
\$? # optional PHP $ sigil
([A-Za-z_]\w*) # variable name (captured)
\s*[:=]\s* # assignment
(['"`]) # opening quote
Expand Down Expand Up @@ -96,10 +97,19 @@
"import.meta.env",
"os.environ.get(",
"os.environ[",
# PHP
"env(",
"getenv(",
"$_ENV[",
"$_SERVER[",
"config(",
)

# Insecure random usage near security-sensitive contexts.
RANDOM_CALLS = re.compile(r"(?:Math\.random|random\.random|random\.randint)\s*\(")
RANDOM_CALLS = re.compile(
r"(?:Math\.random|random\.random|random\.randint"
r"|(?<!\w)rand|(?<!\w)mt_rand|(?<!\w)array_rand)\s*\("
)
SECURITY_CONTEXT_WORDS = re.compile(
r"(?i)(?:token|key|nonce|session|salt|secret|password|otp|csrf|auth)",
)
Expand Down Expand Up @@ -137,6 +147,8 @@
r"(?:console\.(?:log|warn|error|info|debug)|"
r"log(?:ger)?\.(?:info|debug|warning|error|critical)|"
r"logging\.(?:info|debug|warning|error|critical)|"
r"Log::(?:info|debug|warning|error|critical|notice|alert|emergency)|"
r"\berror_log|"
r"\bprint)\s*\(",
)

Expand Down
2 changes: 1 addition & 1 deletion desloppify/engine/detectors/security/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def _insecure_random_entries(
summary="Insecure random used in security context",
severity="medium",
confidence="medium",
remediation="Use secrets.token_hex() (Python) or crypto.randomUUID() (JS)",
remediation="Use secrets.token_hex() (Python), crypto.randomUUID() (JS), or random_bytes() (PHP)",
),
)
]
Expand Down
19 changes: 19 additions & 0 deletions desloppify/engine/detectors/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@
"put",
"delete",
"patch", # HTTP methods
# PHP magic + framework-polymorphic methods
"__construct",
"__destruct",
"__get",
"__set",
"__isset",
"__unset",
"__toString",
"__invoke",
"__clone",
"__debugInfo",
"__serialize",
"__unserialize",
"boot",
"register",
"render",
"toArray",
"rules",
"authorize",
}


Expand Down
3 changes: 2 additions & 1 deletion desloppify/languages/_framework/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def generic_lang(
treesitter_spec=None,
zone_rules: list[ZoneRule] | None = None,
test_coverage_module: object | None = None,
entry_patterns: list[str] | None = None,
) -> LangConfig:
"""Build and register a generic language plugin from tool specs.

Expand Down Expand Up @@ -247,7 +248,7 @@ def generic_lang(
exclusions=exclude or [],
default_src=default_src,
build_dep_graph=dep_graph_fn,
entry_patterns=[],
entry_patterns=entry_patterns or [],
barrel_names=set(),
phases=phases,
fixers=fixers,
Expand Down
4 changes: 3 additions & 1 deletion desloppify/languages/_framework/treesitter/_complexity.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def compute(content: str, lines: list[str], *, _filepath: str = "") -> tuple[int
"for_statement", "for_expression", "for_in_statement",
"while_statement", "while_expression", "do_statement",
"loop_expression",
"case_clause", "match_arm",
"case_clause", "match_arm", "match_conditional_expression",
"catch_clause", "rescue", "except_clause",
"ternary_expression", "conditional_expression",
# Logical operators (short-circuit evaluation).
Expand Down Expand Up @@ -306,6 +306,8 @@ def compute(content: str, lines: list[str], *, _filepath: str = "") -> tuple[int
"anonymous_function", "block_argument",
# Go anonymous functions
"func_literal",
# PHP anonymous functions (``function() { ... }``)
"anonymous_function_creation_expression",
})


Expand Down
111 changes: 105 additions & 6 deletions desloppify/languages/_framework/treesitter/_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ def ts_build_dep_graph(
# Strip surrounding quotes if present.
import_text = import_text.strip("\"'`")

# Prepend group-use prefix when present (PHP ``use A\B\{C, D}``).
prefix_node = _unwrap_node(captures.get("prefix"))
if prefix_node is not None:
prefix_raw = prefix_node.text
prefix_text = (
prefix_raw.decode("utf-8", errors="replace")
if isinstance(prefix_raw, bytes)
else str(prefix_raw)
).strip("\"'`")
import_text = f"{prefix_text}\\{import_text}"

resolved = spec.resolve_import(import_text, filepath, scan_path)
if resolved is None:
continue
Expand Down Expand Up @@ -289,17 +300,105 @@ def resolve_cxx_include(import_text: str, source_file: str, scan_path: str) -> s
return None


_PHP_FILE_CACHE: dict[tuple[str, str], str | None] = {}


def _find_php_file(filename: str, scan_path: str) -> str | None:
"""Search common PHP source roots for *filename*, cached."""
key = (filename, scan_path)
if key in _PHP_FILE_CACHE:
return _PHP_FILE_CACHE[key]
for root in ("app", "src", "lib"):
root_dir = os.path.join(scan_path, root)
if not os.path.isdir(root_dir):
continue
for dirpath, _dirs, files in os.walk(root_dir):
if filename in files:
result = os.path.join(dirpath, filename)
_PHP_FILE_CACHE[key] = result
return result
_PHP_FILE_CACHE[key] = None
return None


_PHP_COMPOSER_CACHE: dict[str, dict[str, str]] = {}


def _read_composer_psr4(scan_path: str) -> dict[str, str]:
"""Read PSR-4 autoload mappings from composer.json, cached per scan_path.

Returns ``{namespace_prefix: directory}`` e.g. ``{"App\\\\": "app/"}``.
"""
if scan_path in _PHP_COMPOSER_CACHE:
return _PHP_COMPOSER_CACHE[scan_path]

mappings: dict[str, str] = {}
composer_path = os.path.join(scan_path, "composer.json")
try:
import json

with open(composer_path) as f:
data = json.load(f)
for section in ("autoload", "autoload-dev"):
psr4 = data.get(section, {}).get("psr-4", {})
for prefix, dirs in psr4.items():
# dirs can be a string or list of strings
if isinstance(dirs, str):
mappings[prefix] = dirs
elif isinstance(dirs, list) and dirs:
mappings[prefix] = dirs[0]
except (OSError, ValueError, KeyError):
pass
_PHP_COMPOSER_CACHE[scan_path] = mappings
return mappings


def resolve_php_import(import_text: str, source_file: str, scan_path: str) -> str | None:
"""Resolve PHP use statements via PSR-4-like mapping.
"""Resolve PHP use statements via PSR-4 mapping.

1. Reads composer.json autoload psr-4 mappings (cached).
2. Falls back to common PSR-4 roots (src/, app/, lib/).
3. For bare trait names, searches common directories for ``Name.php``.

Maps `App\\Models\\User` → src/Models/User.php or app/Models/User.php.
Maps ``App\\Models\\User````app/Models/User.php``.
"""
# Parse composer.json for autoload mapping if available.
# Strip leading backslash from FQNs (e.g. ``\App\Traits\HasRoles``).
import_text = import_text.lstrip("\\")

parts = import_text.replace("\\", "/").split("/")
if len(parts) < 2:
return None

# Try common PSR-4 roots.
# Bare name (e.g. trait ``use HasUuid;``) — search common dirs.
if len(parts) < 2:
name = parts[0] if parts else ""
if not name or not name[0].isupper():
return None
return _find_php_file(name + ".php", scan_path)


# Try composer.json PSR-4 mappings first.
psr4 = _read_composer_psr4(scan_path)
if psr4:
# Reconstruct backslash-separated namespace for prefix matching.
ns = import_text.replace("/", "\\")
if not ns.endswith("\\"):
ns_lookup = ns
else:
ns_lookup = ns

for prefix, directory in sorted(psr4.items(), key=lambda x: -len(x[0])):
# Normalize prefix: ensure trailing backslash
norm_prefix = prefix.rstrip("\\") + "\\"
if ns_lookup.startswith(norm_prefix) or ns_lookup + "\\" == norm_prefix:
remainder = ns_lookup[len(norm_prefix):]
if not remainder:
continue
rel_path = remainder.replace("\\", os.sep) + ".php"
candidate = os.path.join(scan_path, directory, rel_path)
candidate = os.path.normpath(candidate)
if os.path.isfile(candidate):
return candidate

# Fallback: try common PSR-4 roots by stripping namespace prefixes.
for prefix_len in range(1, min(3, len(parts))):
rel_path = os.path.join(*parts[prefix_len:]) + ".php"
for src_root in ["src", "app", "lib", "."]:
Expand Down
12 changes: 12 additions & 0 deletions desloppify/languages/_framework/treesitter/_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,15 @@
(namespace_use_declaration
(namespace_use_clause
(qualified_name) @path)) @import
(namespace_use_declaration
(namespace_name) @prefix
(namespace_use_group
(namespace_use_clause
(name) @path))) @import
(use_declaration
(qualified_name) @path) @import
(use_declaration
(name) @path) @import
""",
resolve_import=resolve_php_import,
class_query="""
Expand All @@ -260,6 +269,9 @@
(trait_declaration
name: (name) @name
body: (declaration_list) @body) @class
(enum_declaration
name: (name) @name
body: (enum_declaration_list) @body) @class
""",
log_patterns=(
r"^\s*(?:echo |print |var_dump|error_log|Log::)",
Expand Down
42 changes: 41 additions & 1 deletion desloppify/languages/_framework/treesitter/_unused_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@ def detect_unused_imports(
if not raw_path:
continue

# Check for alias (e.g. PHP ``use Foo as Bar``, Python ``import X as Y``).
# When an alias is present, search for the alias name instead.
alias_name = _extract_alias(import_node)

# Extract the imported name from the path.
name = _extract_import_name(raw_path)
name = alias_name or _extract_import_name(raw_path)
if not name:
continue

Expand All @@ -85,6 +89,42 @@ def detect_unused_imports(
return entries


def _extract_alias(import_node) -> str | None:
"""Extract alias name from ``use Foo as Bar`` or ``import X as Y``.

Walks children of the import node looking for an alias/name node
after an ``as`` keyword. Returns the alias text or None.
"""
found_as = False
for child in _iter_children(import_node):
text = _node_text(child)
if text == "as":
found_as = True
continue
# The node immediately after "as" is the alias name.
if found_as and child.type in ("name", "identifier", "namespace_name"):
return _node_text(child)
return None


def _iter_children(node):
"""Recursively yield terminal-ish children relevant to alias extraction.

Only descends into namespace_use_clause / import_clause nodes (the
immediate import container) — avoids descending into unrelated subtrees.
"""
for i in range(node.child_count):
child = node.children[i]
# Yield leaf-like nodes (keywords, identifiers).
if child.child_count == 0:
yield child
elif child.type in (
"namespace_use_clause", "import_clause",
"namespace_alias", "as_pattern",
):
yield from _iter_children(child)


def _extract_import_name(import_path: str) -> str:
"""Extract the usable name from an import path.

Expand Down
Loading