aletheore 0.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.
- aletheore/__init__.py +1 -0
- aletheore/adapters/__init__.py +1 -0
- aletheore/adapters/base.py +13 -0
- aletheore/adapters/claude_code.py +38 -0
- aletheore/architecture.py +137 -0
- aletheore/cli.py +417 -0
- aletheore/dashboard.py +892 -0
- aletheore/endpoints.py +1121 -0
- aletheore/evidence.py +118 -0
- aletheore/git_intel/__init__.py +1 -0
- aletheore/git_intel/analyzer.py +176 -0
- aletheore/healthcheck.py +102 -0
- aletheore/history.py +190 -0
- aletheore/licenses.py +176 -0
- aletheore/mcp_server.py +209 -0
- aletheore/query.py +90 -0
- aletheore/report.py +79 -0
- aletheore/scanner/__init__.py +1 -0
- aletheore/scanner/detect.py +280 -0
- aletheore/scanner/graph.py +1169 -0
- aletheore/secrets.py +192 -0
- aletheore/static/logo.png +0 -0
- aletheore/vulnerabilities.py +108 -0
- aletheore-0.2.0.dist-info/METADATA +361 -0
- aletheore-0.2.0.dist-info/RECORD +29 -0
- aletheore-0.2.0.dist-info/WHEEL +5 -0
- aletheore-0.2.0.dist-info/entry_points.txt +2 -0
- aletheore-0.2.0.dist-info/licenses/LICENSE +201 -0
- aletheore-0.2.0.dist-info/top_level.txt +1 -0
aletheore/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentAdapter(ABC):
|
|
5
|
+
name: str = "unnamed"
|
|
6
|
+
|
|
7
|
+
@abstractmethod
|
|
8
|
+
def is_available(self) -> bool:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def invoke(self, instruction: str, cwd: str) -> str:
|
|
13
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from aletheore.adapters.base import AgentAdapter
|
|
5
|
+
|
|
6
|
+
INVOCATION_TIMEOUT_SECONDS = 600
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdapterInvocationError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
14
|
+
name = "claude"
|
|
15
|
+
|
|
16
|
+
def is_available(self) -> bool:
|
|
17
|
+
return shutil.which("claude") is not None
|
|
18
|
+
|
|
19
|
+
def invoke(self, instruction: str, cwd: str) -> str:
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["claude", "-p", instruction],
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
timeout=INVOCATION_TIMEOUT_SECONDS,
|
|
27
|
+
)
|
|
28
|
+
except subprocess.TimeoutExpired as exc:
|
|
29
|
+
raise AdapterInvocationError(
|
|
30
|
+
f"claude invocation timed out after {INVOCATION_TIMEOUT_SECONDS}s"
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
if result.returncode != 0:
|
|
34
|
+
raise AdapterInvocationError(
|
|
35
|
+
f"claude invocation failed (exit {result.returncode}): {result.stderr}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return result.stdout
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import networkx as nx
|
|
5
|
+
from networkx.algorithms.community import greedy_modularity_communities
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_architecture_config(repo_path: Path) -> dict | None:
|
|
9
|
+
config_file = repo_path / ".aletheore.json"
|
|
10
|
+
if not config_file.exists():
|
|
11
|
+
return None
|
|
12
|
+
try:
|
|
13
|
+
data = json.loads(config_file.read_text(encoding="utf-8", errors="ignore"))
|
|
14
|
+
except json.JSONDecodeError:
|
|
15
|
+
return None
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
layer_markers = data.get("layer_markers", {})
|
|
20
|
+
if not isinstance(layer_markers, dict):
|
|
21
|
+
layer_markers = {}
|
|
22
|
+
|
|
23
|
+
cluster_resolution = data.get("cluster_resolution", 1.0)
|
|
24
|
+
if not isinstance(cluster_resolution, (int, float)) or isinstance(cluster_resolution, bool):
|
|
25
|
+
cluster_resolution = 1.0
|
|
26
|
+
|
|
27
|
+
return {"layer_markers": layer_markers, "cluster_resolution": float(cluster_resolution)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
LAYER_FOLDER_MARKERS = {
|
|
31
|
+
"domain": 0,
|
|
32
|
+
"core": 0,
|
|
33
|
+
"entities": 0,
|
|
34
|
+
"application": 1,
|
|
35
|
+
"services": 1,
|
|
36
|
+
"use_cases": 1,
|
|
37
|
+
"infrastructure": 2,
|
|
38
|
+
"infra": 2,
|
|
39
|
+
"adapters": 2,
|
|
40
|
+
"api": 2,
|
|
41
|
+
"routers": 2,
|
|
42
|
+
"web": 2,
|
|
43
|
+
"controllers": 2,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_clusters(dependency_graph: dict, resolution: float = 1.0) -> tuple[list[dict], list[dict]]:
|
|
48
|
+
graph = nx.Graph()
|
|
49
|
+
graph.add_nodes_from(dependency_graph["nodes"])
|
|
50
|
+
graph.add_edges_from(dependency_graph["edges"])
|
|
51
|
+
|
|
52
|
+
communities = list(greedy_modularity_communities(graph, resolution=resolution))
|
|
53
|
+
|
|
54
|
+
cluster_of: dict[str, int] = {}
|
|
55
|
+
clusters = []
|
|
56
|
+
for cluster_id, community in enumerate(communities):
|
|
57
|
+
modules = sorted(community)
|
|
58
|
+
for module in modules:
|
|
59
|
+
cluster_of[module] = cluster_id
|
|
60
|
+
clusters.append({"id": cluster_id, "modules": modules, "internal_edges": 0})
|
|
61
|
+
|
|
62
|
+
for a, b in dependency_graph["edges"]:
|
|
63
|
+
if cluster_of.get(a) is not None and cluster_of.get(a) == cluster_of.get(b):
|
|
64
|
+
clusters[cluster_of[a]]["internal_edges"] += 1
|
|
65
|
+
|
|
66
|
+
cross_pairs: dict[tuple[int, int], list[list[str]]] = {}
|
|
67
|
+
for a, b in dependency_graph["edges"]:
|
|
68
|
+
ca, cb = cluster_of.get(a), cluster_of.get(b)
|
|
69
|
+
if ca is None or cb is None or ca == cb:
|
|
70
|
+
continue
|
|
71
|
+
cross_pairs.setdefault((ca, cb), []).append([a, b])
|
|
72
|
+
|
|
73
|
+
cross_cluster_edges = [
|
|
74
|
+
{"from_cluster": ca, "to_cluster": cb, "count": len(edges), "edges": edges}
|
|
75
|
+
for (ca, cb), edges in sorted(cross_pairs.items())
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
return clusters, cross_cluster_edges
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _classify_module_rank(rel_path: str, markers: dict[str, int]) -> tuple[str, int] | None:
|
|
82
|
+
parts = Path(rel_path).parts
|
|
83
|
+
for part in parts[:-1]:
|
|
84
|
+
if part in markers:
|
|
85
|
+
return part, markers[part]
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def detect_layer_violations(
|
|
90
|
+
dependency_graph: dict, custom_markers: dict[str, int] | None = None
|
|
91
|
+
) -> dict:
|
|
92
|
+
effective_markers = {**LAYER_FOLDER_MARKERS, **(custom_markers or {})}
|
|
93
|
+
|
|
94
|
+
classifications: dict[str, tuple[str, int]] = {}
|
|
95
|
+
for node in dependency_graph["nodes"]:
|
|
96
|
+
result = _classify_module_rank(node, effective_markers)
|
|
97
|
+
if result is not None:
|
|
98
|
+
classifications[node] = result
|
|
99
|
+
|
|
100
|
+
distinct_ranks = {rank for _, rank in classifications.values()}
|
|
101
|
+
|
|
102
|
+
layer_folders: dict[str, set[str]] = {}
|
|
103
|
+
for node, (name, _rank) in classifications.items():
|
|
104
|
+
parts = Path(node).parts
|
|
105
|
+
idx = parts.index(name)
|
|
106
|
+
folder = str(Path(*parts[: idx + 1]))
|
|
107
|
+
layer_folders.setdefault(name, set()).add(folder)
|
|
108
|
+
|
|
109
|
+
layers = [
|
|
110
|
+
{"name": name, "rank": effective_markers[name], "folders": sorted(folders)}
|
|
111
|
+
for name, folders in sorted(layer_folders.items())
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
custom_markers_contributed = bool(custom_markers) and any(
|
|
115
|
+
name in custom_markers for name in layer_folders
|
|
116
|
+
)
|
|
117
|
+
if len(distinct_ranks) < 2 and not custom_markers_contributed:
|
|
118
|
+
return {"convention_detected": False, "layers": [], "violations": []}
|
|
119
|
+
|
|
120
|
+
violations = []
|
|
121
|
+
for from_node, to_node in dependency_graph["edges"]:
|
|
122
|
+
from_info = classifications.get(from_node)
|
|
123
|
+
to_info = classifications.get(to_node)
|
|
124
|
+
if from_info is None or to_info is None:
|
|
125
|
+
continue
|
|
126
|
+
from_name, from_rank = from_info
|
|
127
|
+
to_name, to_rank = to_info
|
|
128
|
+
if from_rank < to_rank:
|
|
129
|
+
violations.append(
|
|
130
|
+
{
|
|
131
|
+
"from": from_node,
|
|
132
|
+
"to": to_node,
|
|
133
|
+
"reason": f"inner layer '{from_name}' imports outer layer '{to_name}'",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return {"convention_detected": True, "layers": layers, "violations": violations}
|
aletheore/cli.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import webbrowser
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from aletheore.adapters.claude_code import AdapterInvocationError, ClaudeCodeAdapter
|
|
10
|
+
from aletheore.dashboard import build_app
|
|
11
|
+
from aletheore.evidence import scan_repository, write_evidence
|
|
12
|
+
from aletheore.healthcheck import run_healthcheck, save_healthcheck
|
|
13
|
+
from aletheore.history import compute_diff, list_snapshots, save_snapshot
|
|
14
|
+
from aletheore.mcp_server import build_server
|
|
15
|
+
from aletheore.query import (
|
|
16
|
+
BranchNotFoundInEvidenceError,
|
|
17
|
+
ModuleNotFoundInEvidenceError,
|
|
18
|
+
QUERY_FUNCTIONS,
|
|
19
|
+
)
|
|
20
|
+
from aletheore.report import (
|
|
21
|
+
AmbiguousAdapterError,
|
|
22
|
+
NoAdapterAvailableError,
|
|
23
|
+
run_reasoning_phase,
|
|
24
|
+
select_adapter,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
KNOWN_ADAPTERS = [ClaudeCodeAdapter()]
|
|
28
|
+
|
|
29
|
+
MANUAL_DIR = str(Path(__file__).resolve().parent.parent / "manual")
|
|
30
|
+
|
|
31
|
+
SPONSOR_NOTE = """
|
|
32
|
+
┌───────────────────────────────────────────────────────────┐
|
|
33
|
+
│ Aletheore is 100% open-source, local, and free. │
|
|
34
|
+
│ No accounts, no tracking — nothing leaves this machine. │
|
|
35
|
+
│ │
|
|
36
|
+
│ If it saved you time, consider supporting development: │
|
|
37
|
+
│ https://github.com/sponsors/ArihantK15 │
|
|
38
|
+
└───────────────────────────────────────────────────────────┘
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _scan(
|
|
43
|
+
repo_path: str,
|
|
44
|
+
check_vulnerabilities: bool,
|
|
45
|
+
scan_git_history: bool,
|
|
46
|
+
check_licenses: bool = True,
|
|
47
|
+
map_endpoints: bool = True,
|
|
48
|
+
) -> tuple[int, dict, Path]:
|
|
49
|
+
repo = Path(repo_path).resolve()
|
|
50
|
+
print(f"Scanning {repo}...")
|
|
51
|
+
evidence = scan_repository(
|
|
52
|
+
repo,
|
|
53
|
+
check_vulnerabilities=check_vulnerabilities,
|
|
54
|
+
scan_git_history=scan_git_history,
|
|
55
|
+
check_licenses=check_licenses,
|
|
56
|
+
map_endpoints=map_endpoints,
|
|
57
|
+
)
|
|
58
|
+
evidence_path = write_evidence(evidence, repo)
|
|
59
|
+
print(f"Evidence written to {evidence_path}")
|
|
60
|
+
snapshot_path = save_snapshot(evidence, repo)
|
|
61
|
+
print(f"Snapshot saved to {snapshot_path}")
|
|
62
|
+
return 0, evidence, evidence_path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _audit(
|
|
66
|
+
repo_path: str,
|
|
67
|
+
forced_agent: str | None,
|
|
68
|
+
check_vulnerabilities: bool,
|
|
69
|
+
scan_git_history: bool,
|
|
70
|
+
check_licenses: bool = True,
|
|
71
|
+
map_endpoints: bool = True,
|
|
72
|
+
) -> int:
|
|
73
|
+
_exit_code, _evidence, evidence_path = _scan(
|
|
74
|
+
repo_path, check_vulnerabilities, scan_git_history, check_licenses, map_endpoints
|
|
75
|
+
)
|
|
76
|
+
repo = Path(repo_path).resolve()
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
adapter = select_adapter(
|
|
80
|
+
KNOWN_ADAPTERS, forced_name=forced_agent, interactive=sys.stdin.isatty()
|
|
81
|
+
)
|
|
82
|
+
except (NoAdapterAvailableError, AmbiguousAdapterError) as exc:
|
|
83
|
+
print(f"error: {exc}")
|
|
84
|
+
print(f"Evidence is still available at {evidence_path} for manual use.")
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
print(f"Running audit with {adapter.name}...")
|
|
88
|
+
try:
|
|
89
|
+
report_path = run_reasoning_phase(adapter, repo_path=str(repo), manual_dir=MANUAL_DIR)
|
|
90
|
+
except AdapterInvocationError as exc:
|
|
91
|
+
print(f"error: {exc}")
|
|
92
|
+
print(f"Evidence is still available at {evidence_path} for manual use.")
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
print(f"Audit report written to {report_path}")
|
|
96
|
+
print(SPONSOR_NOTE)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _query_changes(repo_path: str, full: bool) -> int:
|
|
101
|
+
repo = Path(repo_path).resolve()
|
|
102
|
+
snapshots = list_snapshots(repo)
|
|
103
|
+
|
|
104
|
+
if len(snapshots) < 2:
|
|
105
|
+
print("no prior snapshot to compare against - run 'aletheore scan' again later to compare")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
old = json.loads(snapshots[-2].read_text())
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
print(f"error: most recent snapshot is unreadable ({snapshots[-2]})")
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
new = json.loads(snapshots[-1].read_text())
|
|
115
|
+
diff = compute_diff(old, new, full=full)
|
|
116
|
+
print(json.dumps(diff, indent=2))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _query(kind: str, target: str | None, repo_path: str, full: bool = False) -> int:
|
|
121
|
+
if kind == "changes":
|
|
122
|
+
return _query_changes(repo_path, full)
|
|
123
|
+
|
|
124
|
+
repo = Path(repo_path).resolve()
|
|
125
|
+
evidence_path = repo / ".aletheore" / "evidence.json"
|
|
126
|
+
if not evidence_path.exists():
|
|
127
|
+
print(f"error: no evidence found at {evidence_path}")
|
|
128
|
+
print(f"Run 'aletheore scan {repo}' first.")
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
func, requires_target = QUERY_FUNCTIONS[kind]
|
|
132
|
+
if requires_target and target is None:
|
|
133
|
+
print(f"error: query type '{kind}' requires a target argument")
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
evidence = json.loads(evidence_path.read_text())
|
|
137
|
+
try:
|
|
138
|
+
result = func(evidence, target)
|
|
139
|
+
except (ModuleNotFoundInEvidenceError, BranchNotFoundInEvidenceError) as exc:
|
|
140
|
+
print(f"error: {exc}")
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
print(json.dumps(result, indent=2))
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _diff(
|
|
148
|
+
old_path: str,
|
|
149
|
+
new_path: str,
|
|
150
|
+
full: bool,
|
|
151
|
+
fail_on_new_secrets: bool,
|
|
152
|
+
fail_on_new_vulnerabilities: bool = False,
|
|
153
|
+
fail_on_new_layer_violations: bool = False,
|
|
154
|
+
) -> int:
|
|
155
|
+
old_file = Path(old_path)
|
|
156
|
+
new_file = Path(new_path)
|
|
157
|
+
|
|
158
|
+
if not old_file.exists():
|
|
159
|
+
print(f"error: evidence file not found: {old_file}")
|
|
160
|
+
return 1
|
|
161
|
+
if not new_file.exists():
|
|
162
|
+
print(f"error: evidence file not found: {new_file}")
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
old = json.loads(old_file.read_text())
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
print(f"error: {old_file} is not valid JSON")
|
|
169
|
+
return 1
|
|
170
|
+
try:
|
|
171
|
+
new = json.loads(new_file.read_text())
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
print(f"error: {new_file} is not valid JSON")
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
diff = compute_diff(old, new, full=full)
|
|
177
|
+
print(json.dumps(diff, indent=2))
|
|
178
|
+
|
|
179
|
+
if fail_on_new_secrets or fail_on_new_vulnerabilities or fail_on_new_layer_violations:
|
|
180
|
+
curated = diff if not full else compute_diff(old, new, full=False)
|
|
181
|
+
should_fail = False
|
|
182
|
+
|
|
183
|
+
if fail_on_new_secrets:
|
|
184
|
+
new_real_secrets = [
|
|
185
|
+
f
|
|
186
|
+
for f in curated["secrets"]["new"]
|
|
187
|
+
if not f.get("likely_placeholder", False) and not f.get("accepted", False)
|
|
188
|
+
]
|
|
189
|
+
new_real_history_secrets = [
|
|
190
|
+
f
|
|
191
|
+
for f in curated["history_secrets"]["new"]
|
|
192
|
+
if not f.get("likely_placeholder", False) and not f.get("accepted", False)
|
|
193
|
+
]
|
|
194
|
+
should_fail = should_fail or bool(new_real_secrets or new_real_history_secrets)
|
|
195
|
+
|
|
196
|
+
if fail_on_new_vulnerabilities:
|
|
197
|
+
should_fail = should_fail or bool(curated["vulnerabilities"]["new"])
|
|
198
|
+
|
|
199
|
+
if fail_on_new_layer_violations:
|
|
200
|
+
should_fail = should_fail or bool(curated["layer_violations"]["new"])
|
|
201
|
+
|
|
202
|
+
if should_fail:
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _healthcheck(repo_path: str, base_url: str) -> int:
|
|
209
|
+
repo = Path(repo_path).resolve()
|
|
210
|
+
evidence_path = repo / ".aletheore" / "evidence.json"
|
|
211
|
+
if not evidence_path.exists():
|
|
212
|
+
print(f"error: no evidence found at {evidence_path}")
|
|
213
|
+
print(f"Run 'aletheore scan {repo}' first.")
|
|
214
|
+
return 1
|
|
215
|
+
|
|
216
|
+
evidence = json.loads(evidence_path.read_text())
|
|
217
|
+
endpoints = evidence["repository"].get("api_endpoints", {}).get("endpoints", [])
|
|
218
|
+
result = run_healthcheck(endpoints, base_url)
|
|
219
|
+
save_healthcheck(result, repo)
|
|
220
|
+
|
|
221
|
+
for entry in result["results"]:
|
|
222
|
+
method = entry.get("method") or "?"
|
|
223
|
+
if entry.get("skipped"):
|
|
224
|
+
print(f"{method:6} {entry['path']:40} SKIPPED ({entry['reason']})")
|
|
225
|
+
else:
|
|
226
|
+
status = entry["status_code"] if entry["reachable"] else "UNREACHABLE"
|
|
227
|
+
note = f" ({entry['note']})" if entry.get("note") else ""
|
|
228
|
+
print(f"{method:6} {entry['path']:40} {status} {entry['latency_ms']}ms{note}")
|
|
229
|
+
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _mcp(repo_path: str) -> int:
|
|
234
|
+
repo = Path(repo_path).resolve()
|
|
235
|
+
server = build_server(repo)
|
|
236
|
+
server.run(transport="stdio")
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _dashboard(repo_path: str, port: int) -> int:
|
|
241
|
+
repo = Path(repo_path).resolve()
|
|
242
|
+
app = build_app(repo)
|
|
243
|
+
url = f"http://127.0.0.1:{port}"
|
|
244
|
+
print(f"Dashboard running at {url}")
|
|
245
|
+
webbrowser.open(url)
|
|
246
|
+
uvicorn.run(app, host="127.0.0.1", port=port)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main() -> int:
|
|
251
|
+
parser = argparse.ArgumentParser(prog="aletheore")
|
|
252
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
253
|
+
|
|
254
|
+
audit_parser = subparsers.add_parser("audit", help="audit a repository")
|
|
255
|
+
audit_parser.add_argument("path", nargs="?", default=".")
|
|
256
|
+
audit_parser.add_argument("--agent", default=None, help="force a specific agent adapter by name")
|
|
257
|
+
audit_parser.add_argument(
|
|
258
|
+
"--no-check-vulnerabilities",
|
|
259
|
+
dest="check_vulnerabilities",
|
|
260
|
+
action="store_false",
|
|
261
|
+
default=True,
|
|
262
|
+
help="skip the OSV.dev dependency-vulnerability check (on by default)",
|
|
263
|
+
)
|
|
264
|
+
audit_parser.add_argument(
|
|
265
|
+
"--no-scan-git-history",
|
|
266
|
+
dest="scan_git_history",
|
|
267
|
+
action="store_false",
|
|
268
|
+
default=True,
|
|
269
|
+
help="skip walking git history for secrets (on by default)",
|
|
270
|
+
)
|
|
271
|
+
audit_parser.add_argument(
|
|
272
|
+
"--no-check-licenses",
|
|
273
|
+
dest="check_licenses",
|
|
274
|
+
action="store_false",
|
|
275
|
+
default=True,
|
|
276
|
+
help="skip the dependency-license check (on by default)",
|
|
277
|
+
)
|
|
278
|
+
audit_parser.add_argument(
|
|
279
|
+
"--no-map-endpoints",
|
|
280
|
+
dest="map_endpoints",
|
|
281
|
+
action="store_false",
|
|
282
|
+
default=True,
|
|
283
|
+
help="skip static API endpoint mapping (on by default)",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
scan_parser = subparsers.add_parser("scan", help="run only the deterministic scan phase")
|
|
287
|
+
scan_parser.add_argument("path", nargs="?", default=".")
|
|
288
|
+
scan_parser.add_argument(
|
|
289
|
+
"--no-check-vulnerabilities",
|
|
290
|
+
dest="check_vulnerabilities",
|
|
291
|
+
action="store_false",
|
|
292
|
+
default=True,
|
|
293
|
+
help="skip the OSV.dev dependency-vulnerability check (on by default)",
|
|
294
|
+
)
|
|
295
|
+
scan_parser.add_argument(
|
|
296
|
+
"--no-scan-git-history",
|
|
297
|
+
dest="scan_git_history",
|
|
298
|
+
action="store_false",
|
|
299
|
+
default=True,
|
|
300
|
+
help="skip walking git history for secrets (on by default)",
|
|
301
|
+
)
|
|
302
|
+
scan_parser.add_argument(
|
|
303
|
+
"--no-check-licenses",
|
|
304
|
+
dest="check_licenses",
|
|
305
|
+
action="store_false",
|
|
306
|
+
default=True,
|
|
307
|
+
help="skip the dependency-license check (on by default)",
|
|
308
|
+
)
|
|
309
|
+
scan_parser.add_argument(
|
|
310
|
+
"--no-map-endpoints",
|
|
311
|
+
dest="map_endpoints",
|
|
312
|
+
action="store_false",
|
|
313
|
+
default=True,
|
|
314
|
+
help="skip static API endpoint mapping (on by default)",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
query_parser = subparsers.add_parser("query", help="query an existing evidence.json")
|
|
318
|
+
query_parser.add_argument("kind", choices=list(QUERY_FUNCTIONS.keys()) + ["changes"])
|
|
319
|
+
query_parser.add_argument("target", nargs="?", default=None)
|
|
320
|
+
query_parser.add_argument("--path", dest="repo_path", default=".")
|
|
321
|
+
query_parser.add_argument(
|
|
322
|
+
"--full",
|
|
323
|
+
action="store_true",
|
|
324
|
+
default=False,
|
|
325
|
+
help="show the full raw diff instead of the curated summary (only applies to 'changes')",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
diff_parser = subparsers.add_parser("diff", help="compare two evidence.json files")
|
|
329
|
+
diff_parser.add_argument("old", help="path to the baseline evidence.json")
|
|
330
|
+
diff_parser.add_argument("new", help="path to the comparison evidence.json")
|
|
331
|
+
diff_parser.add_argument(
|
|
332
|
+
"--full",
|
|
333
|
+
action="store_true",
|
|
334
|
+
default=False,
|
|
335
|
+
help="show the full raw diff instead of the curated summary",
|
|
336
|
+
)
|
|
337
|
+
diff_parser.add_argument(
|
|
338
|
+
"--fail-on-new-secrets",
|
|
339
|
+
dest="fail_on_new_secrets",
|
|
340
|
+
action="store_true",
|
|
341
|
+
default=False,
|
|
342
|
+
help="exit 1 if a new real (non-placeholder) secret finding appears",
|
|
343
|
+
)
|
|
344
|
+
diff_parser.add_argument(
|
|
345
|
+
"--fail-on-new-vulnerabilities",
|
|
346
|
+
dest="fail_on_new_vulnerabilities",
|
|
347
|
+
action="store_true",
|
|
348
|
+
default=False,
|
|
349
|
+
help="exit 1 if a new dependency vulnerability finding appears",
|
|
350
|
+
)
|
|
351
|
+
diff_parser.add_argument(
|
|
352
|
+
"--fail-on-new-layer-violations",
|
|
353
|
+
dest="fail_on_new_layer_violations",
|
|
354
|
+
action="store_true",
|
|
355
|
+
default=False,
|
|
356
|
+
help="exit 1 if a new layer-convention violation appears",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
mcp_parser = subparsers.add_parser("mcp", help="run an MCP server scoped to a repository")
|
|
360
|
+
mcp_parser.add_argument("path", nargs="?", default=".")
|
|
361
|
+
|
|
362
|
+
dashboard_parser = subparsers.add_parser(
|
|
363
|
+
"dashboard", help="run a live local dashboard scoped to a repository"
|
|
364
|
+
)
|
|
365
|
+
dashboard_parser.add_argument("path", nargs="?", default=".")
|
|
366
|
+
dashboard_parser.add_argument("--port", type=int, default=8420)
|
|
367
|
+
|
|
368
|
+
healthcheck_parser = subparsers.add_parser(
|
|
369
|
+
"healthcheck", help="GET-only live health check of mapped API endpoints"
|
|
370
|
+
)
|
|
371
|
+
healthcheck_parser.add_argument("path", nargs="?", default=".")
|
|
372
|
+
healthcheck_parser.add_argument("--base-url", required=True, dest="base_url")
|
|
373
|
+
|
|
374
|
+
args = parser.parse_args()
|
|
375
|
+
|
|
376
|
+
if args.command == "audit":
|
|
377
|
+
return _audit(
|
|
378
|
+
args.path,
|
|
379
|
+
args.agent,
|
|
380
|
+
args.check_vulnerabilities,
|
|
381
|
+
args.scan_git_history,
|
|
382
|
+
args.check_licenses,
|
|
383
|
+
args.map_endpoints,
|
|
384
|
+
)
|
|
385
|
+
if args.command == "scan":
|
|
386
|
+
exit_code, _evidence, _evidence_path = _scan(
|
|
387
|
+
args.path,
|
|
388
|
+
args.check_vulnerabilities,
|
|
389
|
+
args.scan_git_history,
|
|
390
|
+
args.check_licenses,
|
|
391
|
+
args.map_endpoints,
|
|
392
|
+
)
|
|
393
|
+
return exit_code
|
|
394
|
+
if args.command == "query":
|
|
395
|
+
return _query(args.kind, args.target, args.repo_path, args.full)
|
|
396
|
+
if args.command == "diff":
|
|
397
|
+
return _diff(
|
|
398
|
+
args.old,
|
|
399
|
+
args.new,
|
|
400
|
+
args.full,
|
|
401
|
+
args.fail_on_new_secrets,
|
|
402
|
+
args.fail_on_new_vulnerabilities,
|
|
403
|
+
args.fail_on_new_layer_violations,
|
|
404
|
+
)
|
|
405
|
+
if args.command == "mcp":
|
|
406
|
+
return _mcp(args.path)
|
|
407
|
+
if args.command == "dashboard":
|
|
408
|
+
return _dashboard(args.path, args.port)
|
|
409
|
+
if args.command == "healthcheck":
|
|
410
|
+
return _healthcheck(args.path, args.base_url)
|
|
411
|
+
|
|
412
|
+
parser.print_help()
|
|
413
|
+
return 1
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
sys.exit(main())
|