speculynx 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.
speculynx/dast.py ADDED
@@ -0,0 +1,154 @@
1
+ import httpx
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import typer
7
+
8
+ # ==============================================================================
9
+ # FORGE DE REQUÊTES
10
+ # ==============================================================================
11
+
12
+ def forge_url(base_url: str, path: str, params: dict) -> str:
13
+ """Remplace les path params {id} par des valeurs de test."""
14
+ url = base_url.rstrip("/") + path
15
+ for param_name in params:
16
+ url = url.replace(f"{{{param_name}}}", "1")
17
+ return url
18
+
19
+ def get_path_params(methods: dict) -> dict:
20
+ """Extrait les paramètres de path depuis la spec."""
21
+ params = {}
22
+ for method, config in methods.items():
23
+ if isinstance(config, dict):
24
+ for p in config.get("parameters", []):
25
+ if p.get("in") == "path":
26
+ params[p.get("name")] = "1"
27
+ return params
28
+
29
+ # ==============================================================================
30
+ # CHECKS DAST
31
+ # ==============================================================================
32
+
33
+ def check_dast_auth_bypass(client: httpx.Client, base_url: str, openapi_data: dict) -> dict:
34
+ """DAST-01 : Vérifie si des endpoints répondent 200 sans authentification."""
35
+ findings = []
36
+ paths = openapi_data.get("paths", {})
37
+
38
+ for path, methods in list(paths.items())[:5]: # limite à 5 paths pour le MVP
39
+ path_params = get_path_params(methods)
40
+ url = forge_url(base_url, path, path_params)
41
+ for method in ["get", "post", "put", "delete"]:
42
+ if method not in methods:
43
+ continue
44
+ try:
45
+ resp = client.request(method.upper(), url, timeout=5)
46
+ if resp.status_code == 200:
47
+ findings.append(f"{method.upper()} {url} → 200 sans token")
48
+ except Exception:
49
+ pass
50
+
51
+ passed = len(findings) == 0
52
+ return {
53
+ "id": "DAST-01",
54
+ "name": "Auth Bypass",
55
+ "severity": "CRITIQUE",
56
+ "passed": passed,
57
+ "dynamic": True,
58
+ "description": "Envoie des requêtes sans token et vérifie si l'API répond 200.",
59
+ "fail_message": f"Endpoints accessibles sans auth : {findings}" if findings else None
60
+ }
61
+
62
+
63
+ def check_dast_verbose_errors(client: httpx.Client, base_url: str, openapi_data: dict) -> dict:
64
+ """DAST-03 : Envoie des données malformées et cherche des stack traces."""
65
+ findings = []
66
+ paths = openapi_data.get("paths", {})
67
+ error_keywords = ["traceback", "exception", "error at line", "syntaxerror",
68
+ "nameerror", "typeerror", "file \"/", "line "]
69
+
70
+ for path, methods in list(paths.items())[:5]:
71
+ path_params = get_path_params(methods)
72
+ url = forge_url(base_url, path, path_params)
73
+ if "post" in methods or "put" in methods:
74
+ method = "post" if "post" in methods else "put"
75
+ try:
76
+ resp = client.request(
77
+ method.upper(), url,
78
+ json={"id": "'; DROP TABLE users; --", "value": None},
79
+ timeout=5
80
+ )
81
+ body = resp.text.lower()
82
+ if any(kw in body for kw in error_keywords):
83
+ findings.append(f"{method.upper()} {url} → stack trace détectée")
84
+ except Exception:
85
+ pass
86
+
87
+ passed = len(findings) == 0
88
+ return {
89
+ "id": "DAST-03",
90
+ "name": "Verbose Errors / Info Leak",
91
+ "severity": "ÉLEVÉE",
92
+ "passed": passed,
93
+ "dynamic": True,
94
+ "description": "Envoie des payloads malformés et détecte les stack traces dans les réponses.",
95
+ "fail_message": f"Info leak détecté : {findings}" if findings else None
96
+ }
97
+
98
+
99
+ def check_dast_rate_limit(client: httpx.Client, base_url: str, openapi_data: dict) -> dict:
100
+ """DAST-05 : Envoie 20 requêtes rapides et vérifie si un 429 est retourné."""
101
+ paths = openapi_data.get("paths", {})
102
+ if not paths:
103
+ return {"id": "DAST-05", "passed": True, "dynamic": True,
104
+ "name": "Rate Limiting", "severity": "MOYENNE",
105
+ "description": "Aucun path trouvé.", "fail_message": None}
106
+
107
+ first_path = list(paths.keys())[0]
108
+ path_params = get_path_params(paths[first_path])
109
+ url = forge_url(base_url, first_path, path_params)
110
+
111
+ got_429 = False
112
+ for _ in range(20):
113
+ try:
114
+ resp = client.get(url, timeout=3)
115
+ if resp.status_code == 429:
116
+ got_429 = True
117
+ break
118
+ except Exception:
119
+ break
120
+
121
+ return {
122
+ "id": "DAST-05",
123
+ "name": "Rate Limiting absent",
124
+ "severity": "MOYENNE",
125
+ "passed": got_429,
126
+ "dynamic": True,
127
+ "description": "Envoie 20 requêtes rapides et vérifie si l'API applique un rate limit (HTTP 429).",
128
+ "fail_message": f"Aucun rate limit détecté sur {url} après 20 requêtes." if not got_429 else None
129
+ }
130
+
131
+ # ==============================================================================
132
+ # CHEF D'ORCHESTRE DAST
133
+ # ==============================================================================
134
+
135
+ def run_dast_audit(file_path: Path, base_url: str) -> list:
136
+ """
137
+ Lance l'audit DAST complet.
138
+ base_url : ex. https://api.example.com
139
+ """
140
+ from speculynx.scanner import load_openapi_file
141
+ openapi_data = load_openapi_file(file_path)
142
+
143
+ typer.echo(typer.style(f"\n🌐 Cible DAST : {base_url}", fg=typer.colors.CYAN))
144
+ typer.echo(typer.style("⚠️ Mode DAST : requêtes réelles envoyées à l'API cible\n", fg=typer.colors.YELLOW))
145
+
146
+ # Client HTTP sans authentification (test d'auth bypass)
147
+ with httpx.Client(verify=False, follow_redirects=True) as client:
148
+ results = [
149
+ check_dast_auth_bypass(client, base_url, openapi_data),
150
+ check_dast_verbose_errors(client, base_url, openapi_data),
151
+ check_dast_rate_limit(client, base_url, openapi_data),
152
+ ]
153
+
154
+ return results
speculynx/main.py ADDED
@@ -0,0 +1,120 @@
1
+ import typer
2
+ import os
3
+ from typing import Optional
4
+ from pathlib import Path
5
+ from speculynx.scanner import run_audit
6
+ from speculynx.dast import run_dast_audit
7
+ from speculynx.utils import load_api_key, save_api_key, verify_license_online, generate_pdf_report
8
+
9
+ app = typer.Typer(
10
+ name="speculynx",
11
+ help="Speculynx : L'outil CLI d'audit de sécurité pour API REST.",
12
+ add_completion=False
13
+ )
14
+
15
+ def remove_local_license():
16
+ config_path = os.path.expanduser("~/.speculynx.json")
17
+ if os.path.exists(config_path):
18
+ os.remove(config_path)
19
+
20
+ @app.command()
21
+ def login():
22
+ """Connecte la CLI à votre compte Speculynx Pro."""
23
+ typer.echo(typer.style("🔑 Connexion à Speculynx Pro", fg=typer.colors.MAGENTA, bold=True))
24
+ api_key = typer.prompt("Entrez votre clé de licence Speculynx Pro")
25
+ typer.echo("Vérification auprès du serveur cloud...")
26
+ license_info = verify_license_online(api_key)
27
+ if license_info.get("status") == "active":
28
+ save_api_key(api_key)
29
+ typer.echo(typer.style("✅ Authentification réussie !", fg=typer.colors.GREEN, bold=True))
30
+ else:
31
+ typer.echo(typer.style("❌ Clé invalide.", fg=typer.colors.RED))
32
+
33
+ @app.command()
34
+ def info():
35
+ """Affiche les détails de votre licence actuelle."""
36
+ saved_key = load_api_key()
37
+ if not saved_key:
38
+ typer.echo("Aucune licence trouvée. Faites 'speculynx login'.")
39
+ return
40
+ license_info = verify_license_online(saved_key)
41
+ if license_info.get("status") == "active":
42
+ typer.echo(typer.style("💎 État : Speculynx Pro", fg=typer.colors.CYAN, bold=True))
43
+ else:
44
+ typer.echo(typer.style("⚡ État : Speculynx Free", fg=typer.colors.YELLOW, bold=True))
45
+ remove_local_license()
46
+
47
+ @app.command()
48
+ def scan(
49
+ file: Path = typer.Option(..., "--file", "-f", help="Fichier OpenAPI à auditer.", exists=True),
50
+ export: Optional[Path] = typer.Option(None, "--export", "-e", help="[Pro] Exporter le rapport PDF.")
51
+ ):
52
+ """Lance l'audit de sécurité statique sur une spec OpenAPI."""
53
+ saved_key = load_api_key()
54
+ is_pro = False
55
+ if saved_key:
56
+ license_info = verify_license_online(saved_key)
57
+ if license_info.get("status") == "active":
58
+ is_pro = True
59
+ else:
60
+ remove_local_license()
61
+
62
+ typer.echo(typer.style("👑 MODE PRO ACTIVÉ", fg=typer.colors.MAGENTA, bold=True) if is_pro else typer.style("⚡ MODE FREE", fg=typer.colors.YELLOW))
63
+ audit_results = run_audit(file, is_pro=is_pro)
64
+ _print_results(audit_results)
65
+
66
+ if export:
67
+ if is_pro:
68
+ generate_pdf_report(file.name, audit_results, export)
69
+ typer.echo(f"✅ Rapport exporté : {export}")
70
+ else:
71
+ typer.echo(typer.style("🛑 Export refusé. Membres Pro uniquement.", fg=typer.colors.RED))
72
+
73
+ @app.command(name="scan-live")
74
+ def scan_live(
75
+ file: Path = typer.Option(..., "--file", "-f", help="Fichier OpenAPI à auditer.", exists=True),
76
+ target: str = typer.Option(..., "--target", "-t", help="URL de base de l'API cible (ex: https://api.example.com)"),
77
+ export: Optional[Path] = typer.Option(None, "--export", "-e", help="[Pro] Exporter le rapport PDF.")
78
+ ):
79
+ """[Pro] Lance un audit DAST : envoie de vraies requêtes à l'API cible."""
80
+ saved_key = load_api_key()
81
+ is_pro = False
82
+ if saved_key:
83
+ license_info = verify_license_online(saved_key)
84
+ if license_info.get("status") == "active":
85
+ is_pro = True
86
+ else:
87
+ remove_local_license()
88
+
89
+ if not is_pro:
90
+ typer.echo(typer.style("🛑 scan-live est réservé aux membres Pro.", fg=typer.colors.RED))
91
+ typer.echo("👉 Abonnez-vous sur https://speculynx.dev")
92
+ raise typer.Exit(code=1)
93
+
94
+ typer.echo(typer.style("👑 MODE PRO ACTIVÉ — DAST", fg=typer.colors.MAGENTA, bold=True))
95
+ typer.echo(typer.style(f"🌐 Cible : {target}", fg=typer.colors.CYAN))
96
+ typer.echo(typer.style("⚠️ Des requêtes réelles vont être envoyées à l'API cible.\n", fg=typer.colors.YELLOW))
97
+
98
+ dast_results = run_dast_audit(file, target)
99
+ _print_results(dast_results)
100
+
101
+ if export:
102
+ generate_pdf_report(file.name, dast_results, export)
103
+ typer.echo(f"✅ Rapport DAST exporté : {export}")
104
+
105
+ def _print_results(results: list):
106
+ typer.echo("")
107
+ passed_count = sum(1 for r in results if r["passed"])
108
+ failed_count = len(results) - passed_count
109
+ for result in results:
110
+ badge = "🌐" if result.get("dynamic") else "📄"
111
+ if result["passed"]:
112
+ typer.echo(typer.style(f"{badge} ✅ [OK] {result['name']}", fg=typer.colors.GREEN))
113
+ else:
114
+ typer.echo(typer.style(f"{badge} 🚨 [{result['severity']}] {result['name']}", fg=typer.colors.RED))
115
+ typer.echo(f" ↳ {result['fail_message']}")
116
+ typer.echo("")
117
+ typer.echo(typer.style(f"📊 Résultat : {passed_count} OK / {failed_count} échec(s)", bold=True))
118
+
119
+ if __name__ == "__main__":
120
+ app()
speculynx/scanner.py ADDED
@@ -0,0 +1,124 @@
1
+ import json
2
+ import yaml
3
+ from pathlib import Path
4
+ import typer
5
+
6
+ def load_openapi_file(file_path: Path) -> dict:
7
+ suffix = file_path.suffix.lower()
8
+ try:
9
+ with open(file_path, 'r', encoding='utf-8') as f:
10
+ if suffix in ['.yaml', '.yml']: return yaml.safe_load(f)
11
+ elif suffix == '.json': return json.load(f)
12
+ else: raise ValueError("Format non supporté.")
13
+ except Exception as e:
14
+ typer.echo(typer.style(f"❌ Erreur : {e}", fg=typer.colors.RED))
15
+ raise typer.Exit(code=1)
16
+
17
+ # ==============================================================================
18
+ # PACK DE RÈGLES GRATUITES (FREEMIUM)
19
+ # ==============================================================================
20
+
21
+ def check_free_key_exposure(openapi_data: dict) -> dict:
22
+ passed = True
23
+ paths = openapi_data.get('paths', {})
24
+ for path, methods in paths.items():
25
+ for method, config in methods.items():
26
+ params = config.get('parameters', [])
27
+ for p in params:
28
+ if p.get('in') == 'query' and any(k in p.get('name', '').lower() for k in ['apikey', 'api_key', 'key']):
29
+ passed = False
30
+ break
31
+ return {
32
+ "id": "KEY-EXP-01",
33
+ "name": "Clés exposées dans l'URL",
34
+ "severity": "ÉLEVÉE",
35
+ "passed": passed,
36
+ "description": "Vérifie si les clés API transitent de manière non sécurisée dans la Query string.",
37
+ "fail_message": "Des clés API sont passées en paramètres de requête (Query string)."
38
+ }
39
+
40
+ def check_free_no_expiration(openapi_data: dict) -> dict:
41
+ passed = True
42
+ content_str = json.dumps(openapi_data).lower()
43
+ if 'key' in content_str and not any(exp in content_str for exp in ['exp', 'ttl', 'expire', 'duration']):
44
+ passed = False
45
+ return {
46
+ "id": "KEY-EXP-02",
47
+ "name": "Absence d'expiration des clés",
48
+ "severity": "MOYENNE",
49
+ "passed": passed,
50
+ "description": "Vérifie si les clés d'accès intègrent un mécanisme de cycle de vie ou d'expiration.",
51
+ "fail_message": "Les clés d'accès n'ont pas de date d'expiration ou de cycle de vie défini."
52
+ }
53
+
54
+ # ==============================================================================
55
+ # PACK DE RÈGLES AVANCÉES (VERSION PRO - ZERO TRUST & AI AGENTS)
56
+ # ==============================================================================
57
+
58
+ def check_pro_identity_context(openapi_data: dict) -> dict:
59
+ passed = True
60
+ components = openapi_data.get('components', {})
61
+ security_schemes = components.get('securitySchemes', {})
62
+ for name, scheme in security_schemes.items():
63
+ if scheme.get('type') == 'apiKey':
64
+ passed = False
65
+ return {
66
+ "id": "KEY-PRO-01",
67
+ "name": "Manque d'identité dynamique",
68
+ "severity": "ÉLEVÉE",
69
+ "passed": passed,
70
+ "description": "Vérifie l'utilisation de protocoles d'identité modernes (OAuth2/JWT) avec contexte.",
71
+ "fail_message": "L'API utilise des clés statiques sans contexte d'identité dynamique (IP, géo, rôle)."
72
+ }
73
+
74
+ def check_pro_ai_agent_risk(openapi_data: dict) -> dict:
75
+ passed = True
76
+ paths = openapi_data.get('paths', {})
77
+ destructive_keywords = ['delete', 'drop', 'purge', 'clear', 'truncate', 'destroy']
78
+ for path, methods in paths.items():
79
+ if 'delete' in methods or any(k in path.lower() for k in destructive_keywords):
80
+ content_str = json.dumps(methods).lower()
81
+ if not any(mfa in content_str for mfa in ['mfa', 'confirm', 'just-in-time', 'verification']):
82
+ passed = False
83
+ break
84
+ return {
85
+ "id": "KEY-PRO-02",
86
+ "name": "Risque d'action par Agent IA",
87
+ "severity": "CRITIQUE",
88
+ "passed": passed,
89
+ "description": "Vérifie si les routes destructrices possèdent une double validation contre les bugs d'IA.",
90
+ "fail_message": "Routes destructrices exposées à des agents IA sans validation d'intention en temps réel."
91
+ }
92
+
93
+ def check_pro_over_permissioned(openapi_data: dict) -> dict:
94
+ passed = True
95
+ components = openapi_data.get('components', {})
96
+ security_schemes = components.get('securitySchemes', {})
97
+ for name, scheme in security_schemes.items():
98
+ if scheme.get('type') == 'oauth2':
99
+ flows = scheme.get('flows', {})
100
+ for flow_name, flow_data in flows.items():
101
+ if not flow_data.get('scopes'):
102
+ passed = False
103
+ return {
104
+ "id": "KEY-PRO-03",
105
+ "name": "Permissions sur-permissionnées",
106
+ "severity": "ÉLEVÉE",
107
+ "passed": passed,
108
+ "description": "Vérifie si le contrôle d'accès applique le principe du moindre privilège via des scopes.",
109
+ "fail_message": "L'accès à l'API ne définit pas de restrictions par granularité fine (scopes)."
110
+ }
111
+
112
+ # ==============================================================================
113
+ # CHEF D'ORCHESTRE
114
+ # ==============================================================================
115
+
116
+ def run_audit(file_path: Path, is_pro: bool = False) -> list:
117
+ openapi_data = load_openapi_file(file_path)
118
+ rules_to_run = [check_free_key_exposure, check_free_no_expiration]
119
+ if is_pro:
120
+ rules_to_run.extend([check_pro_identity_context, check_pro_ai_agent_risk, check_pro_over_permissioned])
121
+ results = []
122
+ for rule in rules_to_run:
123
+ results.append(rule(openapi_data))
124
+ return results
speculynx/utils.py ADDED
@@ -0,0 +1,60 @@
1
+ import httpx
2
+ import json
3
+ from pathlib import Path
4
+ from fpdf import FPDF
5
+
6
+ BACKEND_URL = "https://api.speculynx.dev"
7
+ CONFIG_FILE = Path.home() / ".speculynx.json"
8
+
9
+ def save_api_key(api_key: str):
10
+ try:
11
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
12
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
13
+ json.dump({"api_key": api_key}, f)
14
+ except Exception as e:
15
+ print(f"⚠️ Impossible de sauvegarder la clé : {e}")
16
+
17
+ def load_api_key() -> str:
18
+ if CONFIG_FILE.exists():
19
+ try:
20
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
21
+ data = json.load(f)
22
+ return data.get("api_key", "")
23
+ except:
24
+ return ""
25
+ return ""
26
+
27
+ def verify_license_online(api_key: str) -> dict:
28
+ try:
29
+ response = httpx.get(
30
+ f"{BACKEND_URL}/v1/verify",
31
+ params={"key": api_key},
32
+ timeout=5
33
+ )
34
+ if response.status_code == 200:
35
+ return response.json()
36
+ return {"status": "expired"}
37
+ except httpx.RequestError:
38
+ return {"status": "network_error"}
39
+
40
+ class SpeculynxPDF(FPDF):
41
+ def header(self):
42
+ self.set_font("Helvetica", "B", 16)
43
+ self.set_text_color(128, 0, 128)
44
+ self.cell(0, 10, "SPECULYNX - RAPPORT D'AUDIT", ln=True, align="C")
45
+ self.line(10, 22, 200, 22)
46
+
47
+ def footer(self):
48
+ self.set_y(-15)
49
+ self.set_font("Helvetica", "I", 8)
50
+ self.cell(0, 10, "Document genere par Speculynx Pro", align="C")
51
+
52
+ def generate_pdf_report(target_file: str, audit_results: list, output_path: Path):
53
+ pdf = SpeculynxPDF()
54
+ pdf.add_page()
55
+ pdf.set_font("Helvetica", size=10)
56
+ pdf.cell(0, 10, f"Fichier: {target_file}", ln=True)
57
+ for result in audit_results:
58
+ status = "OK" if result["passed"] else "FAIL"
59
+ pdf.cell(0, 10, f"{result['name']} : {status}", ln=True)
60
+ pdf.output(str(output_path))
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: speculynx
3
+ Version: 0.1.0
4
+ Summary: Outil d'audit de sécurité pour API REST
5
+ Author: Sami Hameg
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: pyyaml>=6.0
9
+ Requires-Dist: fpdf2>=2.7
10
+ Requires-Dist: httpx>=0.27
@@ -0,0 +1,9 @@
1
+ speculynx/dast.py,sha256=2xtyrgZJwKvOGhaiyNqdLuicIIRZ9yHOdtpEy1enYU8,6029
2
+ speculynx/main.py,sha256=DPcn2VyrANzeKiZTs2xYk3Z5YQeowEhCe4RSxxHxRt4,4988
3
+ speculynx/scanner.py,sha256=17hl1LHY6JQyuL2Er1KCbndc0ms1pLs4TKPWs3gA44Y,5505
4
+ speculynx/utils.py,sha256=QDhxvT6-vQ_RwviVCmFvz6SncXYsglNQ7A1nm4kOjbs,1934
5
+ speculynx-0.1.0.dist-info/METADATA,sha256=Wqf9owtmWrzbVTd65S5dnXXfL4Lf5hNvltSHeT4BFOA,254
6
+ speculynx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ speculynx-0.1.0.dist-info/entry_points.txt,sha256=OnWALNqjboQmFmyQiakgZbHeMBsrP9U1i6hhPA9wMdg,49
8
+ speculynx-0.1.0.dist-info/top_level.txt,sha256=eKsQLu7ibbYbkcCuum1jczRjxB8KjD_tyOYIGqr5TWk,10
9
+ speculynx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ speculynx = speculynx.main:app
@@ -0,0 +1 @@
1
+ speculynx