Skip to content
Merged
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
23 changes: 15 additions & 8 deletions docs/agent-usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,21 @@ test debt, not product bugs.

## Known limitations (not fixed — scope, not a quick bug)

- **No Rust taint analysis.** `security --check taint` only analyzes
Python/JS/TS/TSX (`ast_taint_engine.get_supported_languages()`). A Tauri
app that shells out via `std::process::Command` (verified: this workspace
has several `Command::new(...)` sinks fed by `std::env::var()` sources in
`.rs` files) gets zero taint coverage on the Rust side. This is a real
feature gap for `harus berkerja di rs` — building Rust source/sink rules
+ AST walking is a multi-day feature, not a bug fix, so it wasn't
attempted this session. Tracked as a GitHub issue for follow-up.
- **Rust taint is a narrow MVP, not general-purpose** (issue #240, MVP
shipped). `security --check taint` now covers one Rust pattern:
a `#[tauri::command]` function parameter (untrusted-by-construction, since
that's exactly how Tauri's IPC delivers frontend data) reaching a
dangerous sink (`Command::new`, `std::process::Command`, `std::fs`
path ops) **within the same function body**. It's regex-based (documented
trade-off — possible false positives on sanitized params, since v1 has no
sanitizer allowlist) and reports every param→sink flow for review. What's
still NOT covered: (a) full cross-language IPC correlation — tracing a
value from a TS `invoke("cmd", {...})` call across the boundary into the
matching Rust command (needs cross-language graph edges the current
architecture doesn't build); (b) general Rust taint for non-Tauri-command
functions; (c) full AST precision matching the Python/JS engine. See
`docs/design/0240-tauri-command-param-taint.md`. Run it standalone with
`security --check taint --language rust`.
- **Rust `impl`-block dead-code false positives** (issue #228) — separate,
deeper false-positive source than the test-function one fixed this
session. Still open.
Expand Down
111 changes: 111 additions & 0 deletions docs/design/0240-tauri-command-param-taint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Design Doc: Rust `#[tauri::command]` parameter-to-sink taint (MVP)

> **Status:** Accepted (scope narrowed from original issue during design)
> **Date:** 2026-07-12
> **Author:** Claude (direct implementation, no worker — user directive)
> **Related issues:** #240

---

## Problem

`security --check taint` (`ast_taint_engine.get_supported_languages()`) only
covers Python/JS/TS/TSX — zero taint coverage for Rust. Verified on a real
Tauri workspace this session: genuine `Command::new(...)` sinks in `.rs`
files fed by `std::env::var()` sources, invisible to the taint scanner
entirely.

## Goal (narrowed from the original issue)

Issue #240 originally scoped this as full cross-language taint: track a
value from `invoke("cmd", {arg: userInput})` on the TypeScript side, across
the IPC boundary, into the matching `#[tauri::command] fn cmd(arg: ...)` on
the Rust side, to a dangerous sink inside that function.

**That full cross-language correlation is out of scope for this MVP** —
matching a TS `invoke()` call site to its Rust command implementation
requires resolving the string literal command name against the
`#[tauri::command]` function name across files/languages, which the current
parser/graph architecture doesn't do (graph edges are same-language call
edges, not cross-language string-literal-to-attribute correlations). Doing
that correctly is a separate, larger effort.

**What ships in this MVP instead**, and why it still delivers most of the
real-world value: every parameter of a `#[tauri::command]`-annotated Rust
function is, by construction, untrusted input from the frontend — Tauri's
own IPC dispatch is exactly how that data arrives. So the source doesn't
need to be traced from the TS side at all; **the `#[tauri::command]`
attribute itself marks the function's parameters as taint sources**. From
there it's an intra-procedural (single-file) taint problem — the same
class of analysis the existing Python/JS engine already does, just applied
to Rust with a smaller, hand-picked sink list.

This catches the exact pattern found on the real workspace this session
(env var / parameter flowing into `Command::new()`), without requiring
cross-language correlation.

## Changes

### Approach: regex-based, not full tree-sitter AST

Given the scope and time budget, this ships as a **regex-based pattern
matcher** (consistent with how several other CodeLens engines — e.g.
`regexaudit_engine.py` — already work), not a full tree-sitter AST walker
matching the precision of `ast_taint_engine.py`'s Python/JS engine. This is
an explicit, documented trade-off: fewer false negatives on obfuscated code
paths, more false positives possible on parameters that are actually
sanitized before reaching a sink in ways the regex can't see. Full
AST-based Rust taint (matching JS/Python precision) is future work, not
attempted here.

### Detection logic

1. Find every `#[tauri::command]` attribute immediately followed by `fn
name(params...) ... { body }` (brace-matched to find the function body
boundary).
2. Extract parameter names from the signature.
3. Within the function body, flag any line where a parameter name appears
as a direct argument (or in a format!/concatenation immediately feeding)
one of a small, high-confidence Rust sink list:
- `Command::new(...)` / `.arg(...)` chains (command injection)
- `std::fs::` path operations (`read`, `write`, `remove_file`,
`create_dir`, ...) (path traversal)
- `std::process::Command`
4. No sanitizer-detection in v1 (unlike the Python/JS engine's
`PYTHON_SANITIZERS`/`JS_SANITIZERS`) — every match is reported as a
finding for human/agent review, not auto-suppressed. Adding a
Rust sanitizer allowlist is straightforward follow-up once this MVP is
validated against real findings.

### New Files

- `scripts/rust_command_taint.py` — the regex-based detector described above.

### Modified Files

- `scripts/commands/security.py` (or wherever `--check taint` dispatches) —
when scanning a workspace with `.rs` files, additionally run the new
detector and merge findings into the same `taint` output shape
(`by_rule`, `findings[]`) the Python/JS engine already produces.
- `docs/agent-usage-guide.md` — update the "no Rust taint" known limitation
to describe the narrower actual gap (cross-language IPC correlation,
not all Rust taint).

## Testing

Unit tests with synthetic `#[tauri::command]` functions (parameter reaching
a sink vs. not), plus verification against the real workspace pattern found
this session (`std::env::var()` → `Command::new()` inside a
`#[tauri::command]` function).

## Alternatives Considered

- **Full cross-language IPC correlation (the original issue scope).**
Rejected for this MVP — requires new cross-file/cross-language graph
edges the current architecture doesn't build; a legitimately separate,
larger effort if pursued later.
- **Full tree-sitter AST-based Rust taint matching JS/Python precision.**
Rejected for this MVP on time/scope grounds — regex-based detection with
documented trade-offs ships real value now; upgrading to full AST
precision is compatible future work that doesn't require redesigning the
finding shape.
48 changes: 46 additions & 2 deletions scripts/commands/taint.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
def add_args(parser):
parser.add_argument("workspace", nargs="?", default=None,
help="Path to workspace root (auto-detected if omitted)")
parser.add_argument("--language", choices=["python", "javascript", "typescript"], default=None,
help="Filter analysis to a specific language")
parser.add_argument("--language", choices=["python", "javascript", "typescript", "rust"], default=None,
help="Filter analysis to a specific language. 'rust' runs only the "
"#[tauri::command] parameter-to-sink MVP scanner (issue #240) — "
"see docs/design/0240-tauri-command-param-taint.md for its scope.")
parser.add_argument("--with-secrets", action="store_true", default=False,
help="Include secrets engine findings as taint sources")
parser.add_argument("--severity", choices=["critical", "high", "medium", "low"], default=None,
Expand All @@ -32,6 +34,29 @@ def execute(args, workspace):
no_ast = getattr(args, 'no_ast', False)
use_ast = getattr(args, 'ast', False)

# Issue #240 MVP: --language rust runs ONLY the Rust
# #[tauri::command]-parameter scanner — ast_taint_engine's
# get_supported_languages() doesn't include Rust at all, so routing
# "rust" through it would either error or silently match nothing.
# See docs/design/0240-tauri-command-param-taint.md for scope.
if language == "rust":
from rust_command_taint import scan_workspace as scan_rust_taint
findings = scan_rust_taint(workspace)
result = {
"status": "ok",
"engine": "rust_command_taint",
"languages_analyzed": ["rust"],
"findings": findings,
"total_findings": len(findings),
}
if getattr(args, 'severity', None):
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
min_sev = severity_order.get(args.severity, 3)
result["findings"] = [f for f in result["findings"]
if severity_order.get(f.get("severity", "low"), 3) <= min_sev]
result["total_findings"] = len(result["findings"])
return result

# Issue #49 Phase 1: unified entry point through ast_taint_engine.
# The AST engine now handles both intra-file (default) and cross-file
# (--cross-file flag) modes. The old crossfile_taint_engine and
Expand Down Expand Up @@ -69,6 +94,25 @@ def execute(args, workspace):
result["cross_file"] = False
result["cross_file_fallback"] = cross_file

# Issue #240 MVP: on a default (no --language filter) scan, also merge
# in Rust #[tauri::command] parameter-to-sink findings. Only makes
# sense when the caller didn't restrict to a specific non-Rust
# language — --language rust itself is handled by the early return
# above, and --language python/javascript/typescript means the caller
# wants just that language.
if language is None and result.get("status") == "ok":
try:
from rust_command_taint import scan_workspace as scan_rust_taint
rust_findings = scan_rust_taint(workspace)
if rust_findings:
result.setdefault("findings", []).extend(rust_findings)
result["total_findings"] = len(result["findings"])
langs = result.setdefault("languages_analyzed", [])
if "rust" not in langs:
langs.append("rust")
except Exception:
pass

# Optionally enhance with secrets findings
if getattr(args, 'with_secrets', False):
try:
Expand Down
174 changes: 174 additions & 0 deletions scripts/rust_command_taint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# @WHO: scripts/rust_command_taint.py
# @WHAT: Regex-based taint detection for #[tauri::command] parameters flowing to dangerous sinks
# @PART: engine
# @ENTRY: scan_workspace()
"""Rust `#[tauri::command]` parameter-to-sink taint detection (issue #240, MVP).

`ast_taint_engine.py` only supports Python/JS/TS/TSX — Rust has zero taint
coverage. Full cross-language taint (tracing a value from a TypeScript
`invoke("cmd", {...})` call across the IPC boundary into the matching Rust
`#[tauri::command]` function) is out of scope for this MVP — see
docs/design/0240-tauri-command-param-taint.md for why.

What this DOES cover: every parameter of a `#[tauri::command]`-annotated
function is untrusted input by construction (that's exactly how Tauri's IPC
dispatch delivers frontend data to Rust) — no cross-language tracing needed
to establish that. From there this is intra-procedural taint: does a
parameter flow into a dangerous sink within the same function body.

This is regex-based, not a full tree-sitter AST walker (consistent with
several other CodeLens engines, e.g. regexaudit_engine.py) — an explicit,
documented trade-off. False negatives are possible on parameters sanitized
in ways the regex can't see; there is no sanitizer allowlist in this MVP.
"""

import os
import re
from typing import Any, Dict, List

from utils import DEFAULT_IGNORE_DIRS, should_ignore_dir, logger


_COMMAND_ATTR_RE = re.compile(
r"#\[\s*tauri::command\s*(?:\([^)]*\))?\s*\]\s*"
r"(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*\(([^)]*)\)",
re.MULTILINE,
)

# Parameter name from a Rust fn signature: `name: Type` or `mut name: Type`.
_PARAM_NAME_RE = re.compile(r"(?:mut\s+)?(\w+)\s*:")

# High-confidence Rust sinks. Each maps to (rule_id, cwe, human message).
_SINKS: List[Dict[str, str]] = [
{
"pattern": r"Command::new\s*\(",
"rule_id": "rust-command-injection",
"cwe": "CWE-78",
"sink": "Command::new",
"message": "Tauri command parameter reaches Command::new() — potential command injection",
},
{
"pattern": r"std::process::Command::new\s*\(",
"rule_id": "rust-command-injection",
"cwe": "CWE-78",
"sink": "std::process::Command::new",
"message": "Tauri command parameter reaches std::process::Command::new() — potential command injection",
},
{
"pattern": r"std::fs::(read|write|remove_file|remove_dir|remove_dir_all|create_dir|create_dir_all|copy|rename)\s*\(",
"rule_id": "rust-path-traversal",
"cwe": "CWE-22",
"sink": "std::fs",
"message": "Tauri command parameter reaches a std::fs path operation — potential path traversal",
},
]


def _find_function_body(source: str, start: int) -> str:
"""Return the brace-matched body of the function starting at ``start``
(the index of the opening `fn` match). Returns "" if unbalanced."""
brace_start = source.find("{", start)
if brace_start == -1:
return ""
depth = 0
for i in range(brace_start, len(source)):
if source[i] == "{":
depth += 1
elif source[i] == "}":
depth -= 1
if depth == 0:
return source[brace_start:i + 1]
return source[brace_start:]


def _scan_file(file_path: str, rel_path: str) -> List[Dict[str, Any]]:

Check failure on line 84 in scripts/rust_command_taint.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ9Wb7FROsIJlDz03Oai&open=AZ9Wb7FROsIJlDz03Oai&pullRequest=250
findings: List[Dict[str, Any]] = []
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
source = f.read()
except (IOError, OSError):
return findings

for match in _COMMAND_ATTR_RE.finditer(source):
fn_name = match.group(1)
params_raw = match.group(2)
params = [
m.group(1) for m in _PARAM_NAME_RE.finditer(params_raw)
if m.group(1) not in ("self",)
]
if not params:
continue

body = _find_function_body(source, match.end())
if not body:
continue

body_start_line = source[:match.end()].count("\n") + 1

for line_offset, line in enumerate(body.split("\n")):
for sink in _SINKS:
if not re.search(sink["pattern"], line):
continue
for param in params:
# Direct usage of the parameter name as/near an argument
# on the same line as the sink call.
if re.search(rf"\b{re.escape(param)}\b", line):
findings.append({
"rule_id": sink["rule_id"],
"rule_name": "Tauri command parameter taint",
"severity": "high",
"cwe": sink["cwe"],
"message": (
f"{sink['message']} in #[tauri::command] fn "
f"'{fn_name}' (parameter '{param}')"
),
"file": rel_path,
"line": body_start_line + line_offset,
"source": f"tauri::command param '{param}'",
"sink": sink["sink"],
"tainted_variable": param,
"sanitized": False,
"confidence": "medium",
"taint_path": (
f"#[tauri::command] fn {fn_name}({param}: ...) "
f"→ {sink['sink']}"
),
"engine": "rust_command_taint",
})
return findings


def scan_workspace(workspace: str, max_files: int = 3000) -> List[Dict[str, Any]]:
"""Scan all `.rs` files in ``workspace`` for tainted Tauri command
parameters reaching a dangerous sink.

Returns a list of finding dicts in the same shape as
``ast_taint_engine``'s Python/JS findings (rule_id, severity, cwe,
message, file, line, source, sink, tainted_variable, sanitized,
confidence, taint_path) so callers can merge them into one list.
"""
findings: List[Dict[str, Any]] = []
workspace = os.path.abspath(workspace)
files_scanned = 0

for root, dirs, filenames in os.walk(workspace):
rel_root = os.path.relpath(root, workspace)
if should_ignore_dir(rel_root):
dirs.clear()
continue
dirs[:] = [d for d in dirs if d not in DEFAULT_IGNORE_DIRS and not d.startswith(".")]

for filename in filenames:
if not filename.endswith(".rs"):
continue
if files_scanned >= max_files:
return findings
file_path = os.path.join(root, filename)
rel_path = os.path.relpath(file_path, workspace)
try:
findings.extend(_scan_file(file_path, rel_path))
except Exception:
logger.debug(f"rust_command_taint: failed to scan {rel_path}", exc_info=True)
files_scanned += 1

return findings
Loading
Loading