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 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"]
@@ -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