speculynx 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.
- speculynx-0.1.0/PKG-INFO +10 -0
- speculynx-0.1.0/README.md +1 -0
- speculynx-0.1.0/pyproject.toml +23 -0
- speculynx-0.1.0/setup.cfg +4 -0
- speculynx-0.1.0/speculynx/dast.py +154 -0
- speculynx-0.1.0/speculynx/main.py +120 -0
- speculynx-0.1.0/speculynx/scanner.py +124 -0
- speculynx-0.1.0/speculynx/utils.py +60 -0
- speculynx-0.1.0/speculynx.egg-info/PKG-INFO +10 -0
- speculynx-0.1.0/speculynx.egg-info/SOURCES.txt +13 -0
- speculynx-0.1.0/speculynx.egg-info/dependency_links.txt +1 -0
- speculynx-0.1.0/speculynx.egg-info/entry_points.txt +2 -0
- speculynx-0.1.0/speculynx.egg-info/requires.txt +4 -0
- speculynx-0.1.0/speculynx.egg-info/top_level.txt +1 -0
- speculynx-0.1.0/tests/test_audit.py +13 -0
speculynx-0.1.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
# speculynx-backend
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "speculynx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [{name = "Sami Hameg"}]
|
|
9
|
+
description = "Outil d'audit de sécurité pour API REST"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typer>=0.12",
|
|
13
|
+
"pyyaml>=6.0",
|
|
14
|
+
"fpdf2>=2.7",
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
speculynx = "speculynx.main:app"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["."]
|
|
23
|
+
include = ["speculynx*"]
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
speculynx/dast.py
|
|
4
|
+
speculynx/main.py
|
|
5
|
+
speculynx/scanner.py
|
|
6
|
+
speculynx/utils.py
|
|
7
|
+
speculynx.egg-info/PKG-INFO
|
|
8
|
+
speculynx.egg-info/SOURCES.txt
|
|
9
|
+
speculynx.egg-info/dependency_links.txt
|
|
10
|
+
speculynx.egg-info/entry_points.txt
|
|
11
|
+
speculynx.egg-info/requires.txt
|
|
12
|
+
speculynx.egg-info/top_level.txt
|
|
13
|
+
tests/test_audit.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
speculynx
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import typer # Importe typer ici
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from speculynx.scanner import run_audit
|
|
5
|
+
|
|
6
|
+
def test_invalid_file_handling():
|
|
7
|
+
# On attend maintenant l'exception spécifique de Typer
|
|
8
|
+
with pytest.raises(typer.Exit):
|
|
9
|
+
run_audit(Path("test_bidon.txt"))
|
|
10
|
+
|
|
11
|
+
def test_audit_logic_runs():
|
|
12
|
+
results = run_audit(Path("api_test.yaml"), is_pro=False)
|
|
13
|
+
assert len(results) >= 2
|