supersonar 0.1.0__tar.gz
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.
- supersonar-0.1.0/PKG-INFO +53 -0
- supersonar-0.1.0/README.md +39 -0
- supersonar-0.1.0/pyproject.toml +26 -0
- supersonar-0.1.0/setup.cfg +4 -0
- supersonar-0.1.0/supersonar/__init__.py +3 -0
- supersonar-0.1.0/supersonar/__main__.py +6 -0
- supersonar-0.1.0/supersonar/cli.py +83 -0
- supersonar-0.1.0/supersonar/config.py +62 -0
- supersonar-0.1.0/supersonar/models.py +24 -0
- supersonar-0.1.0/supersonar/quality_gate.py +30 -0
- supersonar-0.1.0/supersonar/reporters.py +82 -0
- supersonar-0.1.0/supersonar/rules/__init__.py +4 -0
- supersonar-0.1.0/supersonar/rules/python.py +107 -0
- supersonar-0.1.0/supersonar/scanner.py +27 -0
- supersonar-0.1.0/supersonar.egg-info/PKG-INFO +53 -0
- supersonar-0.1.0/supersonar.egg-info/SOURCES.txt +19 -0
- supersonar-0.1.0/supersonar.egg-info/dependency_links.txt +1 -0
- supersonar-0.1.0/supersonar.egg-info/entry_points.txt +2 -0
- supersonar-0.1.0/supersonar.egg-info/top_level.txt +1 -0
- supersonar-0.1.0/tests/test_quality_gate.py +38 -0
- supersonar-0.1.0/tests/test_scanner.py +42 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: supersonar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A SonarQube-like static analysis CLI for Python projects.
|
|
5
|
+
Author: Supersonar Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: static-analysis,lint,security,ci,sonarqube
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# supersonar
|
|
16
|
+
|
|
17
|
+
`supersonar` is a lightweight, SonarQube-inspired static analysis CLI for Python projects.
|
|
18
|
+
It is designed for local use and CI pipelines via `pip install`.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install .
|
|
24
|
+
supersonar scan . --format json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CI usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install supersonar
|
|
31
|
+
supersonar scan . --format sarif --out reports/supersonar.sarif --fail-on high
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Config (`supersonar.toml`)
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
[scan]
|
|
38
|
+
exclude = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
|
|
39
|
+
|
|
40
|
+
[quality_gate]
|
|
41
|
+
fail_on = "high"
|
|
42
|
+
max_issues = 0
|
|
43
|
+
|
|
44
|
+
[report]
|
|
45
|
+
format = "json"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Rule coverage (MVP)
|
|
49
|
+
|
|
50
|
+
- `SS001` - dangerous `eval` / `exec`
|
|
51
|
+
- `SS002` - broad `except Exception` or bare `except`
|
|
52
|
+
- `SS003` - hardcoded secret-like tokens
|
|
53
|
+
- `SS004` - `TODO` / `FIXME` markers
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# supersonar
|
|
2
|
+
|
|
3
|
+
`supersonar` is a lightweight, SonarQube-inspired static analysis CLI for Python projects.
|
|
4
|
+
It is designed for local use and CI pipelines via `pip install`.
|
|
5
|
+
|
|
6
|
+
## Quick start
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install .
|
|
10
|
+
supersonar scan . --format json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## CI usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install supersonar
|
|
17
|
+
supersonar scan . --format sarif --out reports/supersonar.sarif --fail-on high
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Config (`supersonar.toml`)
|
|
21
|
+
|
|
22
|
+
```toml
|
|
23
|
+
[scan]
|
|
24
|
+
exclude = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
|
|
25
|
+
|
|
26
|
+
[quality_gate]
|
|
27
|
+
fail_on = "high"
|
|
28
|
+
max_issues = 0
|
|
29
|
+
|
|
30
|
+
[report]
|
|
31
|
+
format = "json"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Rule coverage (MVP)
|
|
35
|
+
|
|
36
|
+
- `SS001` - dangerous `eval` / `exec`
|
|
37
|
+
- `SS002` - broad `except Exception` or bare `except`
|
|
38
|
+
- `SS003` - hardcoded secret-like tokens
|
|
39
|
+
- `SS004` - `TODO` / `FIXME` markers
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "supersonar"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A SonarQube-like static analysis CLI for Python projects."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "Supersonar Contributors" }]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = ["static-analysis", "lint", "security", "ci", "sonarqube"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
supersonar = "supersonar.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
include = ["supersonar*"]
|
|
26
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from supersonar.config import Config, load_config
|
|
7
|
+
from supersonar.models import Severity
|
|
8
|
+
from supersonar.quality_gate import evaluate_gate
|
|
9
|
+
from supersonar.reporters import to_json_report, to_sarif_report, write_report
|
|
10
|
+
from supersonar.scanner import scan_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="supersonar", description="Static analysis scanner for Python.")
|
|
15
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
16
|
+
|
|
17
|
+
scan = subparsers.add_parser("scan", help="Run static analysis scan.")
|
|
18
|
+
scan.add_argument("path", nargs="?", default=".", help="Path to scan.")
|
|
19
|
+
scan.add_argument("--config", help="Path to supersonar TOML config.")
|
|
20
|
+
scan.add_argument("--exclude", action="append", default=[], help="Extra exclude directory names.")
|
|
21
|
+
scan.add_argument("--format", choices=["json", "sarif"], help="Report output format.")
|
|
22
|
+
scan.add_argument("--out", help="Write report to file. Defaults to stdout.")
|
|
23
|
+
scan.add_argument("--fail-on", choices=["low", "medium", "high", "critical"], help="Fail on severity level.")
|
|
24
|
+
scan.add_argument("--max-issues", type=int, help="Fail if issue count exceeds this number.")
|
|
25
|
+
|
|
26
|
+
return parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
parser = build_parser()
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
if args.command == "scan":
|
|
34
|
+
exit_code = run_scan(args)
|
|
35
|
+
raise SystemExit(exit_code)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_scan(args: argparse.Namespace) -> int:
|
|
39
|
+
config = load_config(args.config)
|
|
40
|
+
merged = merge_cli_with_config(args, config)
|
|
41
|
+
|
|
42
|
+
result = scan_path(args.path, excludes=merged.scan.exclude)
|
|
43
|
+
report_payload = render_report(result, merged.report.output_format)
|
|
44
|
+
write_report(report_payload, merged.report.out)
|
|
45
|
+
|
|
46
|
+
passed, reasons = evaluate_gate(
|
|
47
|
+
result,
|
|
48
|
+
fail_on=merged.quality_gate.fail_on,
|
|
49
|
+
max_issues=merged.quality_gate.max_issues,
|
|
50
|
+
)
|
|
51
|
+
if not passed:
|
|
52
|
+
for reason in reasons:
|
|
53
|
+
print(f"[gate] {reason}", file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def merge_cli_with_config(args: argparse.Namespace, config: Config) -> Config:
|
|
59
|
+
merged = config
|
|
60
|
+
if args.exclude:
|
|
61
|
+
merged.scan.exclude = list(dict.fromkeys([*merged.scan.exclude, *args.exclude]))
|
|
62
|
+
if args.format:
|
|
63
|
+
merged.report.output_format = args.format
|
|
64
|
+
if args.out:
|
|
65
|
+
merged.report.out = args.out
|
|
66
|
+
if args.fail_on:
|
|
67
|
+
merged.quality_gate.fail_on = args.fail_on
|
|
68
|
+
if args.max_issues is not None:
|
|
69
|
+
merged.quality_gate.max_issues = args.max_issues
|
|
70
|
+
return merged
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_report(result, output_format: str):
|
|
74
|
+
if output_format == "sarif":
|
|
75
|
+
return to_sarif_report(result)
|
|
76
|
+
if output_format == "json":
|
|
77
|
+
return to_json_report(result)
|
|
78
|
+
raise ValueError(f"Unsupported report format: {output_format}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
83
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from supersonar.models import Severity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_EXCLUDES = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ScanConfig:
|
|
15
|
+
exclude: list[str] = field(default_factory=lambda: DEFAULT_EXCLUDES.copy())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class QualityGateConfig:
|
|
20
|
+
fail_on: Severity | None = None
|
|
21
|
+
max_issues: int | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class ReportConfig:
|
|
26
|
+
output_format: str = "json"
|
|
27
|
+
out: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class Config:
|
|
32
|
+
scan: ScanConfig = field(default_factory=ScanConfig)
|
|
33
|
+
quality_gate: QualityGateConfig = field(default_factory=QualityGateConfig)
|
|
34
|
+
report: ReportConfig = field(default_factory=ReportConfig)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_config(path: str | None) -> Config:
|
|
38
|
+
if path is None:
|
|
39
|
+
default = Path("supersonar.toml")
|
|
40
|
+
if not default.exists():
|
|
41
|
+
return Config()
|
|
42
|
+
path = str(default)
|
|
43
|
+
|
|
44
|
+
cfg_path = Path(path)
|
|
45
|
+
if not cfg_path.exists():
|
|
46
|
+
raise FileNotFoundError(f"Config file not found: {cfg_path}")
|
|
47
|
+
|
|
48
|
+
with cfg_path.open("rb") as fh:
|
|
49
|
+
payload = tomllib.load(fh)
|
|
50
|
+
|
|
51
|
+
scan = payload.get("scan", {})
|
|
52
|
+
quality_gate = payload.get("quality_gate", {})
|
|
53
|
+
report = payload.get("report", {})
|
|
54
|
+
|
|
55
|
+
config = Config()
|
|
56
|
+
config.scan.exclude = list(scan.get("exclude", config.scan.exclude))
|
|
57
|
+
config.quality_gate.fail_on = quality_gate.get("fail_on")
|
|
58
|
+
config.quality_gate.max_issues = quality_gate.get("max_issues")
|
|
59
|
+
config.report.output_format = report.get("format", config.report.output_format)
|
|
60
|
+
config.report.out = report.get("out")
|
|
61
|
+
return config
|
|
62
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
Severity = Literal["low", "medium", "high", "critical"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class Issue:
|
|
11
|
+
rule_id: str
|
|
12
|
+
title: str
|
|
13
|
+
severity: Severity
|
|
14
|
+
message: str
|
|
15
|
+
file_path: str
|
|
16
|
+
line: int
|
|
17
|
+
column: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class ScanResult:
|
|
22
|
+
issues: list[Issue]
|
|
23
|
+
files_scanned: int
|
|
24
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from supersonar.models import ScanResult, Severity
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SEVERITY_ORDER: dict[Severity, int] = {
|
|
7
|
+
"low": 1,
|
|
8
|
+
"medium": 2,
|
|
9
|
+
"high": 3,
|
|
10
|
+
"critical": 4,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def evaluate_gate(
|
|
15
|
+
result: ScanResult,
|
|
16
|
+
fail_on: Severity | None = None,
|
|
17
|
+
max_issues: int | None = None,
|
|
18
|
+
) -> tuple[bool, list[str]]:
|
|
19
|
+
failed_reasons: list[str] = []
|
|
20
|
+
|
|
21
|
+
if fail_on is not None:
|
|
22
|
+
threshold = SEVERITY_ORDER[fail_on]
|
|
23
|
+
if any(SEVERITY_ORDER[issue.severity] >= threshold for issue in result.issues):
|
|
24
|
+
failed_reasons.append(f"Detected issue severity >= '{fail_on}'")
|
|
25
|
+
|
|
26
|
+
if max_issues is not None and len(result.issues) > max_issues:
|
|
27
|
+
failed_reasons.append(f"Issue count {len(result.issues)} exceeds max_issues={max_issues}")
|
|
28
|
+
|
|
29
|
+
return (len(failed_reasons) == 0, failed_reasons)
|
|
30
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from supersonar.models import Issue, ScanResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _issue_to_dict(issue: Issue) -> dict[str, Any]:
|
|
12
|
+
return {
|
|
13
|
+
"rule_id": issue.rule_id,
|
|
14
|
+
"title": issue.title,
|
|
15
|
+
"severity": issue.severity,
|
|
16
|
+
"message": issue.message,
|
|
17
|
+
"file_path": issue.file_path,
|
|
18
|
+
"line": issue.line,
|
|
19
|
+
"column": issue.column,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_json_report(result: ScanResult) -> dict[str, Any]:
|
|
24
|
+
counts = Counter(issue.severity for issue in result.issues)
|
|
25
|
+
return {
|
|
26
|
+
"files_scanned": result.files_scanned,
|
|
27
|
+
"issues_total": len(result.issues),
|
|
28
|
+
"severity_counts": dict(counts),
|
|
29
|
+
"issues": [_issue_to_dict(issue) for issue in result.issues],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_sarif_report(result: ScanResult) -> dict[str, Any]:
|
|
34
|
+
sarif_results: list[dict[str, Any]] = []
|
|
35
|
+
for issue in result.issues:
|
|
36
|
+
sarif_results.append(
|
|
37
|
+
{
|
|
38
|
+
"ruleId": issue.rule_id,
|
|
39
|
+
"level": _severity_to_level(issue.severity),
|
|
40
|
+
"message": {"text": f"{issue.title}: {issue.message}"},
|
|
41
|
+
"locations": [
|
|
42
|
+
{
|
|
43
|
+
"physicalLocation": {
|
|
44
|
+
"artifactLocation": {"uri": issue.file_path},
|
|
45
|
+
"region": {"startLine": issue.line, "startColumn": issue.column},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
54
|
+
"version": "2.1.0",
|
|
55
|
+
"runs": [
|
|
56
|
+
{
|
|
57
|
+
"tool": {"driver": {"name": "supersonar", "informationUri": "https://example.com"}},
|
|
58
|
+
"results": sarif_results,
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_report(payload: dict[str, Any], out: str | None) -> None:
|
|
65
|
+
rendered = json.dumps(payload, indent=2)
|
|
66
|
+
if out is None:
|
|
67
|
+
print(rendered)
|
|
68
|
+
return
|
|
69
|
+
output_path = Path(out)
|
|
70
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _severity_to_level(severity: str) -> str:
|
|
75
|
+
mapping = {
|
|
76
|
+
"low": "note",
|
|
77
|
+
"medium": "warning",
|
|
78
|
+
"high": "error",
|
|
79
|
+
"critical": "error",
|
|
80
|
+
}
|
|
81
|
+
return mapping.get(severity, "warning")
|
|
82
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from supersonar.models import Issue
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SECRET_PATTERN = re.compile(
|
|
11
|
+
r"(api[_-]?key|secret|token|password)\s*=\s*['\"][^'\"]{8,}['\"]",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PythonRuleEngine:
|
|
17
|
+
def run(self, file_path: Path) -> list[Issue]:
|
|
18
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
19
|
+
issues: list[Issue] = []
|
|
20
|
+
issues.extend(self._find_todo_fixme(source, file_path))
|
|
21
|
+
issues.extend(self._find_secrets(source, file_path))
|
|
22
|
+
issues.extend(self._analyze_ast(source, file_path))
|
|
23
|
+
return issues
|
|
24
|
+
|
|
25
|
+
def _find_todo_fixme(self, source: str, file_path: Path) -> list[Issue]:
|
|
26
|
+
issues: list[Issue] = []
|
|
27
|
+
for idx, line in enumerate(source.splitlines(), start=1):
|
|
28
|
+
if "TODO" in line or "FIXME" in line:
|
|
29
|
+
issues.append(
|
|
30
|
+
Issue(
|
|
31
|
+
rule_id="SS004",
|
|
32
|
+
title="Work item marker in source",
|
|
33
|
+
severity="low",
|
|
34
|
+
message="Found TODO/FIXME marker. Track and resolve before release.",
|
|
35
|
+
file_path=str(file_path),
|
|
36
|
+
line=idx,
|
|
37
|
+
column=max(line.find("TODO"), line.find("FIXME"), 0) + 1,
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
return issues
|
|
41
|
+
|
|
42
|
+
def _find_secrets(self, source: str, file_path: Path) -> list[Issue]:
|
|
43
|
+
issues: list[Issue] = []
|
|
44
|
+
for idx, line in enumerate(source.splitlines(), start=1):
|
|
45
|
+
if SECRET_PATTERN.search(line):
|
|
46
|
+
issues.append(
|
|
47
|
+
Issue(
|
|
48
|
+
rule_id="SS003",
|
|
49
|
+
title="Potential hardcoded secret",
|
|
50
|
+
severity="high",
|
|
51
|
+
message="Potential credential/token assignment found in source.",
|
|
52
|
+
file_path=str(file_path),
|
|
53
|
+
line=idx,
|
|
54
|
+
column=1,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return issues
|
|
58
|
+
|
|
59
|
+
def _analyze_ast(self, source: str, file_path: Path) -> list[Issue]:
|
|
60
|
+
issues: list[Issue] = []
|
|
61
|
+
try:
|
|
62
|
+
tree = ast.parse(source)
|
|
63
|
+
except SyntaxError as exc:
|
|
64
|
+
issues.append(
|
|
65
|
+
Issue(
|
|
66
|
+
rule_id="SS000",
|
|
67
|
+
title="Syntax error",
|
|
68
|
+
severity="medium",
|
|
69
|
+
message=f"Could not parse file: {exc.msg}",
|
|
70
|
+
file_path=str(file_path),
|
|
71
|
+
line=exc.lineno or 1,
|
|
72
|
+
column=exc.offset or 1,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return issues
|
|
76
|
+
|
|
77
|
+
for node in ast.walk(tree):
|
|
78
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
79
|
+
if node.func.id in {"eval", "exec"}:
|
|
80
|
+
issues.append(
|
|
81
|
+
Issue(
|
|
82
|
+
rule_id="SS001",
|
|
83
|
+
title="Dangerous dynamic execution",
|
|
84
|
+
severity="critical",
|
|
85
|
+
message=f"Avoid {node.func.id}() because it executes dynamic code.",
|
|
86
|
+
file_path=str(file_path),
|
|
87
|
+
line=getattr(node, "lineno", 1),
|
|
88
|
+
column=getattr(node, "col_offset", 0) + 1,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if isinstance(node, ast.ExceptHandler):
|
|
92
|
+
is_bare = node.type is None
|
|
93
|
+
is_exception = isinstance(node.type, ast.Name) and node.type.id == "Exception"
|
|
94
|
+
if is_bare or is_exception:
|
|
95
|
+
issues.append(
|
|
96
|
+
Issue(
|
|
97
|
+
rule_id="SS002",
|
|
98
|
+
title="Broad exception handling",
|
|
99
|
+
severity="medium",
|
|
100
|
+
message="Avoid bare except or except Exception; catch specific errors.",
|
|
101
|
+
file_path=str(file_path),
|
|
102
|
+
line=getattr(node, "lineno", 1),
|
|
103
|
+
column=getattr(node, "col_offset", 0) + 1,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return issues
|
|
107
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from supersonar.models import Issue, ScanResult
|
|
6
|
+
from supersonar.rules import PythonRuleEngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _should_exclude(path: Path, excludes: list[str]) -> bool:
|
|
10
|
+
parts = set(path.parts)
|
|
11
|
+
return any(ex in parts for ex in excludes)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def scan_path(root: str, excludes: list[str]) -> ScanResult:
|
|
15
|
+
root_path = Path(root).resolve()
|
|
16
|
+
rule_engine = PythonRuleEngine()
|
|
17
|
+
issues: list[Issue] = []
|
|
18
|
+
files_scanned = 0
|
|
19
|
+
|
|
20
|
+
for file_path in root_path.rglob("*.py"):
|
|
21
|
+
if _should_exclude(file_path, excludes):
|
|
22
|
+
continue
|
|
23
|
+
files_scanned += 1
|
|
24
|
+
issues.extend(rule_engine.run(file_path))
|
|
25
|
+
|
|
26
|
+
return ScanResult(issues=issues, files_scanned=files_scanned)
|
|
27
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: supersonar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A SonarQube-like static analysis CLI for Python projects.
|
|
5
|
+
Author: Supersonar Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: static-analysis,lint,security,ci,sonarqube
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# supersonar
|
|
16
|
+
|
|
17
|
+
`supersonar` is a lightweight, SonarQube-inspired static analysis CLI for Python projects.
|
|
18
|
+
It is designed for local use and CI pipelines via `pip install`.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install .
|
|
24
|
+
supersonar scan . --format json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CI usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install supersonar
|
|
31
|
+
supersonar scan . --format sarif --out reports/supersonar.sarif --fail-on high
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Config (`supersonar.toml`)
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
[scan]
|
|
38
|
+
exclude = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
|
|
39
|
+
|
|
40
|
+
[quality_gate]
|
|
41
|
+
fail_on = "high"
|
|
42
|
+
max_issues = 0
|
|
43
|
+
|
|
44
|
+
[report]
|
|
45
|
+
format = "json"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Rule coverage (MVP)
|
|
49
|
+
|
|
50
|
+
- `SS001` - dangerous `eval` / `exec`
|
|
51
|
+
- `SS002` - broad `except Exception` or bare `except`
|
|
52
|
+
- `SS003` - hardcoded secret-like tokens
|
|
53
|
+
- `SS004` - `TODO` / `FIXME` markers
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
supersonar/__init__.py
|
|
4
|
+
supersonar/__main__.py
|
|
5
|
+
supersonar/cli.py
|
|
6
|
+
supersonar/config.py
|
|
7
|
+
supersonar/models.py
|
|
8
|
+
supersonar/quality_gate.py
|
|
9
|
+
supersonar/reporters.py
|
|
10
|
+
supersonar/scanner.py
|
|
11
|
+
supersonar.egg-info/PKG-INFO
|
|
12
|
+
supersonar.egg-info/SOURCES.txt
|
|
13
|
+
supersonar.egg-info/dependency_links.txt
|
|
14
|
+
supersonar.egg-info/entry_points.txt
|
|
15
|
+
supersonar.egg-info/top_level.txt
|
|
16
|
+
supersonar/rules/__init__.py
|
|
17
|
+
supersonar/rules/python.py
|
|
18
|
+
tests/test_quality_gate.py
|
|
19
|
+
tests/test_scanner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
supersonar
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from supersonar.models import Issue, ScanResult
|
|
6
|
+
from supersonar.quality_gate import evaluate_gate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QualityGateTests(unittest.TestCase):
|
|
10
|
+
def test_fails_on_severity_threshold(self) -> None:
|
|
11
|
+
result = ScanResult(
|
|
12
|
+
issues=[
|
|
13
|
+
Issue(
|
|
14
|
+
rule_id="SS001",
|
|
15
|
+
title="t",
|
|
16
|
+
severity="critical",
|
|
17
|
+
message="m",
|
|
18
|
+
file_path="x.py",
|
|
19
|
+
line=1,
|
|
20
|
+
column=1,
|
|
21
|
+
)
|
|
22
|
+
],
|
|
23
|
+
files_scanned=1,
|
|
24
|
+
)
|
|
25
|
+
passed, reasons = evaluate_gate(result, fail_on="high")
|
|
26
|
+
self.assertFalse(passed)
|
|
27
|
+
self.assertTrue(reasons)
|
|
28
|
+
|
|
29
|
+
def test_passes_under_max_issues(self) -> None:
|
|
30
|
+
result = ScanResult(issues=[], files_scanned=1)
|
|
31
|
+
passed, reasons = evaluate_gate(result, max_issues=0)
|
|
32
|
+
self.assertTrue(passed)
|
|
33
|
+
self.assertEqual(reasons, [])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
unittest.main()
|
|
38
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from supersonar.scanner import scan_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScannerTests(unittest.TestCase):
|
|
11
|
+
def test_detects_eval_issue(self) -> None:
|
|
12
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
13
|
+
root = Path(tmp)
|
|
14
|
+
sample = root / "sample.py"
|
|
15
|
+
sample.write_text("value = eval('2+2')\n", encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
result = scan_path(str(root), excludes=[])
|
|
18
|
+
rule_ids = {issue.rule_id for issue in result.issues}
|
|
19
|
+
|
|
20
|
+
self.assertIn("SS001", rule_ids)
|
|
21
|
+
self.assertEqual(result.files_scanned, 1)
|
|
22
|
+
|
|
23
|
+
def test_excludes_directory(self) -> None:
|
|
24
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
25
|
+
root = Path(tmp)
|
|
26
|
+
src = root / "src"
|
|
27
|
+
src.mkdir()
|
|
28
|
+
ignored = root / "venv"
|
|
29
|
+
ignored.mkdir()
|
|
30
|
+
(src / "ok.py").write_text("print('ok')\n", encoding="utf-8")
|
|
31
|
+
(ignored / "bad.py").write_text("eval('x')\n", encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
result = scan_path(str(root), excludes=["venv"])
|
|
34
|
+
rule_ids = {issue.rule_id for issue in result.issues}
|
|
35
|
+
|
|
36
|
+
self.assertNotIn("SS001", rule_ids)
|
|
37
|
+
self.assertEqual(result.files_scanned, 1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
unittest.main()
|
|
42
|
+
|