docgen-secure 1.0.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.
@@ -0,0 +1,2 @@
1
+ """DocGen Secure - Hybrid Security & Quality Analyzer"""
2
+ __version__ = "1.0.0"
docgen_secure/cli.py ADDED
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ šŸ›”ļø DocGen Secure v3.0 - 10/10
4
+ Hybrid: Regex + AST + AI | GitHub | Detailed Results
5
+ """
6
+ import sys, os, ast, re, json, random, time, ssl, logging, subprocess, tempfile, shutil
7
+ from pathlib import Path
8
+ from collections import defaultdict
9
+ from urllib.request import Request, urlopen
10
+ from urllib.error import URLError, HTTPError
11
+
12
+ logging.basicConfig(level=logging.WARNING)
13
+ logger = logging.getLogger(__name__)
14
+
15
+ try:
16
+ from dotenv import load_dotenv
17
+ load_dotenv()
18
+ except ImportError:
19
+ pass
20
+
21
+ SSL_CONTEXT = ssl.create_default_context()
22
+
23
+ # ============================================================
24
+ # RULES
25
+ # ============================================================
26
+ SECURITY_RULES = [
27
+ ('hardcoded_password', 'šŸ”“ Hardcoded Password', r'(?i)password\s*[:=]\s*["\'][^"\']+["\']', 'Move to .env'),
28
+ ('api_key_exposed', 'šŸ”“ API Key Exposed', r'(?:sk-|AIza|ghp_|xai-|gsk_|hf_)[a-zA-Z0-9]{20,}', 'Move to .env'),
29
+ ('dangerous_eval', 'šŸ”“ Dangerous eval()', r'\beval\s*\(', 'Avoid eval()'),
30
+ ('dangerous_exec', 'šŸ”“ Dangerous exec()', r'\bexec\s*\(', 'Avoid exec()'),
31
+ ('dangerous_os_system', 'šŸ”“ Dangerous os.system()', r'\bos\.system\s*\(', 'Use subprocess.run()'),
32
+ ('sql_injection_fstring', 'šŸ”“ SQL Injection (f-string)', r'execute\s*\(\s*f["\']', 'Parameterized queries'),
33
+ ('sql_injection_format', 'šŸ”“ SQL Injection (.format)', r'execute\s*\(\s*["\'].*\.format\(', 'Parameterized queries'),
34
+ ('sql_injection_percent', 'šŸ”“ SQL Injection (%)', r'execute\s*\(\s*["\'].*%[srd]', 'Parameterized queries'),
35
+ ('sql_injection_concat', 'šŸ”“ SQL Injection (+)', r'execute\s*\(\s*["\'].*\+', 'Parameterized queries'),
36
+ ('debug_mode', '🟔 Debug Mode ON', r'DEBUG\s*=\s*True', 'Set False in production'),
37
+ ('weak_crypto_md5', '🟔 Weak Crypto (MD5)', r'\bhashlib\.md5\s*\(', 'Use SHA-256'),
38
+ ('weak_crypto_sha1', '🟔 Weak Crypto (SHA1)', r'\bhashlib\.sha1\s*\(', 'Use SHA-256'),
39
+ ('insecure_deserialization', 'šŸ”“ Insecure Pickle', r'\bpickle\.loads?\s*\(', 'Use JSON instead'),
40
+ ('xxe_vulnerability', 'šŸ”“ XXE (XML)', r'xml\.etree\.ElementTree.*parse', 'Use defusedxml'),
41
+ ('open_redirect', '🟔 Open Redirect', r'redirect\s*\(\s*request\.', 'Validate URLs'),
42
+ ]
43
+
44
+ QUALITY_RULES = [
45
+ ('missing_type_hints', '🟔 Missing Type Hints', None, 'Add type hints'),
46
+ ('missing_docstring', '🟔 Missing Docstring', None, 'Add docstring'),
47
+ ('function_too_long', '🟔 Function Too Long (>50 lines)', None, 'Split into smaller functions'),
48
+ ('deep_nesting', '🟔 Deep Nesting (>3 levels)', None, 'Use early return pattern'),
49
+ ('too_many_args', '🟔 Too Many Arguments (>5)', None, 'Use dataclass or dict'),
50
+ ('bare_except', '🟔 Bare except:', None, 'Specify exception type'),
51
+ ]
52
+
53
+ # ============================================================
54
+ # KEY MANAGER (Silent)
55
+ # ============================================================
56
+ def get_keys(prefix):
57
+ keys = []
58
+ for i in range(1, 100):
59
+ k = os.getenv(f'{prefix}_{i}')
60
+ if k: keys.append(k)
61
+ else: break
62
+ single = os.getenv(prefix)
63
+ if single: keys.append(single)
64
+ return keys
65
+
66
+ class KeyManager:
67
+ def __init__(self):
68
+ self.keys = {}
69
+ self.index = {}
70
+ self.last_used = {}
71
+ for prefix in ['OR_KEY', 'GEMINI_KEY']:
72
+ k = get_keys(prefix)
73
+ if k:
74
+ self.keys[prefix] = k
75
+ self.index[prefix] = 0
76
+ self.last_used[prefix] = 0
77
+
78
+ def get_key(self, prefix):
79
+ if prefix not in self.keys: return None
80
+ keys = self.keys[prefix]
81
+ elapsed = time.time() - self.last_used.get(prefix, 0)
82
+ if elapsed < 0.3: time.sleep(0.3 - elapsed)
83
+ idx = self.index[prefix] % len(keys)
84
+ self.index[prefix] += 1
85
+ self.last_used[prefix] = time.time()
86
+ return keys[idx]
87
+
88
+ class AICaller:
89
+ def __init__(self, key_manager):
90
+ self.km = key_manager
91
+ self.calls = 0
92
+
93
+ def ask(self, prompt, max_tokens=400):
94
+ # OpenRouter
95
+ key = self.km.get_key('OR_KEY')
96
+ if key:
97
+ result = self._call_openrouter(key, prompt, max_tokens)
98
+ if result: self.calls += 1; return result
99
+ # Gemini
100
+ key = self.km.get_key('GEMINI_KEY')
101
+ if key:
102
+ result = self._call_gemini(key, prompt, max_tokens)
103
+ if result: self.calls += 1; return result
104
+ return None
105
+
106
+ def _call_openrouter(self, key, prompt, max_tokens):
107
+ try:
108
+ data = json.dumps({'model':'google/gemini-2.0-flash-001','messages':[{'role':'user','content':prompt}],'max_tokens':max_tokens,'temperature':0.1}).encode()
109
+ req = Request('https://openrouter.ai/api/v1/chat/completions', data=data,
110
+ headers={'Authorization':f'Bearer {key}','Content-Type':'application/json'})
111
+ with urlopen(req, timeout=15, context=SSL_CONTEXT) as r:
112
+ return json.loads(r.read())['choices'][0]['message']['content']
113
+ except: return None
114
+
115
+ def _call_gemini(self, key, prompt, max_tokens):
116
+ try:
117
+ data = json.dumps({'contents':[{'parts':[{'text':prompt}]}],'generationConfig':{'maxOutputTokens':max_tokens,'temperature':0.1}}).encode()
118
+ req = Request(f'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={key}',
119
+ data=data, headers={'Content-Type':'application/json'})
120
+ with urlopen(req, timeout=15, context=SSL_CONTEXT) as r:
121
+ return json.loads(r.read())['candidates'][0]['content']['parts'][0]['text']
122
+ except: return None
123
+
124
+ # ============================================================
125
+ # ANALYZER
126
+ # ============================================================
127
+ class Analyzer:
128
+ def __init__(self, lang='en', ai_caller=None, silent=False):
129
+ self.silent = silent
130
+ self.lang = lang
131
+ self.ai = ai_caller
132
+ self.issues = []
133
+ self.files_analyzed = 0
134
+ self.ai_scanned = 0
135
+
136
+ def analyze_file(self, filepath, rel_path=''):
137
+ path = Path(filepath)
138
+ self.files_analyzed += 1
139
+ display_name = rel_path or path.name
140
+
141
+ try:
142
+ with open(filepath, 'r', encoding='utf-8') as f:
143
+ lines = f.readlines()
144
+ content = ''.join(lines)
145
+ except: return
146
+
147
+ # REGEX
148
+ for i, line in enumerate(lines, 1):
149
+ line_s = line.strip()
150
+ if line_s.startswith('#') or line_s.startswith('"""'): continue
151
+ for rule_id, title, pattern, fix in SECURITY_RULES:
152
+ if pattern and re.search(pattern, line, re.IGNORECASE):
153
+ if rule_id == 'hardcoded_password' and ('your_' in line.lower() or 'example' in line.lower()): continue
154
+ if rule_id == 'api_key_exposed' and ('os.environ' in line): continue
155
+ if rule_id == 'debug_mode' and 'False' in line: continue
156
+ self.issues.append({'file':display_name,'line':i,'title':title,'fix':fix,'type':'security','severity':'high' if 'šŸ”“' in title else 'medium'})
157
+ break
158
+
159
+ # AST
160
+ try:
161
+ tree = ast.parse(content)
162
+ for node in ast.walk(tree):
163
+ if isinstance(node, ast.FunctionDef) and not node.name.startswith('_'):
164
+ # Type hints
165
+ if not all(a.annotation for a in node.args.args if a.arg != 'self') or not node.returns:
166
+ self.issues.append({'file':display_name,'line':node.lineno,'title':'🟔 Missing Type Hints','fix':f'Add types to {node.name}()','type':'quality','severity':'medium'})
167
+ # Docstring
168
+ if not ast.get_docstring(node):
169
+ self.issues.append({'file':display_name,'line':node.lineno,'title':'🟔 Missing Docstring','fix':f'Add docstring to {node.name}()','type':'quality','severity':'low'})
170
+ # Long function
171
+ if len(node.body) > 50:
172
+ self.issues.append({'file':display_name,'line':node.lineno,'title':f'🟔 Function Too Long ({len(node.body)} lines)','fix':f'Split {node.name}()','type':'quality','severity':'medium'})
173
+ # Args count
174
+ num_args = len([a for a in node.args.args if a.arg != 'self'])
175
+ if num_args > 5:
176
+ self.issues.append({'file':display_name,'line':node.lineno,'title':f'🟔 Too Many Args ({num_args})','fix':f'Use dataclass for {node.name}()','type':'quality','severity':'low'})
177
+ # Nesting
178
+ depth = self._max_nesting(node)
179
+ if depth > 3:
180
+ self.issues.append({'file':display_name,'line':node.lineno,'title':f'🟔 Deep Nesting ({depth} levels)','fix':'Use early return','type':'quality','severity':'medium'})
181
+ elif isinstance(node, ast.ExceptHandler) and node.type is None:
182
+ self.issues.append({'file':display_name,'line':node.lineno,'title':'🟔 Bare except:','fix':'Specify exception','type':'quality','severity':'medium'})
183
+ except SyntaxError: pass
184
+
185
+ # AI Deep Scan
186
+ if self.ai and len(content) < 8000:
187
+ prompt = f"""Analyze this code for bugs and security. Return JSON array [{{"title":"...","fix":"...","severity":"high/medium/low"}}]. CODE:\n{content[:4000]}"""
188
+ result = self.ai.ask(prompt, max_tokens=400)
189
+ if result:
190
+ self.ai_scanned += 1
191
+ try:
192
+ match = re.search(r'\[.*\]', result, re.DOTALL)
193
+ if match:
194
+ for item in json.loads(match.group()):
195
+ if isinstance(item, dict):
196
+ self.issues.append({'file':display_name,'line':0,'title':f"šŸ¤– {item.get('title','')}",'fix':item.get('fix',''),'type':'ai','severity':item.get('severity','medium')})
197
+ except: pass
198
+
199
+ def _max_nesting(self, node, depth=0):
200
+ if not hasattr(node, 'body'): return depth
201
+ max_d = depth
202
+ for child in node.body:
203
+ d = depth+1 if isinstance(child, (ast.If,ast.For,ast.While,ast.Try,ast.With)) else depth
204
+ max_d = max(max_d, self._max_nesting(child, d))
205
+ return max_d
206
+
207
+ def _build_json(self):
208
+ sev = defaultdict(int)
209
+ for i in self.issues: sev[i.get('severity','medium')] += 1
210
+ total = len(self.issues)
211
+ score = max(0, min(10, 10 - sev['high']*0.8 - sev['medium']*0.2 - sev['low']*0.05))
212
+ return {'files_analyzed':self.files_analyzed,'ai_scanned':self.ai_scanned,'score':round(score,1),'total':total,'critical':sev['high'],'warning':sev['medium'],'info':sev['low'],'issues':self.issues[:100]}
213
+
214
+ def analyze_directory(self, dir_path):
215
+ path = Path(dir_path)
216
+ for f in sorted(path.rglob('*.py')):
217
+ if f.is_file() and 'test_' not in f.name and '__pycache__' not in str(f) and '.git' not in str(f) and not f.name.startswith('.'):
218
+ rel = str(f.relative_to(path))
219
+ self.analyze_file(str(f), rel)
220
+ return self.generate_report(dir_path) if not self.silent else self._build_json()
221
+
222
+ def analyze_github(self, url, token=None):
223
+ # Clone
224
+ tmp = tempfile.mkdtemp()
225
+ repo = url.rstrip('/').split('/')[-1].replace('.git','')
226
+ clone_url = url
227
+ if token:
228
+ clone_url = f"https://x-access-token:{token}@github.com/{'/'.join(url.split('/')[-2:])}"
229
+ subprocess.run(['git','clone','--depth','1',clone_url,tmp],capture_output=True,timeout=60)
230
+ # Analyze
231
+ self.analyze_directory(tmp)
232
+ # Save to home
233
+ dest = Path.home() / repo
234
+ if dest.exists(): shutil.rmtree(dest)
235
+ shutil.copytree(tmp, str(dest))
236
+ shutil.rmtree(tmp, ignore_errors=True)
237
+ print(f"\nšŸ“ Saved: ~/{repo}/\n")
238
+
239
+ def generate_report(self, target_path=None):
240
+ if self.silent:
241
+ return self._build_json()
242
+ T = {
243
+ 'ar': {'security':'أمان','quality':'جودة','ai':'AI','total':'Ų„Ų¬Ł…Ų§Ł„ŁŠ','files':'ملف','score':'ŲŖŁ‚ŁŠŁŠŁ…','critical':'Ų­Ų±Ų¬','fix_plan':'Ų®Ų·Ų© ال؄صلاح','target':'الهدف'},
244
+ 'en': {'security':'Security','quality':'Quality','ai':'AI','total':'Total','files':'files','score':'Score','critical':'Critical','fix_plan':'Fix Plan','target':'Target'}
245
+ }
246
+ t = T.get(self.lang, T['en'])
247
+
248
+ sev = defaultdict(int)
249
+ for i in self.issues: sev[i.get('severity','medium')] += 1
250
+
251
+ total = len(self.issues)
252
+ # Better scoring
253
+ score = max(0, min(10, 10 - sev['high']*0.8 - sev['medium']*0.2 - sev['low']*0.05))
254
+
255
+ if score >= 9: grade = 'A+'
256
+ elif score >= 8: grade = 'A'
257
+ elif score >= 7: grade = 'B'
258
+ elif score >= 6: grade = 'C'
259
+ elif score >= 4: grade = 'D'
260
+ else: grade = 'F'
261
+
262
+ print(f"""
263
+ ╔══════════════════════════════════════════════════╗
264
+ ā•‘ šŸ›”ļø DocGen Secure v3.0 Report ā•‘
265
+ ╠══════════════════════════════════════════════════╣
266
+ ā•‘ {t['target']}: {str(target_path or ''):<38}ā•‘
267
+ ā•‘ {t['files']}: {self.files_analyzed:<41}ā•‘
268
+ ╠══════════════════════════════════════════════════╣
269
+ ā•‘ ⭐ {t['score']}: {score:.1f}/10 | šŸ† {grade:<30}ā•‘
270
+ ā•‘ šŸ”“ {t['critical']}: {sev['high']:<4} 🟔 Medium: {sev['medium']:<4} 🟢 Low: {sev['low']:<4} ⬜ {t['total']}: {total:<4}ā•‘
271
+ ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•
272
+ """)
273
+
274
+ # Critical first
275
+ for severity, icon, label in [('high','šŸ”“','CRITICAL'),('medium','🟔','WARNING'),('low','🟢','INFO')]:
276
+ items = [i for i in self.issues if i.get('severity')==severity]
277
+ if items:
278
+ print(f" {icon} {label} ({len(items)}):")
279
+ for item in items[:15]:
280
+ print(f" šŸ“ {item['file']}:{item['line']} → {item['title']}")
281
+ print(f" šŸ’” {item['fix']}\n")
282
+
283
+ if total > 40:
284
+ print(f" ... +{total-40} more\n")
285
+
286
+ print(f"═══════════════════════════════════════════════════")
287
+ print(f" šŸŽÆ {t['fix_plan']}:")
288
+ print(f" 1. šŸ”“ → Fix immediately")
289
+ print(f" 2. 🟔 → Fix before release")
290
+ print(f" 3. 🟢 → Improve over time")
291
+ print(f"═══════════════════════════════════════════════════\n")
292
+
293
+ # JSON
294
+ json_file = 'docgen_report.json'
295
+ with open(json_file, 'w', encoding='utf-8') as f:
296
+ json.dump({'target':str(target_path),'files':self.files_analyzed,'score':round(score,1),'grade':grade,'total':total,'by_severity':dict(sev),'issues':self.issues[:100]}, f, indent=2, ensure_ascii=False)
297
+ print(f"šŸ“„ JSON: {json_file}\n")
298
+
299
+ # ============================================================
300
+ # CLI
301
+ # ============================================================
302
+ def main():
303
+ import argparse
304
+ p = argparse.ArgumentParser(description="šŸ›”ļø DocGen Secure v3.0")
305
+ p.add_argument('target', nargs='?', help='File, directory, or GitHub URL')
306
+ p.add_argument('--en', action='store_true', help='English')
307
+ p.add_argument('--token', help='GitHub token')
308
+ args = p.parse_args()
309
+
310
+ if not args.target:
311
+ p.print_help()
312
+ return
313
+
314
+ target = args.target
315
+ lang = 'en' if args.en else 'ar'
316
+
317
+ km = KeyManager()
318
+ ai = AICaller(km)
319
+ analyzer = Analyzer(lang=lang, ai_caller=ai)
320
+
321
+ # Local first - check if directory exists
322
+ if os.path.isdir(target):
323
+ analyzer.analyze_directory(target)
324
+ return
325
+
326
+ # Local file
327
+ if os.path.isfile(target):
328
+ analyzer.analyze_file(target, Path(target).name)
329
+ analyzer.generate_report(target)
330
+ return
331
+
332
+ # GitHub - convert name to URL
333
+ if not target.startswith('http') and '/' in target and '.' not in target.split('/')[0]:
334
+ target = 'https://github.com/' + target
335
+
336
+ if 'github.com' in target:
337
+ analyzer.analyze_github(target, args.token)
338
+ return
339
+
340
+ # Not found
341
+ print(f"āŒ Not found: {target}")
342
+ return
343
+
344
+ # Old code
345
+ if os.path.isdir(target):
346
+ analyzer.analyze_directory(target)
347
+ elif os.path.isfile(target):
348
+ analyzer.analyze_file(target, Path(target).name)
349
+ analyzer.generate_report(target)
350
+ else:
351
+ print(f"āŒ Not found: {target}")
352
+
353
+ if __name__ == '__main__':
354
+ main()
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: docgen-secure
3
+ Version: 1.0.0
@@ -0,0 +1,7 @@
1
+ docgen_secure/__init__.py,sha256=voQs9twMue6-zDw7O--XXh6_y3VhfDO_gOwjDfFMSbU,79
2
+ docgen_secure/cli.py,sha256=LqKGarN0PkgBS74fF_rN1NEF2LPB6IzogqbBt9sIWfw,17267
3
+ docgen_secure-1.0.0.dist-info/METADATA,sha256=uyvdgCR3CfAqWpA2zFR5oo2QU6sqM4xXHKk015FOefU,57
4
+ docgen_secure-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ docgen_secure-1.0.0.dist-info/entry_points.txt,sha256=eVkxR8kuVgVW2FyrONLa-EyS8rGjwNdI3BT6UDsbu4M,57
6
+ docgen_secure-1.0.0.dist-info/top_level.txt,sha256=Gv2Ob-iRMzsH6MaGBbWLCNQ3Q2g3r9nfpLBELir8Q7s,14
7
+ docgen_secure-1.0.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
+ docgen-secure = docgen_secure.cli:main
@@ -0,0 +1 @@
1
+ docgen_secure