ansede-static 1.2.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.
- ansede_static/__init__.py +90 -0
- ansede_static/_types.py +178 -0
- ansede_static/cache/__init__.py +9 -0
- ansede_static/cache/sqlite_store.py +126 -0
- ansede_static/cli.py +887 -0
- ansede_static/config.py +237 -0
- ansede_static/engine/explain.py +191 -0
- ansede_static/engine/triage.py +106 -0
- ansede_static/engine_version.py +36 -0
- ansede_static/ir/__init__.py +19 -0
- ansede_static/ir/global_graph.py +149 -0
- ansede_static/ir/issues.py +115 -0
- ansede_static/js_analyzer.py +109 -0
- ansede_static/js_ast_analyzer.py +639 -0
- ansede_static/js_engine/__init__.py +45 -0
- ansede_static/js_engine/backends.py +111 -0
- ansede_static/js_engine/common.py +135 -0
- ansede_static/js_engine/context_checks.py +133 -0
- ansede_static/js_engine/pattern_rules.py +327 -0
- ansede_static/js_engine/project.py +1596 -0
- ansede_static/js_engine/react.py +326 -0
- ansede_static/js_engine/routes.py +1398 -0
- ansede_static/js_engine/structure.py +501 -0
- ansede_static/js_engine/taint.py +215 -0
- ansede_static/js_engine/taint_checks.py +289 -0
- ansede_static/monorepo.py +269 -0
- ansede_static/python_analyzer.py +3538 -0
- ansede_static/reporters.py +724 -0
- ansede_static/rules.py +1371 -0
- ansede_static/sanitizers.json +79 -0
- ansede_static/sbom.py +344 -0
- ansede_static/schema.py +60 -0
- ansede_static/yaml_rules.py +202 -0
- ansede_static-1.2.0.dist-info/METADATA +677 -0
- ansede_static-1.2.0.dist-info/RECORD +38 -0
- ansede_static-1.2.0.dist-info/WHEEL +4 -0
- ansede_static-1.2.0.dist-info/entry_points.txt +2 -0
- ansede_static-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ansede_static
|
|
3
|
+
─────────────
|
|
4
|
+
Zero-dependency SAST security scanner for Python and JavaScript.
|
|
5
|
+
|
|
6
|
+
Quick start:
|
|
7
|
+
from ansede_static import scan_file, scan_code
|
|
8
|
+
|
|
9
|
+
result = scan_file("myapp.py")
|
|
10
|
+
for finding in result.sorted_findings():
|
|
11
|
+
print(finding.severity.value, finding.title, finding.line)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ansede_static._types import AnalysisResult, Finding, Severity
|
|
16
|
+
from ansede_static.config import AnsedeConfig, apply_config_to_results, temporary_analyzer_config
|
|
17
|
+
from ansede_static.engine_version import SCHEMA_VERSION, get_engine_version
|
|
18
|
+
from ansede_static.python_analyzer import analyze_python, analyze_file as _py_file
|
|
19
|
+
from ansede_static.js_engine.backends import list_js_backends, run_js_analysis
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"scan_file",
|
|
26
|
+
"scan_code",
|
|
27
|
+
"AnalysisResult",
|
|
28
|
+
"AnsedeConfig",
|
|
29
|
+
"Finding",
|
|
30
|
+
"Severity",
|
|
31
|
+
"SCHEMA_VERSION",
|
|
32
|
+
"list_js_backends",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
__version__ = get_engine_version()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_PYTHON_EXTS = frozenset({".py", ".pyi", ".pyw"})
|
|
39
|
+
_JS_EXTS = frozenset({".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scan_file(path: str | Path, config: AnsedeConfig | None = None, *, js_backend: str = "auto") -> AnalysisResult:
|
|
43
|
+
"""
|
|
44
|
+
Scan a file and return an AnalysisResult.
|
|
45
|
+
|
|
46
|
+
Language is detected from the file extension.
|
|
47
|
+
Raises ValueError for unsupported file types.
|
|
48
|
+
"""
|
|
49
|
+
p = Path(path)
|
|
50
|
+
ext = p.suffix.lower()
|
|
51
|
+
with temporary_analyzer_config(config):
|
|
52
|
+
if ext in _PYTHON_EXTS:
|
|
53
|
+
result = _py_file(p)
|
|
54
|
+
elif ext in _JS_EXTS:
|
|
55
|
+
code = p.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
result, _ = run_js_analysis(code, filename=str(p), requested_backend=js_backend)
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError(f"Unsupported file extension: {ext!r}. Supported: .py, .js, .ts (and variants).")
|
|
59
|
+
apply_config_to_results([result], config)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scan_code(
|
|
64
|
+
code: str,
|
|
65
|
+
language: str,
|
|
66
|
+
filename: str = "",
|
|
67
|
+
config: AnsedeConfig | None = None,
|
|
68
|
+
*,
|
|
69
|
+
js_backend: str = "auto",
|
|
70
|
+
) -> AnalysisResult:
|
|
71
|
+
"""
|
|
72
|
+
Scan source code provided as a string.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
code: Source code.
|
|
76
|
+
language: "python" or "javascript".
|
|
77
|
+
filename: Optional file name for error messages.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: if language is not supported.
|
|
81
|
+
"""
|
|
82
|
+
with temporary_analyzer_config(config):
|
|
83
|
+
if language == "python":
|
|
84
|
+
result = analyze_python(code, filename=filename)
|
|
85
|
+
elif language in ("javascript", "typescript", "js", "ts"):
|
|
86
|
+
result, _ = run_js_analysis(code, filename=filename, requested_backend=js_backend)
|
|
87
|
+
else:
|
|
88
|
+
raise ValueError(f"Unsupported language: {language!r}. Must be 'python' or 'javascript'.")
|
|
89
|
+
apply_config_to_results([result], config)
|
|
90
|
+
return result
|
ansede_static/_types.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ansede_static._types
|
|
3
|
+
────────────────────
|
|
4
|
+
Shared data types for the Ansede Static analyzer.
|
|
5
|
+
Zero external dependencies — pure stdlib only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Severity(str, Enum):
|
|
15
|
+
CRITICAL = "critical"
|
|
16
|
+
HIGH = "high"
|
|
17
|
+
MEDIUM = "medium"
|
|
18
|
+
LOW = "low"
|
|
19
|
+
INFO = "info"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def sort_key(self) -> int:
|
|
23
|
+
return {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}[self.value]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def badge(self) -> str:
|
|
27
|
+
return {"critical": "[CRIT]", "high": "[HIGH]", "medium": "[MEDI]",
|
|
28
|
+
"low": "[LOW ]", "info": "[INFO]"}[self.value]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TraceFrame:
|
|
33
|
+
"""A single source/propagation/sink step for a finding trace."""
|
|
34
|
+
kind: str
|
|
35
|
+
label: str
|
|
36
|
+
line: int | None = None
|
|
37
|
+
start_column: int = 1
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"kind": self.kind,
|
|
42
|
+
"label": self.label,
|
|
43
|
+
"line": self.line,
|
|
44
|
+
"start_column": self.start_column,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Finding:
|
|
50
|
+
"""A single security or quality finding."""
|
|
51
|
+
category: str # "security" | "bug" | "error-handling" | "architecture"
|
|
52
|
+
severity: Severity
|
|
53
|
+
title: str # one-line summary
|
|
54
|
+
description: str # detailed explanation
|
|
55
|
+
line: int | None = None
|
|
56
|
+
suggestion: str = "" # concrete fix
|
|
57
|
+
rule_id: str = "" # stable analyzer-specific rule id, e.g. "PY-004"
|
|
58
|
+
cwe: str = "" # e.g. "CWE-89"
|
|
59
|
+
agent: str = "" # "python-analyzer" | "js-analyzer"
|
|
60
|
+
confidence: float = 1.0
|
|
61
|
+
auto_fix: str = "" # before→after code suggestion
|
|
62
|
+
explanation: str = "" # educational markdown tutorial
|
|
63
|
+
trace: tuple[TraceFrame, ...] = ()
|
|
64
|
+
analysis_kind: str = "pattern"
|
|
65
|
+
triggering_code: str = "" # source line that triggered the finding
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def finding_class(self) -> str:
|
|
69
|
+
"""Coarse-grained class used to separate security from quality findings."""
|
|
70
|
+
if self.cwe or self.category == "security":
|
|
71
|
+
return "security"
|
|
72
|
+
return "quality"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def effective_rule_id(self) -> str:
|
|
76
|
+
"""Return the best available stable rule identifier for downstream tooling."""
|
|
77
|
+
return self.rule_id or self.cwe or self.title
|
|
78
|
+
|
|
79
|
+
def as_dict(self, *, language: str | None = None) -> dict[str, Any]:
|
|
80
|
+
from ansede_static.rules import rule_record_for_finding
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"severity": self.severity.value,
|
|
84
|
+
"title": self.title,
|
|
85
|
+
"description": self.description,
|
|
86
|
+
"line": self.line,
|
|
87
|
+
"suggestion": self.suggestion,
|
|
88
|
+
"rule_id": self.rule_id,
|
|
89
|
+
"cwe": self.cwe,
|
|
90
|
+
"category": self.category,
|
|
91
|
+
"finding_class": self.finding_class,
|
|
92
|
+
"agent": self.agent,
|
|
93
|
+
"confidence": self.confidence,
|
|
94
|
+
"auto_fix": self.auto_fix,
|
|
95
|
+
"explanation": self.explanation,
|
|
96
|
+
"analysis_kind": self.analysis_kind,
|
|
97
|
+
"trace": [frame.as_dict() for frame in self.trace],
|
|
98
|
+
"rule": rule_record_for_finding(
|
|
99
|
+
self.rule_id,
|
|
100
|
+
cwe=self.cwe,
|
|
101
|
+
title=self.title,
|
|
102
|
+
category=self.category,
|
|
103
|
+
severity=self.severity.value,
|
|
104
|
+
language=language,
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class AnalysisResult:
|
|
111
|
+
"""Complete output from scanning a single file."""
|
|
112
|
+
file_path: str
|
|
113
|
+
language: str # "python" | "javascript"
|
|
114
|
+
findings: list[Finding] = field(default_factory=list)
|
|
115
|
+
lines_scanned: int = 0
|
|
116
|
+
parse_error: str = ""
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def critical_count(self) -> int:
|
|
120
|
+
return sum(1 for f in self.findings if f.severity == Severity.CRITICAL)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def high_count(self) -> int:
|
|
124
|
+
return sum(1 for f in self.findings if f.severity == Severity.HIGH)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def medium_count(self) -> int:
|
|
128
|
+
return sum(1 for f in self.findings if f.severity == Severity.MEDIUM)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def low_count(self) -> int:
|
|
132
|
+
return sum(1 for f in self.findings if f.severity == Severity.LOW)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def info_count(self) -> int:
|
|
136
|
+
return sum(1 for f in self.findings if f.severity == Severity.INFO)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def security_count(self) -> int:
|
|
140
|
+
return sum(1 for f in self.findings if f.finding_class == "security")
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def quality_count(self) -> int:
|
|
144
|
+
return sum(1 for f in self.findings if f.finding_class == "quality")
|
|
145
|
+
|
|
146
|
+
def sorted_findings(self) -> list[Finding]:
|
|
147
|
+
return sorted(self.findings, key=lambda f: f.severity.sort_key)
|
|
148
|
+
|
|
149
|
+
def category_counts(self) -> dict[str, int]:
|
|
150
|
+
counts: dict[str, int] = {}
|
|
151
|
+
for finding in self.findings:
|
|
152
|
+
counts[finding.category] = counts.get(finding.category, 0) + 1
|
|
153
|
+
return dict(sorted(counts.items()))
|
|
154
|
+
|
|
155
|
+
def summary_dict(self) -> dict[str, Any]:
|
|
156
|
+
return {
|
|
157
|
+
"critical": self.critical_count,
|
|
158
|
+
"high": self.high_count,
|
|
159
|
+
"medium": self.medium_count,
|
|
160
|
+
"low": self.low_count,
|
|
161
|
+
"info": self.info_count,
|
|
162
|
+
"security_findings": self.security_count,
|
|
163
|
+
"quality_findings": self.quality_count,
|
|
164
|
+
"by_category": self.category_counts(),
|
|
165
|
+
"total": len(self.findings),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def as_dict(self) -> dict[str, Any]:
|
|
169
|
+
return {
|
|
170
|
+
"file": self.file_path,
|
|
171
|
+
"file_path": self.file_path,
|
|
172
|
+
"language": self.language,
|
|
173
|
+
"lines": self.lines_scanned,
|
|
174
|
+
"lines_scanned": self.lines_scanned,
|
|
175
|
+
"parse_error": self.parse_error,
|
|
176
|
+
"findings": [f.as_dict(language=self.language) for f in self.sorted_findings()],
|
|
177
|
+
"summary": self.summary_dict(),
|
|
178
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ansede_static.cache.sqlite_store
|
|
3
|
+
────────────────────────────────
|
|
4
|
+
Tiny SQLite-backed JSON key-value store for incremental scan state.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stable_hash(value: str | bytes) -> str:
|
|
16
|
+
"""Return a stable SHA-256 hex digest for content-addressing."""
|
|
17
|
+
payload = value.encode("utf-8") if isinstance(value, str) else value
|
|
18
|
+
return hashlib.sha256(payload).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SQLiteStore:
|
|
22
|
+
"""Simple bucketed JSON store backed by sqlite3."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: str | Path):
|
|
25
|
+
self.path = Path(path)
|
|
26
|
+
self._connection: sqlite3.Connection | None = None
|
|
27
|
+
|
|
28
|
+
def connect(self) -> sqlite3.Connection:
|
|
29
|
+
"""Open the backing database and initialise the schema if needed."""
|
|
30
|
+
if self._connection is None:
|
|
31
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self._connection = sqlite3.connect(self.path)
|
|
33
|
+
self._connection.row_factory = sqlite3.Row
|
|
34
|
+
self._initialise()
|
|
35
|
+
return self._connection
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
"""Close the database connection if one is open."""
|
|
39
|
+
if self._connection is not None:
|
|
40
|
+
self._connection.close()
|
|
41
|
+
self._connection = None
|
|
42
|
+
|
|
43
|
+
def _initialise(self) -> None:
|
|
44
|
+
conn = self.connect_raw()
|
|
45
|
+
conn.execute(
|
|
46
|
+
"""
|
|
47
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
48
|
+
bucket TEXT NOT NULL,
|
|
49
|
+
cache_key TEXT NOT NULL,
|
|
50
|
+
value_json TEXT NOT NULL,
|
|
51
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
52
|
+
PRIMARY KEY (bucket, cache_key)
|
|
53
|
+
)
|
|
54
|
+
"""
|
|
55
|
+
)
|
|
56
|
+
conn.commit()
|
|
57
|
+
|
|
58
|
+
def connect_raw(self) -> sqlite3.Connection:
|
|
59
|
+
if self._connection is None:
|
|
60
|
+
raise RuntimeError("SQLiteStore is not connected")
|
|
61
|
+
return self._connection
|
|
62
|
+
|
|
63
|
+
def set_json(self, bucket: str, key: str, value: Any) -> None:
|
|
64
|
+
"""Store a JSON-serialisable value under ``bucket``/``key``."""
|
|
65
|
+
payload = json.dumps(value, sort_keys=True)
|
|
66
|
+
conn = self.connect()
|
|
67
|
+
conn.execute(
|
|
68
|
+
"""
|
|
69
|
+
INSERT INTO cache_entries(bucket, cache_key, value_json)
|
|
70
|
+
VALUES(?, ?, ?)
|
|
71
|
+
ON CONFLICT(bucket, cache_key)
|
|
72
|
+
DO UPDATE SET value_json = excluded.value_json, updated_at = CURRENT_TIMESTAMP
|
|
73
|
+
""",
|
|
74
|
+
(bucket, key, payload),
|
|
75
|
+
)
|
|
76
|
+
conn.commit()
|
|
77
|
+
|
|
78
|
+
def get_json(self, bucket: str, key: str) -> Any | None:
|
|
79
|
+
"""Load a stored JSON value, returning ``None`` when absent."""
|
|
80
|
+
conn = self.connect()
|
|
81
|
+
row = conn.execute(
|
|
82
|
+
"SELECT value_json FROM cache_entries WHERE bucket = ? AND cache_key = ?",
|
|
83
|
+
(bucket, key),
|
|
84
|
+
).fetchone()
|
|
85
|
+
if row is None:
|
|
86
|
+
return None
|
|
87
|
+
return json.loads(row[0])
|
|
88
|
+
|
|
89
|
+
def delete(self, bucket: str, key: str) -> None:
|
|
90
|
+
"""Delete a cache entry if it exists."""
|
|
91
|
+
conn = self.connect()
|
|
92
|
+
conn.execute(
|
|
93
|
+
"DELETE FROM cache_entries WHERE bucket = ? AND cache_key = ?",
|
|
94
|
+
(bucket, key),
|
|
95
|
+
)
|
|
96
|
+
conn.commit()
|
|
97
|
+
|
|
98
|
+
def keys(self, bucket: str) -> list[str]:
|
|
99
|
+
"""Return all keys stored in a bucket."""
|
|
100
|
+
conn = self.connect()
|
|
101
|
+
rows = conn.execute(
|
|
102
|
+
"SELECT cache_key FROM cache_entries WHERE bucket = ? ORDER BY cache_key",
|
|
103
|
+
(bucket,),
|
|
104
|
+
).fetchall()
|
|
105
|
+
return [str(row[0]) for row in rows]
|
|
106
|
+
|
|
107
|
+
def evict_older_than(self, bucket: str, days: int) -> int:
|
|
108
|
+
"""Delete entries in *bucket* not updated within the last *days* days.
|
|
109
|
+
|
|
110
|
+
Returns the number of rows deleted. Keeps the cache bounded on
|
|
111
|
+
long-running incremental installations.
|
|
112
|
+
"""
|
|
113
|
+
conn = self.connect()
|
|
114
|
+
cursor = conn.execute(
|
|
115
|
+
"DELETE FROM cache_entries WHERE bucket = ? AND updated_at < datetime('now', ? || ' days')",
|
|
116
|
+
(bucket, f"-{days}"),
|
|
117
|
+
)
|
|
118
|
+
conn.commit()
|
|
119
|
+
return cursor.rowcount
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> SQLiteStore:
|
|
122
|
+
self.connect()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
126
|
+
self.close()
|