wopt 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.
- wopt/__init__.py +7 -0
- wopt/checks/__init__.py +36 -0
- wopt/checks/base.py +29 -0
- wopt/checks/cookies.py +58 -0
- wopt/checks/cors.py +60 -0
- wopt/checks/exposure.py +60 -0
- wopt/checks/headers.py +118 -0
- wopt/checks/tls.py +100 -0
- wopt/cli.py +141 -0
- wopt/core/__init__.py +0 -0
- wopt/core/http_client.py +139 -0
- wopt/core/scanner.py +49 -0
- wopt/models.py +103 -0
- wopt/report/__init__.py +0 -0
- wopt/report/ai_context_output.py +48 -0
- wopt/report/html_output.py +28 -0
- wopt/report/json_output.py +39 -0
- wopt/report/scoring.py +35 -0
- wopt/report/templates/report.html.j2 +66 -0
- wopt-0.1.0.dist-info/METADATA +112 -0
- wopt-0.1.0.dist-info/RECORD +24 -0
- wopt-0.1.0.dist-info/WHEEL +4 -0
- wopt-0.1.0.dist-info/entry_points.txt +2 -0
- wopt-0.1.0.dist-info/licenses/LICENSE +21 -0
wopt/__init__.py
ADDED
wopt/checks/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks
|
|
3
|
+
|
|
4
|
+
Registre central des checks disponibles. Pour ajouter un nouveau
|
|
5
|
+
check : créer la classe dans un module de ce package, puis
|
|
6
|
+
l'ajouter à ALL_CHECKS. Le scanner n'a rien d'autre à connaître.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from wopt.checks.cookies import CookiesCheck
|
|
10
|
+
from wopt.checks.cors import CORSCheck
|
|
11
|
+
from wopt.checks.exposure import ExposurePathsCheck
|
|
12
|
+
from wopt.checks.headers import SecurityHeadersCheck
|
|
13
|
+
from wopt.checks.tls import TLSCheck
|
|
14
|
+
|
|
15
|
+
# Checks "standards" exécutés directement sur le ScanContext
|
|
16
|
+
ALL_CHECKS = [
|
|
17
|
+
SecurityHeadersCheck,
|
|
18
|
+
TLSCheck,
|
|
19
|
+
CookiesCheck,
|
|
20
|
+
CORSCheck,
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# Checks nécessitant des requêtes HTTP additionnelles (probing actif)
|
|
24
|
+
PROBE_CHECKS = [
|
|
25
|
+
ExposurePathsCheck,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ALL_CHECKS",
|
|
30
|
+
"PROBE_CHECKS",
|
|
31
|
+
"SecurityHeadersCheck",
|
|
32
|
+
"TLSCheck",
|
|
33
|
+
"CookiesCheck",
|
|
34
|
+
"CORSCheck",
|
|
35
|
+
"ExposurePathsCheck",
|
|
36
|
+
]
|
wopt/checks/base.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks.base
|
|
3
|
+
|
|
4
|
+
Contrat que tout check de sécurité doit respecter.
|
|
5
|
+
Chaque nouveau check hérite de Check et implémente run().
|
|
6
|
+
Le scanner orchestrateur n'a besoin de rien connaître de la
|
|
7
|
+
logique interne de chaque check : il les découvre et les exécute
|
|
8
|
+
tous de la même façon.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
|
|
15
|
+
from wopt.models import Finding, ScanContext
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Check(ABC):
|
|
19
|
+
"""Classe abstraite : tout check de sécurité l'implémente."""
|
|
20
|
+
|
|
21
|
+
id: str = "base.check"
|
|
22
|
+
name: str = "Base Check"
|
|
23
|
+
category: str = "general" # headers | tls | cookies | cors | exposure
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def run(self, ctx: ScanContext) -> list[Finding]:
|
|
27
|
+
"""Exécute la vérification et retourne une liste de Finding.
|
|
28
|
+
Une liste vide = rien à signaler (tout est conforme)."""
|
|
29
|
+
raise NotImplementedError
|
wopt/checks/cookies.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks.cookies
|
|
3
|
+
|
|
4
|
+
Vérifie que chaque cookie détecté sur la réponse HTTP est
|
|
5
|
+
correctement sécurisé : Secure, HttpOnly, SameSite.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from wopt.checks.base import Check
|
|
11
|
+
from wopt.models import Finding, ScanContext, Severity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CookiesCheck(Check):
|
|
15
|
+
id = "cookies.flags"
|
|
16
|
+
name = "Cookie Security Flags"
|
|
17
|
+
category = "cookies"
|
|
18
|
+
|
|
19
|
+
def run(self, ctx: ScanContext) -> list[Finding]:
|
|
20
|
+
findings: list[Finding] = []
|
|
21
|
+
|
|
22
|
+
for cookie in ctx.cookies:
|
|
23
|
+
if not cookie.secure:
|
|
24
|
+
findings.append(
|
|
25
|
+
Finding(
|
|
26
|
+
check_id=f"{self.id}.{cookie.name}_not_secure",
|
|
27
|
+
title=f"Cookie '{cookie.name}' sans flag Secure",
|
|
28
|
+
severity=Severity.MEDIUM,
|
|
29
|
+
category=self.category,
|
|
30
|
+
description="Ce cookie peut être transmis en clair sur une connexion non chiffrée.",
|
|
31
|
+
recommendation=f"Ajouter le flag Secure au cookie '{cookie.name}'.",
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
if not cookie.http_only:
|
|
35
|
+
findings.append(
|
|
36
|
+
Finding(
|
|
37
|
+
check_id=f"{self.id}.{cookie.name}_not_httponly",
|
|
38
|
+
title=f"Cookie '{cookie.name}' sans flag HttpOnly",
|
|
39
|
+
severity=Severity.MEDIUM,
|
|
40
|
+
category=self.category,
|
|
41
|
+
description="Ce cookie est accessible via JavaScript, ce qui facilite son vol en cas de XSS.",
|
|
42
|
+
recommendation=f"Ajouter le flag HttpOnly au cookie '{cookie.name}'.",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
if not cookie.same_site or cookie.same_site.lower() == "none":
|
|
46
|
+
findings.append(
|
|
47
|
+
Finding(
|
|
48
|
+
check_id=f"{self.id}.{cookie.name}_samesite_weak",
|
|
49
|
+
title=f"Cookie '{cookie.name}' avec SameSite absent ou 'None'",
|
|
50
|
+
severity=Severity.LOW,
|
|
51
|
+
category=self.category,
|
|
52
|
+
description="Ce cookie peut être envoyé lors de requêtes cross-site, augmentant le risque CSRF.",
|
|
53
|
+
recommendation=f"Définir 'SameSite=Lax' ou 'Strict' sur le cookie '{cookie.name}'.",
|
|
54
|
+
evidence=cookie.same_site or "absent",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return findings
|
wopt/checks/cors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks.cors
|
|
3
|
+
|
|
4
|
+
Détecte les configurations CORS dangereuses, en particulier
|
|
5
|
+
la combinaison Access-Control-Allow-Origin: * associée à
|
|
6
|
+
Access-Control-Allow-Credentials: true, qui permet à n'importe
|
|
7
|
+
quel site tiers de lire des réponses authentifiées.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from wopt.checks.base import Check
|
|
13
|
+
from wopt.models import Finding, ScanContext, Severity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CORSCheck(Check):
|
|
17
|
+
id = "cors.configuration"
|
|
18
|
+
name = "CORS Configuration"
|
|
19
|
+
category = "cors"
|
|
20
|
+
|
|
21
|
+
def run(self, ctx: ScanContext) -> list[Finding]:
|
|
22
|
+
findings: list[Finding] = []
|
|
23
|
+
headers_lower = {k.lower(): v for k, v in ctx.response_headers.items()}
|
|
24
|
+
|
|
25
|
+
acao = headers_lower.get("access-control-allow-origin")
|
|
26
|
+
acac = headers_lower.get("access-control-allow-credentials", "").lower() == "true"
|
|
27
|
+
|
|
28
|
+
if acao == "*" and acac:
|
|
29
|
+
findings.append(
|
|
30
|
+
Finding(
|
|
31
|
+
check_id=f"{self.id}.wildcard_with_credentials",
|
|
32
|
+
title="CORS critique : origine wildcard combinée aux credentials",
|
|
33
|
+
severity=Severity.CRITICAL,
|
|
34
|
+
category=self.category,
|
|
35
|
+
description=(
|
|
36
|
+
"Access-Control-Allow-Origin est réglé sur '*' alors que "
|
|
37
|
+
"Access-Control-Allow-Credentials est à 'true'. N'importe quel "
|
|
38
|
+
"site tiers peut lire des réponses authentifiées de l'utilisateur."
|
|
39
|
+
),
|
|
40
|
+
recommendation=(
|
|
41
|
+
"Remplacer '*' par une liste blanche explicite d'origines de confiance, "
|
|
42
|
+
"ou désactiver les credentials si le wildcard est nécessaire."
|
|
43
|
+
),
|
|
44
|
+
evidence=f"Access-Control-Allow-Origin: {acao}",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
elif acao == "*":
|
|
48
|
+
findings.append(
|
|
49
|
+
Finding(
|
|
50
|
+
check_id=f"{self.id}.wildcard_origin",
|
|
51
|
+
title="CORS permissif : origine wildcard",
|
|
52
|
+
severity=Severity.LOW,
|
|
53
|
+
category=self.category,
|
|
54
|
+
description="Access-Control-Allow-Origin accepte toute origine ('*').",
|
|
55
|
+
recommendation="Restreindre aux origines réellement nécessaires si l'API contient des données sensibles.",
|
|
56
|
+
evidence=f"Access-Control-Allow-Origin: {acao}",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return findings
|
wopt/checks/exposure.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks.exposure
|
|
3
|
+
|
|
4
|
+
Vérifie passivement si des chemins sensibles connus répondent
|
|
5
|
+
avec un statut 200 (fichier accessible). Aucune tentative de
|
|
6
|
+
lecture/exploitation du contenu au-delà de la détection : on
|
|
7
|
+
observe uniquement le code de statut HTTP renvoyé.
|
|
8
|
+
|
|
9
|
+
Ce check nécessite des requêtes HTTP additionnelles, effectuées
|
|
10
|
+
par le scanner (pas par ScanContext initial) via check.probe_paths().
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from wopt.checks.base import Check
|
|
16
|
+
from wopt.models import Finding, ScanContext, Severity
|
|
17
|
+
|
|
18
|
+
SENSITIVE_PATHS = {
|
|
19
|
+
"/.env": Severity.CRITICAL,
|
|
20
|
+
"/.git/HEAD": Severity.HIGH,
|
|
21
|
+
"/.git/config": Severity.HIGH,
|
|
22
|
+
"/wp-config.php.bak": Severity.CRITICAL,
|
|
23
|
+
"/config.php.bak": Severity.CRITICAL,
|
|
24
|
+
"/.DS_Store": Severity.LOW,
|
|
25
|
+
"/backup.zip": Severity.MEDIUM,
|
|
26
|
+
"/.aws/credentials": Severity.CRITICAL,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExposurePathsCheck(Check):
|
|
31
|
+
"""Ce check est particulier : il a besoin de requêtes HTTP
|
|
32
|
+
supplémentaires (une par chemin testé). Le scanner l'invoque
|
|
33
|
+
via `probe_paths()` plutôt que `run()` classique -- voir
|
|
34
|
+
core/scanner.py pour l'intégration."""
|
|
35
|
+
|
|
36
|
+
id = "exposure.sensitive_paths"
|
|
37
|
+
name = "Sensitive Paths Exposure"
|
|
38
|
+
category = "exposure"
|
|
39
|
+
|
|
40
|
+
def paths_to_probe(self) -> dict[str, Severity]:
|
|
41
|
+
return SENSITIVE_PATHS
|
|
42
|
+
|
|
43
|
+
def build_finding(self, path: str, severity: Severity, status_code: int) -> Finding:
|
|
44
|
+
return Finding(
|
|
45
|
+
check_id=f"{self.id}.{path.strip('/').replace('/', '_').replace('.', '_')}",
|
|
46
|
+
title=f"Chemin sensible potentiellement exposé : {path}",
|
|
47
|
+
severity=severity,
|
|
48
|
+
category=self.category,
|
|
49
|
+
description=(
|
|
50
|
+
f"La requête vers '{path}' a retourné un statut {status_code}, "
|
|
51
|
+
"suggérant que ce fichier pourrait être accessible publiquement."
|
|
52
|
+
),
|
|
53
|
+
recommendation=f"Vérifier manuellement '{path}' et bloquer son accès public si confirmé.",
|
|
54
|
+
evidence=f"HTTP {status_code}",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def run(self, ctx: ScanContext) -> list[Finding]:
|
|
58
|
+
# Ce check ne fait rien via run() seul -- voir probe_paths()
|
|
59
|
+
# dans le scanner qui gère les requêtes réseau réelles.
|
|
60
|
+
return []
|
wopt/checks/headers.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks.headers
|
|
3
|
+
|
|
4
|
+
Vérifie la présence et la qualité des en-têtes de sécurité HTTP.
|
|
5
|
+
C'est la faille la plus documentée dans les études sur le code
|
|
6
|
+
généré par IA : la quasi-totalité des applications vibe-codées
|
|
7
|
+
en sont dépourvues.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from wopt.checks.base import Check
|
|
13
|
+
from wopt.models import Finding, ScanContext, Severity
|
|
14
|
+
|
|
15
|
+
SECURITY_HEADERS = {
|
|
16
|
+
"strict-transport-security": {
|
|
17
|
+
"severity": Severity.HIGH,
|
|
18
|
+
"title": "HSTS absent",
|
|
19
|
+
"description": "Le header Strict-Transport-Security est absent : le navigateur "
|
|
20
|
+
"peut être redirigé vers une version HTTP non chiffrée du site.",
|
|
21
|
+
"recommendation": "Ajouter 'Strict-Transport-Security: max-age=31536000; includeSubDomains'.",
|
|
22
|
+
},
|
|
23
|
+
"content-security-policy": {
|
|
24
|
+
"severity": Severity.HIGH,
|
|
25
|
+
"title": "CSP absent",
|
|
26
|
+
"description": "Aucune Content-Security-Policy définie : la page est plus "
|
|
27
|
+
"vulnérable aux attaques XSS et à l'injection de contenu tiers.",
|
|
28
|
+
"recommendation": "Définir une Content-Security-Policy restrictive adaptée à l'application.",
|
|
29
|
+
},
|
|
30
|
+
"x-frame-options": {
|
|
31
|
+
"severity": Severity.MEDIUM,
|
|
32
|
+
"title": "X-Frame-Options absent",
|
|
33
|
+
"description": "Le site peut être intégré dans une iframe tierce, exposant "
|
|
34
|
+
"les utilisateurs à des attaques de clickjacking.",
|
|
35
|
+
"recommendation": "Ajouter 'X-Frame-Options: DENY' ou utiliser 'frame-ancestors' en CSP.",
|
|
36
|
+
},
|
|
37
|
+
"x-content-type-options": {
|
|
38
|
+
"severity": Severity.LOW,
|
|
39
|
+
"title": "X-Content-Type-Options absent",
|
|
40
|
+
"description": "Le navigateur peut tenter de deviner le type MIME des ressources, "
|
|
41
|
+
"ce qui ouvre la porte à des attaques par confusion de type.",
|
|
42
|
+
"recommendation": "Ajouter 'X-Content-Type-Options: nosniff'.",
|
|
43
|
+
},
|
|
44
|
+
"referrer-policy": {
|
|
45
|
+
"severity": Severity.LOW,
|
|
46
|
+
"title": "Referrer-Policy absent",
|
|
47
|
+
"description": "Sans Referrer-Policy, l'URL complète peut fuiter vers des sites tiers "
|
|
48
|
+
"via l'en-tête Referer.",
|
|
49
|
+
"recommendation": "Ajouter 'Referrer-Policy: strict-origin-when-cross-origin'.",
|
|
50
|
+
},
|
|
51
|
+
"permissions-policy": {
|
|
52
|
+
"severity": Severity.INFO,
|
|
53
|
+
"title": "Permissions-Policy absent",
|
|
54
|
+
"description": "Aucune restriction explicite sur les APIs navigateur sensibles "
|
|
55
|
+
"(caméra, micro, géolocalisation).",
|
|
56
|
+
"recommendation": "Ajouter 'Permissions-Policy' pour désactiver les APIs non utilisées.",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SecurityHeadersCheck(Check):
|
|
62
|
+
id = "headers.security_headers"
|
|
63
|
+
name = "Security Headers"
|
|
64
|
+
category = "headers"
|
|
65
|
+
|
|
66
|
+
def run(self, ctx: ScanContext) -> list[Finding]:
|
|
67
|
+
findings: list[Finding] = []
|
|
68
|
+
headers_lower = {k.lower(): v for k, v in ctx.response_headers.items()}
|
|
69
|
+
|
|
70
|
+
for header_name, rule in SECURITY_HEADERS.items():
|
|
71
|
+
if header_name not in headers_lower:
|
|
72
|
+
findings.append(
|
|
73
|
+
Finding(
|
|
74
|
+
check_id=f"{self.id}.{header_name}_missing",
|
|
75
|
+
title=rule["title"],
|
|
76
|
+
severity=rule["severity"],
|
|
77
|
+
category=self.category,
|
|
78
|
+
description=rule["description"],
|
|
79
|
+
recommendation=rule["recommendation"],
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Vérification qualitative de la CSP, pas juste présence/absence
|
|
84
|
+
csp = headers_lower.get("content-security-policy")
|
|
85
|
+
if csp:
|
|
86
|
+
weak_tokens = [t for t in ("'unsafe-inline'", "'unsafe-eval'", "*") if t in csp]
|
|
87
|
+
if weak_tokens:
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(
|
|
90
|
+
check_id=f"{self.id}.csp_weak",
|
|
91
|
+
title="CSP affaiblie détectée",
|
|
92
|
+
severity=Severity.MEDIUM,
|
|
93
|
+
category=self.category,
|
|
94
|
+
description=(
|
|
95
|
+
"La Content-Security-Policy contient des directives permissives "
|
|
96
|
+
f"({', '.join(weak_tokens)}) qui réduisent sa protection contre le XSS."
|
|
97
|
+
),
|
|
98
|
+
recommendation="Retirer 'unsafe-inline'/'unsafe-eval'/'*' et utiliser des nonces ou hashes.",
|
|
99
|
+
evidence=csp,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Exposition de la stack technique
|
|
104
|
+
for leaky_header in ("server", "x-powered-by"):
|
|
105
|
+
if leaky_header in headers_lower and headers_lower[leaky_header]:
|
|
106
|
+
findings.append(
|
|
107
|
+
Finding(
|
|
108
|
+
check_id=f"{self.id}.stack_disclosure_{leaky_header}",
|
|
109
|
+
title=f"Divulgation de stack via '{leaky_header}'",
|
|
110
|
+
severity=Severity.INFO,
|
|
111
|
+
category=self.category,
|
|
112
|
+
description=f"Le header '{leaky_header}' révèle des informations sur la stack technique.",
|
|
113
|
+
recommendation=f"Masquer ou supprimer le header '{leaky_header}' côté serveur.",
|
|
114
|
+
evidence=headers_lower[leaky_header],
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return findings
|
wopt/checks/tls.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.checks.tls
|
|
3
|
+
|
|
4
|
+
Vérifie la configuration TLS/SSL : version du protocole négociée
|
|
5
|
+
et date d'expiration du certificat. Les informations TLS sont
|
|
6
|
+
collectées en amont (core/http_client.py) et transmises via
|
|
7
|
+
ScanContext.tls_info pour éviter une connexion TLS redondante.
|
|
8
|
+
|
|
9
|
+
ScanContext.tls_info attendu (dict) :
|
|
10
|
+
{
|
|
11
|
+
"protocol_version": "TLSv1.3",
|
|
12
|
+
"not_after": "2026-09-01T00:00:00+00:00", # ISO 8601
|
|
13
|
+
"days_until_expiry": 60,
|
|
14
|
+
"cipher": "TLS_AES_256_GCM_SHA384",
|
|
15
|
+
}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from wopt.checks.base import Check
|
|
21
|
+
from wopt.models import Finding, ScanContext, Severity
|
|
22
|
+
|
|
23
|
+
OUTDATED_PROTOCOLS = {"TLSv1", "TLSv1.0", "TLSv1.1", "SSLv2", "SSLv3"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TLSCheck(Check):
|
|
27
|
+
id = "tls.configuration"
|
|
28
|
+
name = "TLS Configuration"
|
|
29
|
+
category = "tls"
|
|
30
|
+
|
|
31
|
+
def run(self, ctx: ScanContext) -> list[Finding]:
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
|
|
34
|
+
if not ctx.url.startswith("https://"):
|
|
35
|
+
findings.append(
|
|
36
|
+
Finding(
|
|
37
|
+
check_id=f"{self.id}.no_https",
|
|
38
|
+
title="Le site n'est pas servi en HTTPS",
|
|
39
|
+
severity=Severity.CRITICAL,
|
|
40
|
+
category=self.category,
|
|
41
|
+
description="La cible a été scannée en HTTP simple, sans chiffrement TLS.",
|
|
42
|
+
recommendation="Migrer le site vers HTTPS avec un certificat valide.",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return findings
|
|
46
|
+
|
|
47
|
+
if ctx.tls_info is None:
|
|
48
|
+
findings.append(
|
|
49
|
+
Finding(
|
|
50
|
+
check_id=f"{self.id}.info_unavailable",
|
|
51
|
+
title="Impossible de récupérer les informations TLS",
|
|
52
|
+
severity=Severity.INFO,
|
|
53
|
+
category=self.category,
|
|
54
|
+
description="La négociation TLS a échoué ou n'a pas pu être analysée.",
|
|
55
|
+
recommendation="Vérifier manuellement la configuration TLS du serveur.",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return findings
|
|
59
|
+
|
|
60
|
+
protocol = ctx.tls_info.get("protocol_version")
|
|
61
|
+
if protocol in OUTDATED_PROTOCOLS:
|
|
62
|
+
findings.append(
|
|
63
|
+
Finding(
|
|
64
|
+
check_id=f"{self.id}.outdated_protocol",
|
|
65
|
+
title=f"Protocole TLS obsolète : {protocol}",
|
|
66
|
+
severity=Severity.HIGH,
|
|
67
|
+
category=self.category,
|
|
68
|
+
description=f"Le serveur accepte encore {protocol}, considéré comme non sûr.",
|
|
69
|
+
recommendation="Désactiver TLS < 1.2 et n'accepter que TLS 1.2 / 1.3.",
|
|
70
|
+
evidence=protocol,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
days_left = ctx.tls_info.get("days_until_expiry")
|
|
75
|
+
if days_left is not None:
|
|
76
|
+
if days_left < 0:
|
|
77
|
+
findings.append(
|
|
78
|
+
Finding(
|
|
79
|
+
check_id=f"{self.id}.cert_expired",
|
|
80
|
+
title="Certificat TLS expiré",
|
|
81
|
+
severity=Severity.CRITICAL,
|
|
82
|
+
category=self.category,
|
|
83
|
+
description="Le certificat présenté par le serveur est expiré.",
|
|
84
|
+
recommendation="Renouveler le certificat TLS immédiatement.",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
elif days_left < 14:
|
|
88
|
+
findings.append(
|
|
89
|
+
Finding(
|
|
90
|
+
check_id=f"{self.id}.cert_expiring_soon",
|
|
91
|
+
title=f"Certificat TLS expire dans {days_left} jours",
|
|
92
|
+
severity=Severity.MEDIUM,
|
|
93
|
+
category=self.category,
|
|
94
|
+
description="Le certificat arrive bientôt à expiration.",
|
|
95
|
+
recommendation="Planifier le renouvellement du certificat.",
|
|
96
|
+
evidence=str(days_left),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return findings
|
wopt/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.cli
|
|
3
|
+
|
|
4
|
+
Point d'entrée en ligne de commande.
|
|
5
|
+
|
|
6
|
+
Exemples :
|
|
7
|
+
wopt scan exemple.com
|
|
8
|
+
wopt scan https://exemple.com --format json
|
|
9
|
+
wopt scan https://exemple.com --format html -o rapport.html
|
|
10
|
+
wopt scan https://exemple.com --ai-context
|
|
11
|
+
wopt scan https://exemple.com --no-probes
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from wopt.core.http_client import FetchError
|
|
24
|
+
from wopt.core.scanner import scan
|
|
25
|
+
from wopt.models import Severity
|
|
26
|
+
from wopt.report.ai_context_output import to_ai_context
|
|
27
|
+
from wopt.report.html_output import to_html
|
|
28
|
+
from wopt.report.json_output import to_json
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="wopt",
|
|
32
|
+
help="Mini audit de sécurité web en ligne de commande.\n\n"
|
|
33
|
+
"Développé par Eloundou Nkolo Ryan — https://eloundounkolo.com",
|
|
34
|
+
add_completion=False,
|
|
35
|
+
)
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
SEVERITY_COLORS = {
|
|
39
|
+
Severity.CRITICAL: "bold red",
|
|
40
|
+
Severity.HIGH: "red",
|
|
41
|
+
Severity.MEDIUM: "yellow",
|
|
42
|
+
Severity.LOW: "blue",
|
|
43
|
+
Severity.INFO: "dim",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
SCORE_COLORS = {"A": "green", "B": "green", "C": "yellow", "D": "yellow", "E": "red", "F": "bold red"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def scan_command(
|
|
51
|
+
target: str = typer.Argument(..., help="URL ou domaine à scanner, ex: exemple.com"),
|
|
52
|
+
format: str = typer.Option("table", "--format", "-f", help="table | json | html"),
|
|
53
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Fichier de sortie (json/html)"),
|
|
54
|
+
ai_context: bool = typer.Option(
|
|
55
|
+
False, "--ai-context", help="Génère un rapport markdown prêt à coller dans un agent IA"
|
|
56
|
+
),
|
|
57
|
+
no_probes: bool = typer.Option(
|
|
58
|
+
False, "--no-probes", help="Désactive la vérification des chemins sensibles (scan plus léger)"
|
|
59
|
+
),
|
|
60
|
+
timeout: float = typer.Option(10.0, "--timeout", help="Timeout HTTP en secondes"),
|
|
61
|
+
):
|
|
62
|
+
"""Lance un audit de sécurité non-intrusif sur TARGET."""
|
|
63
|
+
with console.status(f"[bold cyan]Scan de {target}...[/bold cyan]"):
|
|
64
|
+
try:
|
|
65
|
+
result = scan(target, timeout=timeout, include_probes=not no_probes)
|
|
66
|
+
except FetchError as exc:
|
|
67
|
+
console.print(f"[bold red]Erreur :[/bold red] {exc}")
|
|
68
|
+
raise typer.Exit(code=1)
|
|
69
|
+
|
|
70
|
+
if ai_context:
|
|
71
|
+
content = to_ai_context(result)
|
|
72
|
+
if output:
|
|
73
|
+
output.write_text(content, encoding="utf-8")
|
|
74
|
+
console.print(f"[green]Rapport IA écrit dans {output}[/green]")
|
|
75
|
+
else:
|
|
76
|
+
console.print(content)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if format == "json":
|
|
80
|
+
content = to_json(result)
|
|
81
|
+
if output:
|
|
82
|
+
output.write_text(content, encoding="utf-8")
|
|
83
|
+
console.print(f"[green]Rapport JSON écrit dans {output}[/green]")
|
|
84
|
+
else:
|
|
85
|
+
console.print(content)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if format == "html":
|
|
89
|
+
content = to_html(result)
|
|
90
|
+
out_path = output or Path("wopt-report.html")
|
|
91
|
+
out_path.write_text(content, encoding="utf-8")
|
|
92
|
+
console.print(f"[green]Rapport HTML écrit dans {out_path}[/green]")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
_print_table(result)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _print_table(result) -> None:
|
|
99
|
+
score_color = SCORE_COLORS.get(result.score, "white")
|
|
100
|
+
console.print()
|
|
101
|
+
console.print(f"[bold]Cible :[/bold] {result.target}")
|
|
102
|
+
console.print(f"[bold]Score :[/bold] [{score_color}]{result.score}[/{score_color}] ({result.score_value}/100)")
|
|
103
|
+
console.print(f"[dim]Scan effectué en {result.duration_ms:.0f} ms[/dim]")
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
if not result.findings:
|
|
107
|
+
console.print("[green]✓ Aucun problème détecté.[/green]")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
table = Table(show_lines=False)
|
|
111
|
+
table.add_column("Sévérité", no_wrap=True)
|
|
112
|
+
table.add_column("Catégorie", no_wrap=True)
|
|
113
|
+
table.add_column("Problème")
|
|
114
|
+
table.add_column("Recommandation")
|
|
115
|
+
|
|
116
|
+
severity_order = [
|
|
117
|
+
Severity.CRITICAL,
|
|
118
|
+
Severity.HIGH,
|
|
119
|
+
Severity.MEDIUM,
|
|
120
|
+
Severity.LOW,
|
|
121
|
+
Severity.INFO,
|
|
122
|
+
]
|
|
123
|
+
for severity in severity_order:
|
|
124
|
+
for finding in result.findings_by_severity(severity):
|
|
125
|
+
color = SEVERITY_COLORS.get(severity, "white")
|
|
126
|
+
table.add_row(
|
|
127
|
+
f"[{color}]{severity.value.upper()}[/{color}]",
|
|
128
|
+
finding.category,
|
|
129
|
+
finding.title,
|
|
130
|
+
finding.recommendation,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
console.print(table)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
app()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main()
|
wopt/core/__init__.py
ADDED
|
File without changes
|
wopt/core/http_client.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.core.http_client
|
|
3
|
+
|
|
4
|
+
Encapsule les requêtes HTTP nécessaires au scan : une requête
|
|
5
|
+
principale vers la cible, plus les informations TLS et cookies
|
|
6
|
+
extraites de la même connexion pour limiter le nombre de
|
|
7
|
+
requêtes effectuées.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ssl
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from wopt.models import Cookie, ScanContext
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 10.0
|
|
21
|
+
DEFAULT_HEADERS = {
|
|
22
|
+
"User-Agent": "wopt/0.1 (+https://github.com/eloundou-nkolo/wopt) security-audit-tool",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FetchError(Exception):
|
|
27
|
+
"""Levée quand la cible ne peut pas être contactée."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_url(url: str) -> str:
|
|
31
|
+
if not url.startswith(("http://", "https://")):
|
|
32
|
+
url = f"https://{url}"
|
|
33
|
+
return url
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _extract_tls_info(url: str, timeout: float) -> dict | None:
|
|
37
|
+
"""Récupère la version du protocole TLS et la date d'expiration
|
|
38
|
+
du certificat via une connexion SSL directe (indépendante de httpx,
|
|
39
|
+
qui n'expose pas facilement ces détails bas niveau)."""
|
|
40
|
+
if not url.startswith("https://"):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
hostname = url.split("://", 1)[1].split("/", 1)[0].split(":")[0]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import socket
|
|
47
|
+
|
|
48
|
+
context = ssl.create_default_context()
|
|
49
|
+
with socket.create_connection((hostname, 443), timeout=timeout) as sock:
|
|
50
|
+
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
|
|
51
|
+
cert = ssock.getpeercert()
|
|
52
|
+
protocol_version = ssock.version()
|
|
53
|
+
cipher = ssock.cipher()
|
|
54
|
+
|
|
55
|
+
not_after_str = cert.get("notAfter") if cert else None
|
|
56
|
+
days_until_expiry = None
|
|
57
|
+
if not_after_str:
|
|
58
|
+
not_after = datetime.strptime(not_after_str, "%b %d %H:%M:%S %Y %Z").replace(
|
|
59
|
+
tzinfo=timezone.utc
|
|
60
|
+
)
|
|
61
|
+
days_until_expiry = (not_after - datetime.now(timezone.utc)).days
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"protocol_version": protocol_version,
|
|
65
|
+
"not_after": not_after_str,
|
|
66
|
+
"days_until_expiry": days_until_expiry,
|
|
67
|
+
"cipher": cipher[0] if cipher else None,
|
|
68
|
+
}
|
|
69
|
+
except Exception:
|
|
70
|
+
# On ne bloque jamais le scan pour une erreur d'introspection TLS :
|
|
71
|
+
# le check TLS gérera lui-même le cas tls_info=None.
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_cookies(response: httpx.Response) -> list[Cookie]:
|
|
76
|
+
cookies = []
|
|
77
|
+
for header_value in response.headers.get_list("set-cookie"):
|
|
78
|
+
parts = [p.strip() for p in header_value.split(";")]
|
|
79
|
+
name = parts[0].split("=", 1)[0] if parts else "unknown"
|
|
80
|
+
lower_parts = [p.lower() for p in parts]
|
|
81
|
+
|
|
82
|
+
same_site = None
|
|
83
|
+
for p in parts:
|
|
84
|
+
if p.lower().startswith("samesite="):
|
|
85
|
+
same_site = p.split("=", 1)[1]
|
|
86
|
+
|
|
87
|
+
cookies.append(
|
|
88
|
+
Cookie(
|
|
89
|
+
name=name,
|
|
90
|
+
secure="secure" in lower_parts,
|
|
91
|
+
http_only="httponly" in lower_parts,
|
|
92
|
+
same_site=same_site,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return cookies
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch(url: str, timeout: float = DEFAULT_TIMEOUT) -> ScanContext:
|
|
99
|
+
"""Effectue la requête principale vers la cible et construit
|
|
100
|
+
le ScanContext utilisé par tous les checks standards."""
|
|
101
|
+
url = _normalize_url(url)
|
|
102
|
+
start = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with httpx.Client(
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
follow_redirects=True,
|
|
108
|
+
headers=DEFAULT_HEADERS,
|
|
109
|
+
verify=True,
|
|
110
|
+
) as client:
|
|
111
|
+
response = client.get(url)
|
|
112
|
+
except httpx.RequestError as exc:
|
|
113
|
+
raise FetchError(f"Impossible de contacter '{url}' : {exc}") from exc
|
|
114
|
+
|
|
115
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
116
|
+
tls_info = _extract_tls_info(str(response.url), timeout)
|
|
117
|
+
|
|
118
|
+
return ScanContext(
|
|
119
|
+
url=url,
|
|
120
|
+
final_url=str(response.url),
|
|
121
|
+
status_code=response.status_code,
|
|
122
|
+
response_headers=dict(response.headers),
|
|
123
|
+
response_body=response.text,
|
|
124
|
+
cookies=_parse_cookies(response),
|
|
125
|
+
tls_info=tls_info,
|
|
126
|
+
elapsed_ms=elapsed_ms,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def probe_path(base_url: str, path: str, timeout: float = DEFAULT_TIMEOUT) -> int | None:
|
|
131
|
+
"""Requête légère pour vérifier si un chemin spécifique répond.
|
|
132
|
+
Retourne le status_code, ou None si la requête échoue."""
|
|
133
|
+
target = base_url.rstrip("/") + path
|
|
134
|
+
try:
|
|
135
|
+
with httpx.Client(timeout=timeout, follow_redirects=False, headers=DEFAULT_HEADERS) as client:
|
|
136
|
+
response = client.get(target)
|
|
137
|
+
return response.status_code
|
|
138
|
+
except httpx.RequestError:
|
|
139
|
+
return None
|
wopt/core/scanner.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.core.scanner
|
|
3
|
+
|
|
4
|
+
Orchestrateur principal : récupère les données de la cible,
|
|
5
|
+
exécute tous les checks enregistrés, agrège les résultats en
|
|
6
|
+
un ScanResult prêt à être scoré et formaté.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from wopt.checks import ALL_CHECKS, PROBE_CHECKS
|
|
14
|
+
from wopt.core.http_client import FetchError, fetch, probe_path
|
|
15
|
+
from wopt.models import Finding, ScanResult
|
|
16
|
+
from wopt.report.scoring import compute_score
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def scan(url: str, timeout: float = 10.0, include_probes: bool = True) -> ScanResult:
|
|
20
|
+
"""Lance un scan complet sur `url` et retourne un ScanResult.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
url: URL ou nom de domaine de la cible.
|
|
24
|
+
timeout: timeout HTTP en secondes.
|
|
25
|
+
include_probes: si True, teste aussi les chemins sensibles
|
|
26
|
+
connus (requêtes HTTP additionnelles). Mettre à False
|
|
27
|
+
pour un scan plus rapide/moins bruyant côté serveur cible.
|
|
28
|
+
"""
|
|
29
|
+
start = time.perf_counter()
|
|
30
|
+
findings: list[Finding] = []
|
|
31
|
+
|
|
32
|
+
ctx = fetch(url, timeout=timeout)
|
|
33
|
+
|
|
34
|
+
for check_cls in ALL_CHECKS:
|
|
35
|
+
check = check_cls()
|
|
36
|
+
findings.extend(check.run(ctx))
|
|
37
|
+
|
|
38
|
+
if include_probes:
|
|
39
|
+
for check_cls in PROBE_CHECKS:
|
|
40
|
+
check = check_cls()
|
|
41
|
+
for path, severity in check.paths_to_probe().items():
|
|
42
|
+
status = probe_path(ctx.final_url, path, timeout=timeout)
|
|
43
|
+
if status is not None and status < 400:
|
|
44
|
+
findings.append(check.build_finding(path, severity, status))
|
|
45
|
+
|
|
46
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
47
|
+
result = ScanResult.build(target=ctx.final_url, findings=findings, duration_ms=duration_ms)
|
|
48
|
+
result.score, result.score_value = compute_score(findings)
|
|
49
|
+
return result
|
wopt/models.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.models
|
|
3
|
+
|
|
4
|
+
Structures de données partagées par tout le projet :
|
|
5
|
+
- Severity : niveau de gravité d'un résultat
|
|
6
|
+
- Finding : un résultat individuel produit par un check
|
|
7
|
+
- ScanContext : les données brutes collectées sur la cible, passées à chaque check
|
|
8
|
+
- ScanResult : le résultat final agrégé d'un scan complet
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Severity(str, Enum):
|
|
19
|
+
CRITICAL = "critical"
|
|
20
|
+
HIGH = "high"
|
|
21
|
+
MEDIUM = "medium"
|
|
22
|
+
LOW = "low"
|
|
23
|
+
INFO = "info"
|
|
24
|
+
PASS = "pass"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def weight(self) -> int:
|
|
28
|
+
"""Poids utilisé pour le calcul du score global."""
|
|
29
|
+
return {
|
|
30
|
+
Severity.CRITICAL: 25,
|
|
31
|
+
Severity.HIGH: 15,
|
|
32
|
+
Severity.MEDIUM: 8,
|
|
33
|
+
Severity.LOW: 3,
|
|
34
|
+
Severity.INFO: 0,
|
|
35
|
+
Severity.PASS: 0,
|
|
36
|
+
}[self]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Finding:
|
|
41
|
+
"""Un résultat individuel produit par un check de sécurité."""
|
|
42
|
+
|
|
43
|
+
check_id: str
|
|
44
|
+
title: str
|
|
45
|
+
severity: Severity
|
|
46
|
+
category: str
|
|
47
|
+
description: str
|
|
48
|
+
recommendation: str
|
|
49
|
+
evidence: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Cookie:
|
|
54
|
+
name: str
|
|
55
|
+
secure: bool
|
|
56
|
+
http_only: bool
|
|
57
|
+
same_site: str | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ScanContext:
|
|
62
|
+
"""Toutes les données brutes collectées sur la cible.
|
|
63
|
+
Centralisé pour qu'un seul jeu de requêtes HTTP suffise
|
|
64
|
+
à alimenter tous les checks (pas de requêtes redondantes)."""
|
|
65
|
+
|
|
66
|
+
url: str
|
|
67
|
+
final_url: str
|
|
68
|
+
status_code: int
|
|
69
|
+
response_headers: dict[str, str]
|
|
70
|
+
response_body: str
|
|
71
|
+
cookies: list[Cookie] = field(default_factory=list)
|
|
72
|
+
tls_info: dict | None = None
|
|
73
|
+
elapsed_ms: float = 0.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ScanResult:
|
|
78
|
+
"""Résultat final agrégé, prêt à être formaté (JSON/HTML/CLI)."""
|
|
79
|
+
|
|
80
|
+
target: str
|
|
81
|
+
scanned_at: datetime
|
|
82
|
+
findings: list[Finding]
|
|
83
|
+
score: str = "?" # lettre A-F
|
|
84
|
+
score_value: int = 0 # 0-100
|
|
85
|
+
duration_ms: float = 0.0
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def build(cls, target: str, findings: list[Finding], duration_ms: float) -> "ScanResult":
|
|
89
|
+
return cls(
|
|
90
|
+
target=target,
|
|
91
|
+
scanned_at=datetime.now(timezone.utc),
|
|
92
|
+
findings=findings,
|
|
93
|
+
duration_ms=duration_ms,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def findings_by_severity(self, severity: Severity) -> list[Finding]:
|
|
97
|
+
return [f for f in self.findings if f.severity == severity]
|
|
98
|
+
|
|
99
|
+
def count_by_severity(self) -> dict[str, int]:
|
|
100
|
+
counts = {s.value: 0 for s in Severity}
|
|
101
|
+
for f in self.findings:
|
|
102
|
+
counts[f.severity.value] += 1
|
|
103
|
+
return counts
|
wopt/report/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.report.ai_context_output
|
|
3
|
+
|
|
4
|
+
Formate un ScanResult en markdown structuré, optimisé pour être
|
|
5
|
+
collé directement dans un prompt d'agent IA (Claude Code, Cursor...)
|
|
6
|
+
afin qu'il corrige automatiquement les failles trouvées.
|
|
7
|
+
C'est le différenciateur "vibe coding" de wopt (v0.3).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from wopt.models import ScanResult, Severity
|
|
13
|
+
|
|
14
|
+
SEVERITY_ORDER = [
|
|
15
|
+
Severity.CRITICAL,
|
|
16
|
+
Severity.HIGH,
|
|
17
|
+
Severity.MEDIUM,
|
|
18
|
+
Severity.LOW,
|
|
19
|
+
Severity.INFO,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_ai_context(result: ScanResult) -> str:
|
|
24
|
+
lines = [
|
|
25
|
+
f"# Audit de sécurité — {result.target}",
|
|
26
|
+
"",
|
|
27
|
+
f"Score global : {result.score} ({result.score_value}/100)",
|
|
28
|
+
"",
|
|
29
|
+
"Corrige les problèmes suivants, du plus critique au moins critique. "
|
|
30
|
+
"Pour chaque correctif, applique la recommandation indiquée.",
|
|
31
|
+
"",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for severity in SEVERITY_ORDER:
|
|
35
|
+
items = result.findings_by_severity(severity)
|
|
36
|
+
if not items:
|
|
37
|
+
continue
|
|
38
|
+
lines.append(f"## {severity.value.upper()} ({len(items)})")
|
|
39
|
+
lines.append("")
|
|
40
|
+
for f in items:
|
|
41
|
+
lines.append(f"- **{f.title}**")
|
|
42
|
+
lines.append(f" - Problème : {f.description}")
|
|
43
|
+
lines.append(f" - Correctif : {f.recommendation}")
|
|
44
|
+
if f.evidence:
|
|
45
|
+
lines.append(f" - Preuve observée : `{f.evidence}`")
|
|
46
|
+
lines.append("")
|
|
47
|
+
|
|
48
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.report.html_output
|
|
3
|
+
|
|
4
|
+
Rend le rapport HTML lisible via le template Jinja2.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
12
|
+
|
|
13
|
+
from wopt.models import ScanResult
|
|
14
|
+
|
|
15
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_html(result: ScanResult) -> str:
|
|
19
|
+
env = Environment(
|
|
20
|
+
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
21
|
+
autoescape=select_autoescape(["html"]),
|
|
22
|
+
)
|
|
23
|
+
template = env.get_template("report.html.j2")
|
|
24
|
+
return template.render(
|
|
25
|
+
result=result,
|
|
26
|
+
findings=result.findings,
|
|
27
|
+
summary=result.count_by_severity(),
|
|
28
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.report.json_output
|
|
3
|
+
|
|
4
|
+
Sérialise un ScanResult en JSON, pensé pour être consommé par
|
|
5
|
+
un pipeline CI/CD ou un autre outil (parsing facile, structure stable).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from wopt.models import ScanResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_dict(result: ScanResult) -> dict:
|
|
16
|
+
return {
|
|
17
|
+
"target": result.target,
|
|
18
|
+
"scanned_at": result.scanned_at.isoformat(),
|
|
19
|
+
"duration_ms": round(result.duration_ms, 2),
|
|
20
|
+
"score": result.score,
|
|
21
|
+
"score_value": result.score_value,
|
|
22
|
+
"summary": result.count_by_severity(),
|
|
23
|
+
"findings": [
|
|
24
|
+
{
|
|
25
|
+
"check_id": f.check_id,
|
|
26
|
+
"title": f.title,
|
|
27
|
+
"severity": f.severity.value,
|
|
28
|
+
"category": f.category,
|
|
29
|
+
"description": f.description,
|
|
30
|
+
"recommendation": f.recommendation,
|
|
31
|
+
"evidence": f.evidence,
|
|
32
|
+
}
|
|
33
|
+
for f in result.findings
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_json(result: ScanResult, indent: int = 2) -> str:
|
|
39
|
+
return json.dumps(to_dict(result), indent=indent, ensure_ascii=False)
|
wopt/report/scoring.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wopt.report.scoring
|
|
3
|
+
|
|
4
|
+
Calcule un score de sécurité global (0-100, converti en lettre A-F)
|
|
5
|
+
à partir de la liste de findings, en pondérant par sévérité.
|
|
6
|
+
Inspiré de l'approche Mozilla Observatory : on part de 100 et on
|
|
7
|
+
soustrait des points selon la gravité de chaque finding.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from wopt.models import Finding
|
|
13
|
+
|
|
14
|
+
MAX_SCORE = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_score(findings: list[Finding]) -> tuple[str, int]:
|
|
18
|
+
"""Retourne (lettre, valeur_numerique)."""
|
|
19
|
+
penalty = sum(f.severity.weight for f in findings)
|
|
20
|
+
score_value = max(0, MAX_SCORE - penalty)
|
|
21
|
+
return _to_letter(score_value), score_value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_letter(score_value: int) -> str:
|
|
25
|
+
if score_value >= 90:
|
|
26
|
+
return "A"
|
|
27
|
+
if score_value >= 75:
|
|
28
|
+
return "B"
|
|
29
|
+
if score_value >= 60:
|
|
30
|
+
return "C"
|
|
31
|
+
if score_value >= 40:
|
|
32
|
+
return "D"
|
|
33
|
+
if score_value >= 20:
|
|
34
|
+
return "E"
|
|
35
|
+
return "F"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>Rapport wopt — {{ result.target }}</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root {
|
|
8
|
+
--bg: #0f1115; --card: #171a21; --text: #e8e8ea; --muted: #9aa0aa;
|
|
9
|
+
--critical: #e5484d; --high: #f76b15; --medium: #f5a623; --low: #4c9be8; --info: #6b7280; --pass: #3ecf8e;
|
|
10
|
+
}
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
body { background: var(--bg); color: var(--text); font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 40px 20px; }
|
|
13
|
+
.container { max-width: 860px; margin: 0 auto; }
|
|
14
|
+
h1 { font-size: 1.4rem; word-break: break-all; }
|
|
15
|
+
.score-badge { display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; font-size: 2rem; font-weight: 700; background: var(--card); border: 3px solid var(--pass); }
|
|
16
|
+
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 30px; }
|
|
17
|
+
.summary { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0 30px; }
|
|
18
|
+
.pill { padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
|
|
19
|
+
.finding { background: var(--card); border-radius: 10px; padding: 16px 20px; margin-bottom: 12px; border-left: 4px solid var(--muted); }
|
|
20
|
+
.finding.critical { border-color: var(--critical); }
|
|
21
|
+
.finding.high { border-color: var(--high); }
|
|
22
|
+
.finding.medium { border-color: var(--medium); }
|
|
23
|
+
.finding.low { border-color: var(--low); }
|
|
24
|
+
.finding.info { border-color: var(--info); }
|
|
25
|
+
.finding h3 { margin: 0 0 8px; font-size: 1rem; }
|
|
26
|
+
.finding p { margin: 4px 0; color: var(--muted); font-size: 0.9rem; }
|
|
27
|
+
.finding .reco { color: var(--text); }
|
|
28
|
+
.sev-tag { display: inline-block; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 4px; margin-bottom: 8px; }
|
|
29
|
+
.sev-tag.critical { background: var(--critical); }
|
|
30
|
+
.sev-tag.high { background: var(--high); }
|
|
31
|
+
.sev-tag.medium { background: var(--medium); color: #1a1a1a; }
|
|
32
|
+
.sev-tag.low { background: var(--low); }
|
|
33
|
+
.sev-tag.info { background: var(--info); }
|
|
34
|
+
footer { margin-top: 40px; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<div class="container">
|
|
39
|
+
<div class="score-badge">{{ result.score }}</div>
|
|
40
|
+
<h1>Rapport de sécurité — {{ result.target }}</h1>
|
|
41
|
+
<div class="meta">Scanné le {{ result.scanned_at.strftime("%d/%m/%Y à %H:%M UTC") }} · {{ "%.0f"|format(result.duration_ms) }} ms · Score {{ result.score_value }}/100</div>
|
|
42
|
+
|
|
43
|
+
<div class="summary">
|
|
44
|
+
{% for sev, count in summary.items() if count > 0 %}
|
|
45
|
+
<span class="pill {{ sev }}">{{ sev }} : {{ count }}</span>
|
|
46
|
+
{% endfor %}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{% for finding in findings %}
|
|
50
|
+
<div class="finding {{ finding.severity.value }}">
|
|
51
|
+
<span class="sev-tag {{ finding.severity.value }}">{{ finding.severity.value }}</span>
|
|
52
|
+
<h3>{{ finding.title }}</h3>
|
|
53
|
+
<p>{{ finding.description }}</p>
|
|
54
|
+
<p class="reco">→ {{ finding.recommendation }}</p>
|
|
55
|
+
{% if finding.evidence %}<p><code>{{ finding.evidence }}</code></p>{% endif %}
|
|
56
|
+
</div>
|
|
57
|
+
{% else %}
|
|
58
|
+
<p>Aucun problème détecté.</p>
|
|
59
|
+
{% endfor %}
|
|
60
|
+
|
|
61
|
+
<footer>Généré par <a href="https://github.com/eloundou-nkolo/wopt">wopt</a> —
|
|
62
|
+
audit de sécurité web open-source, par
|
|
63
|
+
<a href="https://eloundounkolo.com">Eloundou Nkolo Ryan</a></footer>
|
|
64
|
+
</div>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wopt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mini audit de sécurité web en ligne de commande : headers, TLS, cookies, CORS, fichiers exposés.
|
|
5
|
+
Project-URL: Homepage, https://github.com/eloundou-nkolo/wopt
|
|
6
|
+
Project-URL: Repository, https://github.com/eloundou-nkolo/wopt
|
|
7
|
+
Project-URL: Issues, https://github.com/eloundou-nkolo/wopt/issues
|
|
8
|
+
Project-URL: Portfolio, https://eloundounkolo.com
|
|
9
|
+
Author-email: Eloundou Nkolo Ryan <ryan@eloundounkolo.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: audit,cli,cors,http-headers,security,tls,web
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: cryptography
|
|
25
|
+
Requires-Dist: httpx
|
|
26
|
+
Requires-Dist: jinja2
|
|
27
|
+
Requires-Dist: rich
|
|
28
|
+
Requires-Dist: typer
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-httpx; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# wopt
|
|
36
|
+
|
|
37
|
+
test audit de sécurité web en ligne de commande — non-intrusif, sans configuration, sans infrastructure.
|
|
38
|
+
|
|
39
|
+
`wopt` scanne un site web et vérifie les points de sécurité les plus fréquemment négligés : en-têtes HTTP, configuration TLS, cookies, CORS, et exposition de fichiers sensibles. Conçu pour s'intégrer facilement à un pipeline CI/CD.
|
|
40
|
+
## Auteurs
|
|
41
|
+
|
|
42
|
+
Développé par [Eloundou Nkolo Ryan](https://eloundounkolo.com) — développeur web & intégration de solutions numériques.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install wopt
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Ou en local depuis le code source :
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/eloundou-nkolo/wopt.git
|
|
54
|
+
cd wopt
|
|
55
|
+
pip install -r requirements.txt
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Utilisation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Scan simple, affichage table dans le terminal
|
|
63
|
+
wopt exemple.com
|
|
64
|
+
|
|
65
|
+
# Export JSON (pour CI/CD)
|
|
66
|
+
wopt exemple.com --format json --output rapport.json
|
|
67
|
+
|
|
68
|
+
# Export HTML lisible
|
|
69
|
+
wopt exemple.com --format html --output rapport.html
|
|
70
|
+
|
|
71
|
+
# Rapport formaté pour être collé dans un agent IA (Claude Code, Cursor...)
|
|
72
|
+
wopt exemple.com --ai-context
|
|
73
|
+
|
|
74
|
+
# Scan plus léger, sans vérification des chemins sensibles
|
|
75
|
+
wopt exemple.com --no-probes
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Ce que wopt vérifie
|
|
79
|
+
|
|
80
|
+
| Catégorie | Vérifications |
|
|
81
|
+
|---|---|
|
|
82
|
+
| **Headers** | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, divulgation de stack |
|
|
83
|
+
| **TLS** | Version du protocole, expiration du certificat, redirection HTTPS |
|
|
84
|
+
| **Cookies** | Flags Secure, HttpOnly, SameSite |
|
|
85
|
+
| **CORS** | Combinaison dangereuse wildcard + credentials |
|
|
86
|
+
| **Exposition** | Fichiers sensibles accessibles (`.env`, `.git/HEAD`, backups...) |
|
|
87
|
+
|
|
88
|
+
## Philosophie
|
|
89
|
+
|
|
90
|
+
- **100% passif et non-intrusif** : aucun scan de ports, aucune tentative d'exploitation, aucun brute-force. Uniquement des requêtes HTTP GET standards.
|
|
91
|
+
- **Zéro infrastructure** : tourne entièrement en local, aucune donnée envoyée à un serveur tiers.
|
|
92
|
+
- **Pensé pour l'ère du vibe coding** : conçu pour combler les lacunes de sécurité fréquentes dans le code généré par IA (absence de headers de sécurité, CSRF, CORS mal configuré).
|
|
93
|
+
|
|
94
|
+
## Score
|
|
95
|
+
|
|
96
|
+
Chaque scan produit un score de A à F, calculé par pondération de sévérité (inspiré de l'approche Mozilla Observatory).
|
|
97
|
+
|
|
98
|
+
## Développement
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -r requirements-dev.txt
|
|
102
|
+
pytest
|
|
103
|
+
ruff check .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Avertissement
|
|
107
|
+
|
|
108
|
+
`wopt` est un outil d'audit passif destiné à un usage sur des sites que vous possédez ou êtes autorisé à tester. L'auteur décline toute responsabilité en cas d'usage non autorisé.
|
|
109
|
+
|
|
110
|
+
## Licence
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
wopt/__init__.py,sha256=tU-pobM-yWKZhtZ3PqBcDTroR7Js1RvycpGKqKulTag,160
|
|
2
|
+
wopt/cli.py,sha256=SC9qr-QxJdL1mRmZg01QlmuKqnGuhvHYGrHLeV71mjc,4447
|
|
3
|
+
wopt/models.py,sha256=vmtsXjaOqp63A0c062-Brxc_lRrHvdhxk-B_Gx8YkHs,2662
|
|
4
|
+
wopt/checks/__init__.py,sha256=BVaYpaXaw7ihe0Lh89wY-psJD4wFzbWSQyL-MW9TQEI,871
|
|
5
|
+
wopt/checks/base.py,sha256=2oAwoRH4U24dsV7UtOaFaCtSt1JqdeAzVeYCkvtANHQ,878
|
|
6
|
+
wopt/checks/cookies.py,sha256=oueyKso05z7gFtq8_kblWJZDUhkjOTbY6dPpO0qHo_o,2457
|
|
7
|
+
wopt/checks/cors.py,sha256=4cLD2y7paxnd8YbgTaH_QOZ6DvHt2_0ngG-aGD28cYw,2477
|
|
8
|
+
wopt/checks/exposure.py,sha256=-OIDSqA4w4vQYL8thOc8mdyZRpPbwGrdNYOVG8RMsqs,2234
|
|
9
|
+
wopt/checks/headers.py,sha256=1onvnX0zOKYhUR7DVPOVYKJ-EOZnEGt9OijPAFbdQhI,5245
|
|
10
|
+
wopt/checks/tls.py,sha256=MUAim5Xj56hujVIm0snpu-THBlM2v4SROXEzF5INj-I,3904
|
|
11
|
+
wopt/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
wopt/core/http_client.py,sha256=xFSXXUeN6Y1U1-H2MFemQf-oUcLmwoEkwi6SZr19KeA,4552
|
|
13
|
+
wopt/core/scanner.py,sha256=AvyPkyKRv_jkukaOwW-K5evgpPij1kwixmIHMYxldM0,1740
|
|
14
|
+
wopt/report/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
wopt/report/ai_context_output.py,sha256=-JYhMLS8O3Q-XPnGIqlr7M8HfBTIvqX9VJ1CQlWlNVc,1446
|
|
16
|
+
wopt/report/html_output.py,sha256=W3LZVxQ8jWTn_km3LEG9XNVWp2yuH5-eJtkuCJwFcTo,663
|
|
17
|
+
wopt/report/json_output.py,sha256=Rd0tolmmUch8oOi3hEyXklwDZ-kKqdZrYcxCIpoXiGM,1107
|
|
18
|
+
wopt/report/scoring.py,sha256=sXIrwRVYYe_oSzqGXbR9PV9Hi7mh5fybhnY1gDWJlSw,919
|
|
19
|
+
wopt/report/templates/report.html.j2,sha256=MINFwZYM1SlXtJ9hXEAHFFt6JqIw7ZB5IUteoLQf3Ww,3310
|
|
20
|
+
wopt-0.1.0.dist-info/METADATA,sha256=RxC7k-Jh2Vcz6EW2of2tmOdaQxgJxr6m9dMJHfmeV_k,3883
|
|
21
|
+
wopt-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
22
|
+
wopt-0.1.0.dist-info/entry_points.txt,sha256=MM9fObXlgpeDJxQUFD3B0QBj0keTqD9P5tumYsO4Eyc,39
|
|
23
|
+
wopt-0.1.0.dist-info/licenses/LICENSE,sha256=jYiTBzP0DvZuaaeiCduAocg5-hp0lILAcwrTTtb0a4k,1095
|
|
24
|
+
wopt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eloundou nkolo ryan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|