rce-path 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rce_path/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Public, static-first API. Importing this package does not load AI SDKs."""
2
+
3
+ from .config import ScanConfig
4
+ from .findings import Finding, ScanReport
5
+ from .ingest import scan
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["Finding", "ScanConfig", "ScanReport", "scan"]
rce_path/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
rce_path/_worker.py ADDED
@@ -0,0 +1,149 @@
1
+ """Trusted isolated worker entry point. Never import anything from the target tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import ctypes
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ # -I removes cwd/PYTHONPATH; only the installed package's source root is reintroduced.
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
+
15
+ _job_handle = None
16
+
17
+
18
+ def memory_limit(megabytes: int) -> None:
19
+ global _job_handle
20
+ amount = megabytes * 1024 * 1024
21
+ if os.name != "nt":
22
+ import resource
23
+
24
+ getattr(resource, "setrlimit")(getattr(resource, "RLIMIT_AS"), (amount, amount))
25
+ return
26
+ from ctypes import wintypes
27
+
28
+ class Basic(ctypes.Structure):
29
+ _fields_ = [
30
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
31
+ ("PerJobUserTimeLimit", ctypes.c_int64),
32
+ ("LimitFlags", wintypes.DWORD),
33
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
34
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
35
+ ("ActiveProcessLimit", wintypes.DWORD),
36
+ ("Affinity", ctypes.c_size_t),
37
+ ("PriorityClass", wintypes.DWORD),
38
+ ("SchedulingClass", wintypes.DWORD),
39
+ ]
40
+
41
+ class IO(ctypes.Structure):
42
+ _fields_ = [
43
+ (name, ctypes.c_uint64)
44
+ for name in (
45
+ "ReadOperationCount",
46
+ "WriteOperationCount",
47
+ "OtherOperationCount",
48
+ "ReadTransferCount",
49
+ "WriteTransferCount",
50
+ "OtherTransferCount",
51
+ )
52
+ ]
53
+
54
+ class Extended(ctypes.Structure):
55
+ _fields_ = [
56
+ ("BasicLimitInformation", Basic),
57
+ ("IoInfo", IO),
58
+ ("ProcessMemoryLimit", ctypes.c_size_t),
59
+ ("JobMemoryLimit", ctypes.c_size_t),
60
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
61
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
62
+ ]
63
+
64
+ kernel = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True)
65
+ kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
66
+ kernel.CreateJobObjectW.restype = wintypes.HANDLE
67
+ kernel.GetCurrentProcess.restype = wintypes.HANDLE
68
+ kernel.SetInformationJobObject.argtypes = [
69
+ wintypes.HANDLE,
70
+ ctypes.c_int,
71
+ ctypes.c_void_p,
72
+ wintypes.DWORD,
73
+ ]
74
+ kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
75
+ handle = kernel.CreateJobObjectW(None, None)
76
+ limits = Extended()
77
+ limits.BasicLimitInformation.LimitFlags = 0x100 # JOB_OBJECT_LIMIT_PROCESS_MEMORY
78
+ limits.ProcessMemoryLimit = amount
79
+ if not handle or not kernel.SetInformationJobObject(
80
+ handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)
81
+ ):
82
+ raise OSError("Could not enforce parser-worker memory limit")
83
+ if not kernel.AssignProcessToJobObject(handle, kernel.GetCurrentProcess()):
84
+ raise OSError("Could not assign parser-worker memory limit")
85
+ _job_handle = handle # retain until process exit
86
+
87
+
88
+ def main() -> None:
89
+ from rce_path.config import ScanConfig
90
+ from rce_path.flow import Analyzer
91
+
92
+ request = json.loads(sys.stdin.buffer.read(25_000_000))
93
+ config = ScanConfig.model_validate_json(json.dumps(request["config"]))
94
+ memory_limit(config.parser_memory_mb)
95
+ source = request["source"]
96
+ file = request["file"]
97
+ try:
98
+ tree = ast.parse(source, filename=file)
99
+ count = 0
100
+ for _ in ast.walk(tree):
101
+ count += 1
102
+ if count > config.max_ast_nodes:
103
+ raise ValueError("AST node budget exhausted")
104
+ findings, diagnostics = Analyzer(source, file, config).analyze(tree)
105
+ payload = {
106
+ "findings": [f.model_dump() for f in findings],
107
+ "diagnostics": [d.model_dump() for d in diagnostics],
108
+ }
109
+ except (SyntaxError, UnicodeError, ValueError, RecursionError, MemoryError) as error:
110
+ payload = {
111
+ "findings": [],
112
+ "diagnostics": [
113
+ {
114
+ "code": "parse_error",
115
+ "file": file,
116
+ "line": getattr(error, "lineno", None),
117
+ "affects_completion": True,
118
+ "message": f"{type(error).__name__}: {str(error)[:200]}",
119
+ }
120
+ ],
121
+ }
122
+ # Bound serialized evidence before it enters the parent's capture buffer.
123
+ chunks = []
124
+ size = 0
125
+ for chunk in json.JSONEncoder().iterencode(payload):
126
+ size += len(chunk.encode())
127
+ if size > 16_000_000:
128
+ chunks = [
129
+ json.dumps(
130
+ {
131
+ "findings": [],
132
+ "diagnostics": [
133
+ {
134
+ "code": "evidence_response_limit",
135
+ "file": file,
136
+ "message": "Worker evidence response exceeded size budget.",
137
+ "affects_completion": True,
138
+ }
139
+ ],
140
+ }
141
+ )
142
+ ]
143
+ break
144
+ chunks.append(chunk)
145
+ sys.stdout.write("".join(chunks))
146
+
147
+
148
+ if __name__ == "__main__":
149
+ main()
rce_path/cfg.py ADDED
@@ -0,0 +1,76 @@
1
+ """Structured per-scope control-flow graphs; no target code is evaluated."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class Edge:
11
+ target: int
12
+ condition: ast.expr | None = None
13
+ truth: bool = True
14
+
15
+
16
+ @dataclass
17
+ class Block:
18
+ id: int
19
+ statement: ast.AST | None
20
+ edges: list[Edge] = field(default_factory=list)
21
+
22
+
23
+ @dataclass
24
+ class Graph:
25
+ blocks: dict[int, Block]
26
+ entry: int
27
+
28
+
29
+ class Builder:
30
+ def __init__(self) -> None:
31
+ self.blocks: dict[int, Block] = {}
32
+
33
+ def node(self, stmt: ast.AST | None) -> int:
34
+ index = len(self.blocks)
35
+ self.blocks[index] = Block(index, stmt)
36
+ return index
37
+
38
+ def sequence(
39
+ self, statements: list[ast.stmt], after: int, loop: tuple[int, int] | None = None
40
+ ) -> int:
41
+ current = after
42
+ for stmt in reversed(statements):
43
+ index = self.node(stmt)
44
+ if isinstance(stmt, ast.If):
45
+ yes = self.sequence(stmt.body, current, loop)
46
+ no = self.sequence(stmt.orelse, current, loop)
47
+ self.blocks[index].edges = [Edge(yes, stmt.test), Edge(no, stmt.test, False)]
48
+ elif isinstance(stmt, (ast.While, ast.For, ast.AsyncFor)):
49
+ otherwise = self.sequence(stmt.orelse, current, loop)
50
+ body = self.sequence(stmt.body, index, (current, index))
51
+ test = stmt.test if isinstance(stmt, ast.While) else None
52
+ self.blocks[index].edges = [Edge(body, test), Edge(otherwise, test, False)]
53
+ elif isinstance(stmt, ast.Break):
54
+ self.blocks[index].edges = [Edge(loop[0])] if loop else []
55
+ elif isinstance(stmt, ast.Continue):
56
+ self.blocks[index].edges = [Edge(loop[1])] if loop else []
57
+ elif isinstance(stmt, (ast.Return, ast.Raise)):
58
+ self.blocks[index].edges = []
59
+ elif isinstance(stmt, (ast.With, ast.AsyncWith)):
60
+ self.blocks[index].edges = [Edge(self.sequence(stmt.body, current, loop))]
61
+ elif isinstance(stmt, (ast.Try, ast.TryStar)):
62
+ # Exception edges conservatively join; completion diagnostics disclose this model.
63
+ final = self.sequence(stmt.finalbody, current, loop)
64
+ normal = self.sequence(stmt.orelse, final, loop)
65
+ entries = [self.sequence(stmt.body, normal, loop)]
66
+ entries.extend(self.sequence(h.body, final, loop) for h in stmt.handlers)
67
+ self.blocks[index].edges = [Edge(item) for item in entries]
68
+ else:
69
+ self.blocks[index].edges = [Edge(current)]
70
+ current = index
71
+ return current
72
+
73
+ def build(self, statements: list[ast.stmt]) -> Graph:
74
+ exit_node = self.node(None)
75
+ entry = self.sequence(statements, exit_node)
76
+ return Graph(self.blocks, entry)
rce_path/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """CLI policies are explicit; incomplete scans always take precedence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .config import ScanConfig, load_config
10
+ from .ingest import scan
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="rce-path",
16
+ description="Investigate modeled Python execution paths without executing the target.",
17
+ )
18
+ parser.add_argument("--version", action="version", version="rce-path 0.1.0")
19
+ commands = parser.add_subparsers(dest="command", required=True)
20
+ command = commands.add_parser("scan")
21
+ command.add_argument("path")
22
+ command.add_argument("--format", choices=["json", "markdown"], default="json")
23
+ command.add_argument("--output", type=Path)
24
+ command.add_argument("--config", type=Path)
25
+ command.add_argument("--fail-on", choices=["none", "static-path", "any"], default="none")
26
+ command.add_argument("--ai", choices=["openai", "ollama"])
27
+ command.add_argument("--model")
28
+ command.add_argument("--endpoint")
29
+ command.add_argument("--allow-remote-code", action="store_true")
30
+ command.add_argument("--accept-config-ai-endpoint", action="store_true")
31
+ args = parser.parse_args(argv)
32
+ try:
33
+ target = Path(args.path)
34
+ root = target if target.is_dir() else target.parent
35
+ config_path = args.config or root / "rce-path.toml"
36
+ if args.config or config_path.exists():
37
+ if config_path.is_symlink() or (
38
+ getattr(config_path.lstat(), "st_file_attributes", 0) & 0x400
39
+ ):
40
+ raise ValueError("configuration symlinks/reparse points are not accepted")
41
+ config = load_config(config_path, accept_ai_endpoint=args.accept_config_ai_endpoint)
42
+ else:
43
+ config = ScanConfig()
44
+ updates = {"ai_enabled": bool(args.ai), "allow_remote_code": args.allow_remote_code}
45
+ for name, value in (
46
+ ("ai_provider", args.ai),
47
+ ("ai_model", args.model),
48
+ ("ai_endpoint", args.endpoint),
49
+ ):
50
+ if value is not None:
51
+ updates[name] = value
52
+ config = ScanConfig(**(config.model_dump() | updates))
53
+ if config.ai_enabled and config.ai_provider == "openai" and not config.allow_remote_code:
54
+ raise ValueError("OpenAI code transmission requires --allow-remote-code")
55
+ report = scan(args.path, config)
56
+ output = report.to_json() if args.format == "json" else report.to_markdown()
57
+ if args.output:
58
+ args.output.write_text(output + "\n", encoding="utf-8")
59
+ else:
60
+ print(output)
61
+ if report.status != "completed" or report.ai_status not in {
62
+ "disabled",
63
+ "completed",
64
+ "no_findings",
65
+ }:
66
+ return 2
67
+ failed = args.fail_on == "any" and bool(report.findings)
68
+ failed |= args.fail_on == "static-path" and any(
69
+ f.evidence_tier == "static_path" for f in report.findings
70
+ )
71
+ return 1 if failed else 0
72
+ except (OSError, ValueError) as error:
73
+ print(f"rce-path: {str(error)[:500]}", file=sys.stderr)
74
+ return 2
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
rce_path/config.py ADDED
@@ -0,0 +1,100 @@
1
+ """Strict operator configuration. Repository settings cannot enable AI or grant consent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
10
+
11
+ DEFAULT_EXCLUDES = (
12
+ ".git",
13
+ ".hg",
14
+ ".svn",
15
+ ".venv",
16
+ "venv",
17
+ "env",
18
+ "__pycache__",
19
+ "build",
20
+ "dist",
21
+ "node_modules",
22
+ ".tox",
23
+ ".nox",
24
+ "site-packages",
25
+ ".eggs",
26
+ )
27
+ RULE_IDS = ("python.eval", "python.exec", "os.system", "os.popen", "subprocess.shell")
28
+
29
+
30
+ class ScanConfig(BaseModel):
31
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
32
+ excludes: tuple[str, ...] = DEFAULT_EXCLUDES
33
+ rules: tuple[str, ...] = RULE_IDS
34
+ max_file_bytes: int = Field(default=1_000_000, ge=1, le=20_000_000)
35
+ max_files: int = Field(default=10_000, ge=1, le=1_000_000)
36
+ max_inventory_entries: int = Field(default=100_000, ge=1, le=1_000_000)
37
+ parser_timeout_seconds: float = Field(default=10.0, gt=0, le=300)
38
+ parser_memory_mb: int = Field(default=256, ge=64, le=4096)
39
+ scan_timeout_seconds: float = Field(default=120.0, gt=0, le=86400)
40
+ max_ast_nodes: int = Field(default=30_000, ge=1, le=1_000_000)
41
+ max_flow_steps: int = Field(default=20_000, ge=1, le=1_000_000)
42
+ loop_iterations: int = Field(default=8, ge=1, le=100)
43
+ max_evidence_steps: int = Field(default=64, ge=2, le=512)
44
+ ai_enabled: bool = False
45
+ ai_provider: Literal["openai", "ollama"] = "ollama"
46
+ ai_model: str | None = None
47
+ allow_remote_code: bool = False
48
+ ai_endpoint: str | None = None
49
+ ai_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
50
+ ai_max_reviews: int = Field(default=20, ge=1, le=1000)
51
+ ai_max_input_bytes: int = Field(default=16_000, ge=512, le=200_000)
52
+ ai_max_output_tokens: int = Field(default=1024, ge=64, le=8192)
53
+ ai_total_token_budget: int = Field(default=50_000, ge=64, le=10_000_000)
54
+
55
+ @model_validator(mode="after")
56
+ def valid_selection(self) -> ScanConfig:
57
+ if not self.rules or set(self.rules) - set(RULE_IDS):
58
+ raise ValueError("rules must be a nonempty selection of built-in Phase 1 rules")
59
+ if self.ai_enabled and not self.ai_model:
60
+ raise ValueError("an explicit ai_model is required when AI is enabled")
61
+ if any(not item or "\\" in item for item in self.excludes):
62
+ raise ValueError("excludes must be nonempty names or forward-slash glob patterns")
63
+ return self
64
+
65
+
66
+ def load_config(path: Path, *, accept_ai_endpoint: bool = False) -> ScanConfig:
67
+ from .ingest import _read
68
+
69
+ path = path.absolute()
70
+ raw = tomllib.loads(_read(path, path.parent.resolve(), 64_000).decode("utf-8"))
71
+ if any(not isinstance(raw.get(section, {}), dict) for section in ("scan", "ai")):
72
+ raise ValueError("scan and ai must be TOML tables")
73
+ if set(raw) - {"scan", "ai"}:
74
+ raise ValueError("unknown configuration section")
75
+ settings = dict(raw.get("scan", {}))
76
+ for key in ("excludes", "rules"):
77
+ if key in settings and isinstance(settings[key], list):
78
+ settings[key] = tuple(settings[key])
79
+ ai = dict(raw.get("ai", {}))
80
+ allowed_ai = {
81
+ "provider",
82
+ "model",
83
+ "endpoint",
84
+ "timeout_seconds",
85
+ "max_reviews",
86
+ "max_input_bytes",
87
+ "max_output_tokens",
88
+ "total_token_budget",
89
+ }
90
+ if set(ai) - allowed_ai:
91
+ raise ValueError("unknown AI setting; runtime opt-in/consent belongs on the command line")
92
+ if set(settings) - (
93
+ set(ScanConfig.model_fields) - {"ai_enabled", "allow_remote_code", "ai_endpoint"}
94
+ ):
95
+ raise ValueError("unknown or privileged scan setting")
96
+ for key, value in ai.items():
97
+ if key == "endpoint" and not accept_ai_endpoint:
98
+ continue
99
+ settings[f"ai_{key}"] = value
100
+ return ScanConfig(**settings)
rce_path/findings.py ADDED
@@ -0,0 +1,108 @@
1
+ """Versioned evidence contracts, independent of analysis and provider implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ SCHEMA_VERSION = "1.0"
12
+ RULE_VERSION = "0.1.0"
13
+ SourceCategory = Literal["caller_parameter", "network_request", "local_input", "unknown"]
14
+
15
+
16
+ class Record(BaseModel):
17
+ model_config = ConfigDict(extra="forbid", frozen=True)
18
+
19
+
20
+ class Location(Record):
21
+ file: str
22
+ line: int = Field(ge=1)
23
+ column: int = Field(ge=0)
24
+ end_line: int = Field(ge=1)
25
+ end_column: int = Field(ge=0)
26
+
27
+
28
+ class EvidenceStep(Record):
29
+ id: str
30
+ kind: str
31
+ location: Location
32
+ description: str
33
+ snippet: str = ""
34
+
35
+
36
+ class Diagnostic(Record):
37
+ code: str
38
+ message: str
39
+ file: str | None = None
40
+ line: int | None = None
41
+ affects_completion: bool = False
42
+
43
+
44
+ class Finding(Record):
45
+ schema_version: str = SCHEMA_VERSION
46
+ rule_version: str = RULE_VERSION
47
+ fingerprint: str
48
+ rule_id: str
49
+ sink: str
50
+ argument: str
51
+ location: Location
52
+ source_category: SourceCategory
53
+ source: Location | None = None
54
+ evidence_tier: Literal["sink_only", "static_path", "canary_validated"]
55
+ remote_reachability: Literal["network_source_modeled", "caller_controlled_only", "unknown"]
56
+ impact: str
57
+ steps: tuple[EvidenceStep, ...]
58
+ conditions: tuple[str, ...] = ()
59
+ assumptions: tuple[str, ...] = ()
60
+ unresolved_edges: tuple[EvidenceStep, ...] = ()
61
+ dependencies: dict[str, str] = Field(default_factory=dict)
62
+ human_review: Literal["unreviewed", "confirmed", "dismissed", "needs_information"] = (
63
+ "unreviewed"
64
+ )
65
+ human_rationale: str | None = None
66
+ review_notes: tuple[str, ...] = ()
67
+
68
+
69
+ class ScanReport(Record):
70
+ schema_version: str = SCHEMA_VERSION
71
+ tool_version: str = "0.1.0"
72
+ rule_version: str = RULE_VERSION
73
+ root: str
74
+ status: Literal["completed", "incomplete", "error"]
75
+ findings: tuple[Finding, ...] = ()
76
+ diagnostics: tuple[Diagnostic, ...] = ()
77
+ analyzed_files: int = 0
78
+ skipped_files: int = 0
79
+ content_hashes: dict[str, str] = Field(default_factory=dict)
80
+ revision: str | None = None
81
+ limits: dict[str, int | float] = Field(default_factory=dict)
82
+ enabled_rules: tuple[str, ...] = ()
83
+ exclusions: tuple[str, ...] = ()
84
+ target_syntax: str
85
+ ai_status: str = "disabled"
86
+ ai_reviews: dict[str, dict] = Field(default_factory=dict)
87
+
88
+ @property
89
+ def summary(self) -> str:
90
+ if self.status != "completed":
91
+ return "Incomplete scan; coverage diagnostics require review."
92
+ return (
93
+ f"{len(self.findings)} modeled findings." if self.findings else "No modeled findings."
94
+ )
95
+
96
+ def to_json(self) -> str:
97
+ return self.model_dump_json(indent=2)
98
+
99
+ def to_markdown(self) -> str:
100
+ from .reports import markdown
101
+
102
+ return markdown(self)
103
+
104
+
105
+ def fingerprint(rule: str, location: Location, source: Location | None) -> str:
106
+ """Stable across roots, timestamps, rendering, and AI annotations."""
107
+ payload = [RULE_VERSION, rule, location.model_dump(), source.model_dump() if source else None]
108
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()