canlix 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.
- canlix/__init__.py +1 -0
- canlix/analyzers/__init__.py +42 -0
- canlix/analyzers/deps.py +135 -0
- canlix/analyzers/python_ast.py +3300 -0
- canlix/analyzers/ts_regex.py +3492 -0
- canlix/cli.py +310 -0
- canlix/engine.py +80 -0
- canlix/models.py +107 -0
- canlix/parsers/__init__.py +19 -0
- canlix/parsers/claude.py +72 -0
- canlix/parsers/cursor.py +41 -0
- canlix/parsers/generic.py +14 -0
- canlix/parsers/vscode.py +54 -0
- canlix/probes/__init__.py +15 -0
- canlix/probes/active.py +123 -0
- canlix/probes/base.py +46 -0
- canlix/probes/metadata.py +213 -0
- canlix/probes/runner.py +44 -0
- canlix/probes/transport.py +164 -0
- canlix/reporters/__init__.py +15 -0
- canlix/reporters/json_report.py +96 -0
- canlix/reporters/sarif.py +178 -0
- canlix/reporters/terminal.py +191 -0
- canlix/rules/__init__.py +9 -0
- canlix/rules/base.py +13 -0
- canlix/rules/config.py +82 -0
- canlix/rules/cross_server.py +235 -0
- canlix/rules/execution.py +115 -0
- canlix/rules/network.py +140 -0
- canlix/rules/permissions.py +81 -0
- canlix/rules/secrets.py +108 -0
- canlix/runtime/__init__.py +6 -0
- canlix/runtime/detectors.py +290 -0
- canlix/runtime/proxy.py +170 -0
- canlix/runtime/redact.py +55 -0
- canlix/runtime/sse_server.py +159 -0
- canlix/saas_client.py +61 -0
- canlix-0.1.0.dist-info/METADATA +537 -0
- canlix-0.1.0.dist-info/RECORD +41 -0
- canlix-0.1.0.dist-info/WHEEL +4 -0
- canlix-0.1.0.dist-info/entry_points.txt +2 -0
canlix/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Source code security analyzer for MCP server implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from canlix.analyzers.deps import analyze_package_json, analyze_requirements
|
|
8
|
+
from canlix.analyzers.python_ast import analyze_python
|
|
9
|
+
from canlix.analyzers.ts_regex import analyze_typescript
|
|
10
|
+
from canlix.models import SourceFinding
|
|
11
|
+
|
|
12
|
+
_SKIP = {"node_modules", ".venv", "venv", "__pycache__", ".git", "dist", "build", ".tox"}
|
|
13
|
+
_TS_SUFFIXES = {".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def analyze_path(path: Path) -> list[SourceFinding]:
|
|
17
|
+
"""Analyze a file or directory for security issues. Returns all findings."""
|
|
18
|
+
path = Path(path)
|
|
19
|
+
if path.is_file():
|
|
20
|
+
return _analyze_file(path)
|
|
21
|
+
findings: list[SourceFinding] = []
|
|
22
|
+
for f in sorted(path.rglob("*")):
|
|
23
|
+
if any(part in _SKIP for part in f.parts):
|
|
24
|
+
continue
|
|
25
|
+
findings.extend(_analyze_file(f))
|
|
26
|
+
return findings
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _analyze_file(path: Path) -> list[SourceFinding]:
|
|
30
|
+
if path.suffix == ".py":
|
|
31
|
+
return analyze_python(path)
|
|
32
|
+
if path.suffix in _TS_SUFFIXES:
|
|
33
|
+
return analyze_typescript(path)
|
|
34
|
+
if path.name == "package.json":
|
|
35
|
+
return analyze_package_json(path)
|
|
36
|
+
if path.name.endswith("requirements.txt") or path.name.startswith("requirements"):
|
|
37
|
+
if path.suffix == ".txt":
|
|
38
|
+
return analyze_requirements(path)
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["analyze_path", "analyze_package_json", "analyze_requirements", "SourceFinding"]
|
canlix/analyzers/deps.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Dependency manifest analyzer — detects unpinned or risky package versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from canlix.models import Confidence, Severity, SourceFinding
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# requirements.txt helpers
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
# Lines to skip in requirements.txt
|
|
16
|
+
_REQ_SKIP = re.compile(r"^\s*(?:#|-r\s|-c\s|-e\s|--|-i\s|http[s]?://)")
|
|
17
|
+
# Strip VCS/URL-based specifiers (git+https://...)
|
|
18
|
+
_VCS_LINE = re.compile(r"^(?:git\+|svn\+|hg\+|bzr\+)")
|
|
19
|
+
# Strip extras: requests[security] → requests
|
|
20
|
+
_STRIP_EXTRAS = re.compile(r"\[.*?\]")
|
|
21
|
+
# Match only the package name at the start of a requirement line
|
|
22
|
+
_PKG_NAME = re.compile(r"^([A-Za-z0-9]([A-Za-z0-9._-]*))")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyze_requirements(path: Path) -> list[SourceFinding]:
|
|
26
|
+
"""Scan a requirements*.txt file for unpinned packages."""
|
|
27
|
+
try:
|
|
28
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
29
|
+
except OSError:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
findings: list[SourceFinding] = []
|
|
33
|
+
for lineno, raw in enumerate(lines, start=1):
|
|
34
|
+
line = raw.strip()
|
|
35
|
+
if not line or _REQ_SKIP.match(line) or _VCS_LINE.match(line):
|
|
36
|
+
continue
|
|
37
|
+
clean = _STRIP_EXTRAS.sub("", line)
|
|
38
|
+
# Exact pin present → safe
|
|
39
|
+
if "==" in clean:
|
|
40
|
+
continue
|
|
41
|
+
m = _PKG_NAME.match(clean)
|
|
42
|
+
if not m:
|
|
43
|
+
continue
|
|
44
|
+
pkg = m.group(1)
|
|
45
|
+
findings.append(
|
|
46
|
+
SourceFinding(
|
|
47
|
+
rule_id="DEP-001",
|
|
48
|
+
severity=Severity.MEDIUM,
|
|
49
|
+
confidence=Confidence.HIGH,
|
|
50
|
+
file_path=path,
|
|
51
|
+
line_number=lineno,
|
|
52
|
+
message=f"Package '{pkg}' has no exact version pin — supply chain risk",
|
|
53
|
+
code_snippet=line,
|
|
54
|
+
remediation=f"Pin to an exact version: {pkg}==<version>. Use pip-compile or uv to generate a lockfile.",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return findings
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# package.json helpers
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# Versions that mean "pull whatever is newest"
|
|
65
|
+
_FULLY_UNPINNED = {"*", "latest", ""}
|
|
66
|
+
|
|
67
|
+
# Exact pin: no leading ^ ~ >= <= > < (plain semver or git hash)
|
|
68
|
+
_EXACT_SEMVER = re.compile(r"^\d")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _version_severity(version: str) -> Severity | None:
|
|
72
|
+
"""Return severity for a package.json version specifier, or None if safe."""
|
|
73
|
+
v = version.strip()
|
|
74
|
+
if v in _FULLY_UNPINNED:
|
|
75
|
+
return Severity.HIGH
|
|
76
|
+
if v.startswith(("^", "~", ">=", "<=", ">", "<", "||", "x", "X")):
|
|
77
|
+
return Severity.MEDIUM
|
|
78
|
+
# Exact pin (1.2.3, 1.2.3-alpha) or git URL → safe
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _find_line(lines: list[str], pkg: str, version: str) -> int:
|
|
83
|
+
"""Return the 1-based line number of a package entry in package.json."""
|
|
84
|
+
needle = json.dumps(pkg) # e.g. '"requests"'
|
|
85
|
+
for i, line in enumerate(lines, start=1):
|
|
86
|
+
if needle in line and version in line:
|
|
87
|
+
return i
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def analyze_package_json(path: Path) -> list[SourceFinding]:
|
|
92
|
+
"""Scan a package.json for risky version specifiers."""
|
|
93
|
+
try:
|
|
94
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
95
|
+
data = json.loads(text)
|
|
96
|
+
except (OSError, json.JSONDecodeError):
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
lines = text.splitlines()
|
|
100
|
+
findings: list[SourceFinding] = []
|
|
101
|
+
|
|
102
|
+
dep_sections = [
|
|
103
|
+
data.get("dependencies", {}),
|
|
104
|
+
data.get("devDependencies", {}),
|
|
105
|
+
data.get("optionalDependencies", {}),
|
|
106
|
+
]
|
|
107
|
+
for section in dep_sections:
|
|
108
|
+
if not isinstance(section, dict):
|
|
109
|
+
continue
|
|
110
|
+
for pkg, version in section.items():
|
|
111
|
+
if not isinstance(version, str):
|
|
112
|
+
continue
|
|
113
|
+
sev = _version_severity(version)
|
|
114
|
+
if sev is None:
|
|
115
|
+
continue
|
|
116
|
+
lineno = _find_line(lines, pkg, version)
|
|
117
|
+
rule = "DEP-002" if sev == Severity.HIGH else "DEP-003"
|
|
118
|
+
msg = (
|
|
119
|
+
f"Package '{pkg}' version '{version}' is fully unpinned — highest supply chain risk"
|
|
120
|
+
if sev == Severity.HIGH
|
|
121
|
+
else f"Package '{pkg}' version '{version}' uses a range specifier — supply chain risk"
|
|
122
|
+
)
|
|
123
|
+
findings.append(
|
|
124
|
+
SourceFinding(
|
|
125
|
+
rule_id=rule,
|
|
126
|
+
severity=sev,
|
|
127
|
+
confidence=Confidence.HIGH,
|
|
128
|
+
file_path=path,
|
|
129
|
+
line_number=lineno,
|
|
130
|
+
message=msg,
|
|
131
|
+
code_snippet=f'"{pkg}": "{version}"',
|
|
132
|
+
remediation=f'Pin to exact: "{pkg}": "{version.lstrip("^~>=<")}" and commit a lockfile.',
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return findings
|