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 +8 -0
- rce_path/__main__.py +4 -0
- rce_path/_worker.py +149 -0
- rce_path/cfg.py +76 -0
- rce_path/cli.py +78 -0
- rce_path/config.py +100 -0
- rce_path/findings.py +108 -0
- rce_path/flow.py +590 -0
- rce_path/ingest.py +309 -0
- rce_path/llm/__init__.py +103 -0
- rce_path/llm/contract.py +195 -0
- rce_path/llm/providers/__init__.py +4 -0
- rce_path/llm/providers/base.py +163 -0
- rce_path/llm/providers/ollama.py +93 -0
- rce_path/llm/providers/openai.py +82 -0
- rce_path/py.typed +0 -0
- rce_path/reports.py +87 -0
- rce_path/rules.py +60 -0
- rce_path/symbols.py +59 -0
- rce_path-0.1.0.dist-info/METADATA +79 -0
- rce_path-0.1.0.dist-info/RECORD +24 -0
- rce_path-0.1.0.dist-info/WHEEL +4 -0
- rce_path-0.1.0.dist-info/entry_points.txt +2 -0
- rce_path-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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()
|