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.
Files changed (38) hide show
  1. ansede_static/__init__.py +90 -0
  2. ansede_static/_types.py +178 -0
  3. ansede_static/cache/__init__.py +9 -0
  4. ansede_static/cache/sqlite_store.py +126 -0
  5. ansede_static/cli.py +887 -0
  6. ansede_static/config.py +237 -0
  7. ansede_static/engine/explain.py +191 -0
  8. ansede_static/engine/triage.py +106 -0
  9. ansede_static/engine_version.py +36 -0
  10. ansede_static/ir/__init__.py +19 -0
  11. ansede_static/ir/global_graph.py +149 -0
  12. ansede_static/ir/issues.py +115 -0
  13. ansede_static/js_analyzer.py +109 -0
  14. ansede_static/js_ast_analyzer.py +639 -0
  15. ansede_static/js_engine/__init__.py +45 -0
  16. ansede_static/js_engine/backends.py +111 -0
  17. ansede_static/js_engine/common.py +135 -0
  18. ansede_static/js_engine/context_checks.py +133 -0
  19. ansede_static/js_engine/pattern_rules.py +327 -0
  20. ansede_static/js_engine/project.py +1596 -0
  21. ansede_static/js_engine/react.py +326 -0
  22. ansede_static/js_engine/routes.py +1398 -0
  23. ansede_static/js_engine/structure.py +501 -0
  24. ansede_static/js_engine/taint.py +215 -0
  25. ansede_static/js_engine/taint_checks.py +289 -0
  26. ansede_static/monorepo.py +269 -0
  27. ansede_static/python_analyzer.py +3538 -0
  28. ansede_static/reporters.py +724 -0
  29. ansede_static/rules.py +1371 -0
  30. ansede_static/sanitizers.json +79 -0
  31. ansede_static/sbom.py +344 -0
  32. ansede_static/schema.py +60 -0
  33. ansede_static/yaml_rules.py +202 -0
  34. ansede_static-1.2.0.dist-info/METADATA +677 -0
  35. ansede_static-1.2.0.dist-info/RECORD +38 -0
  36. ansede_static-1.2.0.dist-info/WHEEL +4 -0
  37. ansede_static-1.2.0.dist-info/entry_points.txt +2 -0
  38. 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
@@ -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,9 @@
1
+ """
2
+ ansede_static.cache
3
+ ───────────────────
4
+ Zero-dependency cache helpers.
5
+ """
6
+ from ansede_static.cache.sqlite_store import SQLiteStore, stable_hash
7
+
8
+
9
+ __all__ = ["SQLiteStore", "stable_hash"]
@@ -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()