skylos 2.2.3__py3-none-any.whl → 2.4.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.
Potentially problematic release.
This version of skylos might be problematic. Click here for more details.
- skylos/__init__.py +1 -1
- skylos/analyzer.py +119 -67
- skylos/cli.py +172 -10
- skylos/rules/danger/__init__.py +0 -0
- skylos/rules/danger/danger.py +141 -0
- skylos/rules/danger/danger_cmd/__init__.py +0 -0
- skylos/rules/danger/danger_cmd/cmd_flow.py +208 -0
- skylos/rules/danger/danger_fs/__init__.py +0 -0
- skylos/rules/danger/danger_fs/path_flow.py +188 -0
- skylos/rules/danger/danger_net/__init__.py +0 -0
- skylos/rules/danger/danger_net/ssrf_flow.py +198 -0
- skylos/rules/danger/danger_sql/__init__.py +0 -0
- skylos/rules/danger/danger_sql/sql_flow.py +175 -0
- skylos/rules/danger/danger_sql/sql_raw_flow.py +202 -0
- skylos/rules/danger/danger_web/__init__.py +0 -0
- skylos/rules/danger/danger_web/xss_flow.py +279 -0
- skylos/rules/secrets.py +34 -5
- {skylos-2.2.3.dist-info → skylos-2.4.0.dist-info}/METADATA +1 -1
- {skylos-2.2.3.dist-info → skylos-2.4.0.dist-info}/RECORD +28 -10
- test/test_cmd_injection.py +41 -0
- test/test_dangerous.py +101 -0
- test/test_path_traversal.py +40 -0
- test/test_secrets.py +24 -10
- test/test_sql_injection.py +54 -0
- test/test_ssrf.py +51 -0
- {skylos-2.2.3.dist-info → skylos-2.4.0.dist-info}/WHEEL +0 -0
- {skylos-2.2.3.dist-info → skylos-2.4.0.dist-info}/entry_points.txt +0 -0
- {skylos-2.2.3.dist-info → skylos-2.4.0.dist-info}/top_level.txt +0 -0
skylos/__init__.py
CHANGED
skylos/analyzer.py
CHANGED
|
@@ -9,6 +9,7 @@ from skylos.visitor import Visitor
|
|
|
9
9
|
from skylos.constants import ( PENALTIES, AUTO_CALLED )
|
|
10
10
|
from skylos.visitors.test_aware import TestAwareVisitor
|
|
11
11
|
from skylos.rules.secrets import scan_ctx as _secrets_scan_ctx
|
|
12
|
+
from skylos.rules.danger.danger import scan_ctx as scan_danger
|
|
12
13
|
import os
|
|
13
14
|
import traceback
|
|
14
15
|
from skylos.visitors.framework_aware import FrameworkAwareVisitor, detect_framework_usage
|
|
@@ -238,7 +239,7 @@ class Skylos:
|
|
|
238
239
|
if method.simple_name == "format" and cls.endswith("Formatter"):
|
|
239
240
|
method.references += 1
|
|
240
241
|
|
|
241
|
-
def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False):
|
|
242
|
+
def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False, enable_danger = False):
|
|
242
243
|
files, root = self._get_python_files(path, exclude_folders)
|
|
243
244
|
|
|
244
245
|
if not files:
|
|
@@ -262,6 +263,7 @@ class Skylos:
|
|
|
262
263
|
modmap[f] = self._module(root, f)
|
|
263
264
|
|
|
264
265
|
all_secrets = []
|
|
266
|
+
all_dangers = []
|
|
265
267
|
for file in files:
|
|
266
268
|
mod = modmap[file]
|
|
267
269
|
defs, refs, dyn, exports, test_flags, framework_flags = proc_file(file, mod)
|
|
@@ -276,13 +278,25 @@ class Skylos:
|
|
|
276
278
|
|
|
277
279
|
if enable_secrets and _secrets_scan_ctx is not None:
|
|
278
280
|
try:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
+
src = Path(file).read_text(encoding="utf-8", errors="ignore")
|
|
282
|
+
src_lines = src.splitlines(True)
|
|
283
|
+
rel = str(Path(file).relative_to(root))
|
|
284
|
+
ctx = {"relpath": rel, "lines": src_lines, "tree": None}
|
|
281
285
|
findings = list(_secrets_scan_ctx(ctx))
|
|
282
286
|
if findings:
|
|
283
287
|
all_secrets.extend(findings)
|
|
284
288
|
except Exception:
|
|
285
289
|
pass
|
|
290
|
+
|
|
291
|
+
if enable_danger and scan_danger is not None:
|
|
292
|
+
try:
|
|
293
|
+
findings = scan_danger(root, [file])
|
|
294
|
+
if findings:
|
|
295
|
+
all_dangers.extend(findings)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f"Error scanning {file} for dangerous code: {e}")
|
|
298
|
+
if os.getenv("SKYLOS_DEBUG"):
|
|
299
|
+
logger.error(traceback.format_exc())
|
|
286
300
|
|
|
287
301
|
self._mark_refs()
|
|
288
302
|
self._apply_heuristics()
|
|
@@ -296,7 +310,6 @@ class Skylos:
|
|
|
296
310
|
for d in sorted(self.defs.values(), key=def_sort_key):
|
|
297
311
|
if shown >= 50:
|
|
298
312
|
break
|
|
299
|
-
print(f" type={d.type} refs={d.references} conf={d.confidence} exported={d.is_exported} line={d.line} name={d.name}")
|
|
300
313
|
shown += 1
|
|
301
314
|
|
|
302
315
|
unused = []
|
|
@@ -318,7 +331,12 @@ class Skylos:
|
|
|
318
331
|
|
|
319
332
|
if enable_secrets and all_secrets:
|
|
320
333
|
result["secrets"] = all_secrets
|
|
334
|
+
result["analysis_summary"]["secrets_count"] = len(all_secrets)
|
|
321
335
|
|
|
336
|
+
if enable_danger and all_dangers:
|
|
337
|
+
result["danger"] = all_dangers
|
|
338
|
+
result["analysis_summary"]["danger_count"] = len(all_dangers)
|
|
339
|
+
|
|
322
340
|
for u in unused:
|
|
323
341
|
if u["type"] in ("function", "method"):
|
|
324
342
|
result["unused_functions"].append(u)
|
|
@@ -370,70 +388,104 @@ def proc_file(file_or_args, mod=None):
|
|
|
370
388
|
|
|
371
389
|
return [], [], set(), set(), dummy_visitor, dummy_framework_visitor
|
|
372
390
|
|
|
373
|
-
def analyze(path, conf=60, exclude_folders=None, enable_secrets=False):
|
|
374
|
-
return Skylos().analyze(path,conf, exclude_folders, enable_secrets)
|
|
391
|
+
def analyze(path, conf=60, exclude_folders=None, enable_secrets=False, enable_danger=False):
|
|
392
|
+
return Skylos().analyze(path,conf, exclude_folders, enable_secrets, enable_danger)
|
|
375
393
|
|
|
376
394
|
if __name__ == "__main__":
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
print("
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
print("Summary:")
|
|
392
|
-
if data["unused_functions"]:
|
|
393
|
-
print(f" * Unreachable functions: {len(data['unused_functions'])}")
|
|
394
|
-
if data["unused_imports"]:
|
|
395
|
-
print(f" * Unused imports: {len(data['unused_imports'])}")
|
|
396
|
-
if data["unused_classes"]:
|
|
397
|
-
print(f" * Unused classes: {len(data['unused_classes'])}")
|
|
398
|
-
if data["unused_variables"]:
|
|
399
|
-
print(f" * Unused variables: {len(data['unused_variables'])}")
|
|
400
|
-
|
|
401
|
-
if data["unused_functions"]:
|
|
402
|
-
print("\n - Unreachable Functions")
|
|
403
|
-
print("=======================")
|
|
404
|
-
for i, func in enumerate(data["unused_functions"], 1):
|
|
405
|
-
print(f" {i}. {func['name']}")
|
|
406
|
-
print(f" └─ {func['file']}:{func['line']}")
|
|
407
|
-
|
|
408
|
-
if data["unused_imports"]:
|
|
409
|
-
print("\n - Unused Imports")
|
|
410
|
-
print("================")
|
|
411
|
-
for i, imp in enumerate(data["unused_imports"], 1):
|
|
412
|
-
print(f" {i}. {imp['simple_name']}")
|
|
413
|
-
print(f" └─ {imp['file']}:{imp['line']}")
|
|
414
|
-
|
|
415
|
-
if data["unused_classes"]:
|
|
416
|
-
print("\n - Unused Classes")
|
|
417
|
-
print("=================")
|
|
418
|
-
for i, cls in enumerate(data["unused_classes"], 1):
|
|
419
|
-
print(f" {i}. {cls['name']}")
|
|
420
|
-
print(f" └─ {cls['file']}:{cls['line']}")
|
|
421
|
-
|
|
422
|
-
if data["unused_variables"]:
|
|
423
|
-
print("\n - Unused Variables")
|
|
424
|
-
print("==================")
|
|
425
|
-
for i, var in enumerate(data["unused_variables"], 1):
|
|
426
|
-
print(f" {i}. {var['name']}")
|
|
427
|
-
print(f" └─ {var['file']}:{var['line']}")
|
|
428
|
-
|
|
429
|
-
print("\n" + "─" * 50)
|
|
430
|
-
print(f"Found {total_items} dead code items. Add this badge to your README:")
|
|
431
|
-
print(f"```markdown")
|
|
432
|
-
print(f"")
|
|
433
|
-
print(f"```")
|
|
395
|
+
enable_secrets = ("--secrets" in sys.argv)
|
|
396
|
+
enable_danger = ("--danger" in sys.argv)
|
|
397
|
+
|
|
398
|
+
positional = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
399
|
+
|
|
400
|
+
if not positional:
|
|
401
|
+
print("Usage: python Skylos.py <path> [confidence_threshold] [--secrets] [--danger]")
|
|
402
|
+
sys.exit(2)
|
|
403
|
+
|
|
404
|
+
p = positional[0]
|
|
405
|
+
confidence = int(positional[1]) if len(positional) > 1 else 60
|
|
406
|
+
|
|
407
|
+
result = analyze(p, confidence, enable_secrets=enable_secrets, enable_danger=enable_danger)
|
|
434
408
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
409
|
+
data = json.loads(result)
|
|
410
|
+
print("\n Python Static Analysis Results")
|
|
411
|
+
print("===================================\n")
|
|
412
|
+
|
|
413
|
+
total_dead = 0
|
|
414
|
+
for key, items in data.items():
|
|
415
|
+
if key.startswith("unused_") and isinstance(items, list):
|
|
416
|
+
total_dead += len(items)
|
|
417
|
+
|
|
418
|
+
danger_count = data.get("analysis_summary", {}).get("danger_count", 0) if enable_danger else 0
|
|
419
|
+
secrets_count = data.get("analysis_summary", {}).get("secrets_count", 0) if enable_secrets else 0
|
|
420
|
+
|
|
421
|
+
print("Summary:")
|
|
422
|
+
if data["unused_functions"]:
|
|
423
|
+
print(f" * Unreachable functions: {len(data['unused_functions'])}")
|
|
424
|
+
if data["unused_imports"]:
|
|
425
|
+
print(f" * Unused imports: {len(data['unused_imports'])}")
|
|
426
|
+
if data["unused_classes"]:
|
|
427
|
+
print(f" * Unused classes: {len(data['unused_classes'])}")
|
|
428
|
+
if data["unused_variables"]:
|
|
429
|
+
print(f" * Unused variables: {len(data['unused_variables'])}")
|
|
430
|
+
if enable_danger:
|
|
431
|
+
print(f" * Security issues: {danger_count}")
|
|
432
|
+
if enable_secrets:
|
|
433
|
+
print(f" * Secrets found: {secrets_count}")
|
|
434
|
+
|
|
435
|
+
if data["unused_functions"]:
|
|
436
|
+
print("\n - Unreachable Functions")
|
|
437
|
+
print("=======================")
|
|
438
|
+
for i, func in enumerate(data["unused_functions"], 1):
|
|
439
|
+
print(f" {i}. {func['name']}")
|
|
440
|
+
print(f" └─ {func['file']}:{func['line']}")
|
|
441
|
+
|
|
442
|
+
if data["unused_imports"]:
|
|
443
|
+
print("\n - Unused Imports")
|
|
444
|
+
print("================")
|
|
445
|
+
for i, imp in enumerate(data["unused_imports"], 1):
|
|
446
|
+
print(f" {i}. {imp['simple_name']}")
|
|
447
|
+
print(f" └─ {imp['file']}:{imp['line']}")
|
|
448
|
+
|
|
449
|
+
if data["unused_classes"]:
|
|
450
|
+
print("\n - Unused Classes")
|
|
451
|
+
print("=================")
|
|
452
|
+
for i, cls in enumerate(data["unused_classes"], 1):
|
|
453
|
+
print(f" {i}. {cls['name']}")
|
|
454
|
+
print(f" └─ {cls['file']}:{cls['line']}")
|
|
455
|
+
|
|
456
|
+
if data["unused_variables"]:
|
|
457
|
+
print("\n - Unused Variables")
|
|
458
|
+
print("==================")
|
|
459
|
+
for i, var in enumerate(data["unused_variables"], 1):
|
|
460
|
+
print(f" {i}. {var['name']}")
|
|
461
|
+
print(f" └─ {var['file']}:{var['line']}")
|
|
462
|
+
|
|
463
|
+
if enable_danger and data.get("danger"):
|
|
464
|
+
print("\n - Security Issues")
|
|
465
|
+
print("================")
|
|
466
|
+
for i, f in enumerate(data["danger"], 1):
|
|
467
|
+
print(f" {i}. {f['message']} [{f['rule_id']}] ({f['file']}:{f['line']}) Severity: {f['severity']}")
|
|
468
|
+
|
|
469
|
+
if enable_secrets and data.get("secrets"):
|
|
470
|
+
print("\n - Secrets")
|
|
471
|
+
print("==========")
|
|
472
|
+
for i, s in enumerate(data["secrets"], 1):
|
|
473
|
+
rid = s.get("rule_id", "SECRET")
|
|
474
|
+
msg = s.get("message", "Potential secret")
|
|
475
|
+
file = s.get("file")
|
|
476
|
+
line = s.get("line", 1)
|
|
477
|
+
sev = s.get("severity", "HIGH")
|
|
478
|
+
print(f" {i}. {msg} [{rid}] ({file}:{line}) Severity: {sev}")
|
|
479
|
+
|
|
480
|
+
print("\n" + "─" * 50)
|
|
481
|
+
if enable_danger:
|
|
482
|
+
print(f"Found {total_dead} dead code items and {danger_count} security flaws. Add this badge to your README:")
|
|
438
483
|
else:
|
|
439
|
-
print("
|
|
484
|
+
print(f"Found {total_dead} dead code items. Add this badge to your README:")
|
|
485
|
+
print("```markdown")
|
|
486
|
+
print(f"")
|
|
487
|
+
print("```")
|
|
488
|
+
|
|
489
|
+
print("\nNext steps:")
|
|
490
|
+
print(" * Use --interactive to select specific items to remove")
|
|
491
|
+
print(" * Use --dry-run to preview changes before applying them")
|
skylos/cli.py
CHANGED
|
@@ -43,7 +43,7 @@ def setup_logger(output_file=None):
|
|
|
43
43
|
|
|
44
44
|
formatter = CleanFormatter()
|
|
45
45
|
|
|
46
|
-
console_handler = logging.StreamHandler(sys.
|
|
46
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
47
47
|
console_handler.setFormatter(formatter)
|
|
48
48
|
logger.addHandler(console_handler)
|
|
49
49
|
|
|
@@ -159,19 +159,25 @@ def interactive_selection(logger, unused_functions, unused_imports):
|
|
|
159
159
|
|
|
160
160
|
return selected_functions, selected_imports
|
|
161
161
|
|
|
162
|
-
def print_badge(dead_code_count
|
|
162
|
+
def print_badge(dead_code_count, logger, *, danger_enabled = False, danger_count = 0):
|
|
163
163
|
logger.info(f"\n{Colors.GRAY}{'─' * 50}{Colors.RESET}")
|
|
164
164
|
|
|
165
|
-
if dead_code_count == 0:
|
|
166
|
-
logger.info(
|
|
165
|
+
if dead_code_count == 0 and (not danger_enabled or danger_count == 0):
|
|
166
|
+
logger.info(" Your code is 100% dead code free! Add this badge to your README:")
|
|
167
167
|
logger.info("```markdown")
|
|
168
168
|
logger.info("")
|
|
169
169
|
logger.info("```")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if danger_enabled:
|
|
173
|
+
logger.info(f"Found {dead_code_count} dead code items and {danger_count} security flaws. Add this badge to your README:")
|
|
170
174
|
else:
|
|
171
175
|
logger.info(f"Found {dead_code_count} dead code items. Add this badge to your README:")
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
|
|
177
|
+
logger.info("```markdown")
|
|
178
|
+
logger.info(f"")
|
|
179
|
+
logger.info("```")
|
|
180
|
+
|
|
175
181
|
|
|
176
182
|
def main():
|
|
177
183
|
if len(sys.argv) > 1 and sys.argv[1] == 'run':
|
|
@@ -188,6 +194,12 @@ def main():
|
|
|
188
194
|
)
|
|
189
195
|
parser.add_argument("path", help="Path to the Python project")
|
|
190
196
|
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--table",
|
|
199
|
+
action="store_true",
|
|
200
|
+
help="Show findings in table"
|
|
201
|
+
)
|
|
202
|
+
|
|
191
203
|
parser.add_argument(
|
|
192
204
|
"--version",
|
|
193
205
|
action="version",
|
|
@@ -270,6 +282,9 @@ def main():
|
|
|
270
282
|
parser.add_argument("--secrets", action="store_true",
|
|
271
283
|
help="Scan for API keys. Off by default.")
|
|
272
284
|
|
|
285
|
+
parser.add_argument("--danger", action="store_true",
|
|
286
|
+
help="Scan for security issues. Off by default.")
|
|
287
|
+
|
|
273
288
|
args = parser.parse_args()
|
|
274
289
|
|
|
275
290
|
if args.list_default_excludes:
|
|
@@ -304,16 +319,143 @@ def main():
|
|
|
304
319
|
logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
|
|
305
320
|
|
|
306
321
|
try:
|
|
307
|
-
result_json = run_analyze(args.path, conf=args.confidence, enable_secrets=bool(args.secrets),
|
|
322
|
+
result_json = run_analyze(args.path, conf=args.confidence, enable_secrets=bool(args.secrets),
|
|
323
|
+
enable_danger=bool(args.danger), exclude_folders=list(final_exclude_folders))
|
|
324
|
+
|
|
325
|
+
if args.json:
|
|
326
|
+
print(result_json)
|
|
327
|
+
return
|
|
328
|
+
|
|
308
329
|
result = json.loads(result_json)
|
|
309
330
|
|
|
310
331
|
except Exception as e:
|
|
311
332
|
logger.error(f"Error during analysis: {e}")
|
|
312
333
|
sys.exit(1)
|
|
334
|
+
|
|
335
|
+
if args.table:
|
|
336
|
+
ELLIPSIS = "…"
|
|
337
|
+
|
|
338
|
+
def clip(text, max_length):
|
|
339
|
+
if not text:
|
|
340
|
+
text = ""
|
|
341
|
+
|
|
342
|
+
if len(text) <= max_length:
|
|
343
|
+
return text
|
|
344
|
+
|
|
345
|
+
truncated_end = max(0, max_length - 1)
|
|
346
|
+
return text[:truncated_end] + ELLIPSIS
|
|
347
|
+
|
|
348
|
+
def severity_color(severity):
|
|
349
|
+
severity_upper = (severity or "").upper()
|
|
350
|
+
|
|
351
|
+
if severity_upper in ("HIGH", "CRITICAL"):
|
|
352
|
+
return Colors.RED
|
|
353
|
+
elif severity_upper == "MEDIUM":
|
|
354
|
+
return Colors.YELLOW
|
|
355
|
+
else:
|
|
356
|
+
return Colors.GRAY
|
|
357
|
+
|
|
358
|
+
print(f"\n{Colors.CYAN}{Colors.BOLD}Unused {Colors.RESET}")
|
|
359
|
+
print(f"{Colors.CYAN}{'='*18}{Colors.RESET}")
|
|
360
|
+
|
|
361
|
+
print(f"{Colors.BOLD}{'kind':9} {'name':28} {'where'}{Colors.RESET}")
|
|
362
|
+
print(f"{'-'*9} {'-'*28} {'-'*36}")
|
|
363
|
+
|
|
364
|
+
unused_categories = [
|
|
365
|
+
("unused_functions", "function"),
|
|
366
|
+
("unused_imports", "import"),
|
|
367
|
+
("unused_classes", "class"),
|
|
368
|
+
("unused_variables", "variable"),
|
|
369
|
+
("unused_parameters", "parameter"),
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
for bucket, kind in unused_categories:
|
|
373
|
+
items = result.get(bucket, [])
|
|
374
|
+
for item in items:
|
|
375
|
+
name = item.get("name") or item.get("simple_name") or ""
|
|
376
|
+
file_path = item.get('file', '?')
|
|
377
|
+
line_num = item.get('line', item.get('lineno', '?'))
|
|
378
|
+
where = f"{file_path}:{line_num}"
|
|
379
|
+
|
|
380
|
+
clipped_name = clip(name, 28)
|
|
381
|
+
clipped_where = clip(where, 36)
|
|
382
|
+
print(f"{kind:9} {clipped_name:28} {clipped_where}")
|
|
383
|
+
|
|
384
|
+
secrets = result.get("secrets", []) or []
|
|
385
|
+
if secrets:
|
|
386
|
+
print(f"\n{Colors.RED}{Colors.BOLD}Secrets{Colors.RESET}")
|
|
387
|
+
print(f"{Colors.RED}{'=' * 7}{Colors.RESET}")
|
|
388
|
+
print(f"{Colors.BOLD}{'provider':12} {'message':22} {'preview':24} {'where'}{Colors.RESET}")
|
|
389
|
+
print(f"{'-' * 12} {'-' * 22} {'-' * 24} {'-' * 36}")
|
|
390
|
+
|
|
391
|
+
for secret in secrets[:100]:
|
|
392
|
+
provider = clip(secret.get("provider") or "generic", 12)
|
|
393
|
+
message = clip(secret.get("message") or "Secret detected", 22)
|
|
394
|
+
preview = clip(secret.get("preview") or "****", 24)
|
|
395
|
+
|
|
396
|
+
file_path = secret.get('file', '?')
|
|
397
|
+
line_num = secret.get('line', '?')
|
|
398
|
+
location = f"{file_path}:{line_num}"
|
|
399
|
+
clipped_location = clip(location, 36)
|
|
400
|
+
|
|
401
|
+
print(f"{Colors.MAGENTA}{provider:12}{Colors.RESET} {message:22} {preview:24} {clipped_location}")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
security_issues = result.get("danger", [])
|
|
405
|
+
if security_issues:
|
|
406
|
+
print(f"\n{Colors.RED}{Colors.BOLD}Security issues{Colors.RESET}")
|
|
407
|
+
print(f"{Colors.RED}{'=' * 15}{Colors.RESET}")
|
|
408
|
+
print(f"{Colors.BOLD}{'rule_id':10} {'sev':5} {'message':38} {'where'}{Colors.RESET}")
|
|
409
|
+
print(f"{'-' * 10} {'-' * 5} {'-' * 38} {'-' * 36}")
|
|
410
|
+
|
|
411
|
+
for issue in security_issues[:100]:
|
|
412
|
+
rule_id = clip(issue.get("rule_id") or "", 10)
|
|
413
|
+
severity = (issue.get("severity") or "").upper()
|
|
414
|
+
message = clip(issue.get("message") or "", 38)
|
|
415
|
+
|
|
416
|
+
file_path = issue.get('file', '?')
|
|
417
|
+
line_num = issue.get('line', '?')
|
|
418
|
+
location = f"{file_path}:{line_num}"
|
|
419
|
+
clipped_location = clip(location, 36)
|
|
420
|
+
|
|
421
|
+
severity_color_code = severity_color(severity)
|
|
422
|
+
clipped_severity = clip(severity, 5)
|
|
423
|
+
|
|
424
|
+
print(f"{rule_id:10} {severity_color_code}{clipped_severity:5}{Colors.RESET} {message:38} {clipped_location}")
|
|
425
|
+
|
|
426
|
+
summ = result.get("analysis_summary", {})
|
|
427
|
+
unused_keys = [
|
|
428
|
+
"unused_functions",
|
|
429
|
+
"unused_imports",
|
|
430
|
+
"unused_classes",
|
|
431
|
+
"unused_variables",
|
|
432
|
+
"unused_parameters"
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
total_unused = 0
|
|
436
|
+
for k in unused_keys:
|
|
437
|
+
total_unused += len(result.get(k, []))
|
|
438
|
+
|
|
439
|
+
print(f"\n{Colors.BOLD}Summary{Colors.RESET}")
|
|
440
|
+
|
|
441
|
+
print("=======")
|
|
442
|
+
print(f"files analyzed : {summ.get('total_files','?')}")
|
|
443
|
+
print(f"unused items : {total_unused}")
|
|
444
|
+
if "secrets_count" in summ:
|
|
445
|
+
print(f"secrets : {summ['secrets_count']}")
|
|
446
|
+
if "danger_count" in summ:
|
|
447
|
+
print(f"security issues: {summ['danger_count']}")
|
|
448
|
+
return
|
|
313
449
|
|
|
314
450
|
if args.json:
|
|
315
|
-
|
|
451
|
+
lg = logging.getLogger('skylos')
|
|
452
|
+
for h in list(lg.handlers):
|
|
453
|
+
if isinstance(h, logging.StreamHandler):
|
|
454
|
+
lg.removeHandler(h)
|
|
455
|
+
print(result_json)
|
|
316
456
|
return
|
|
457
|
+
|
|
458
|
+
result = json.loads(result_json)
|
|
317
459
|
|
|
318
460
|
unused_functions = result.get("unused_functions", [])
|
|
319
461
|
unused_imports = result.get("unused_imports", [])
|
|
@@ -321,6 +463,7 @@ def main():
|
|
|
321
463
|
unused_variables = result.get("unused_variables", [])
|
|
322
464
|
unused_classes = result.get("unused_classes", [])
|
|
323
465
|
secrets_findings = result.get("secrets", [])
|
|
466
|
+
danger_findings = result.get("danger", [])
|
|
324
467
|
|
|
325
468
|
logger.info(f"{Colors.CYAN}{Colors.BOLD} Python Static Analysis Results{Colors.RESET}")
|
|
326
469
|
logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
|
|
@@ -333,6 +476,8 @@ def main():
|
|
|
333
476
|
logger.info(f" * Unused classes: {Colors.YELLOW}{len(unused_classes)}{Colors.RESET}")
|
|
334
477
|
if secrets_findings:
|
|
335
478
|
logger.info(f" * Secrets: {Colors.RED}{len(secrets_findings)}{Colors.RESET}")
|
|
479
|
+
if danger_findings:
|
|
480
|
+
logger.info(f" * Security issues: {Colors.RED}{len(danger_findings)}{Colors.RESET}")
|
|
336
481
|
|
|
337
482
|
if args.interactive and (unused_functions or unused_imports):
|
|
338
483
|
logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
|
|
@@ -463,10 +608,27 @@ def main():
|
|
|
463
608
|
prev = s.get("preview", "****")
|
|
464
609
|
msg = s.get("message", "Secret detected")
|
|
465
610
|
logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{msg} [{provider}] {Colors.GRAY}({where}){Colors.RESET} -> {prev}")
|
|
611
|
+
|
|
612
|
+
if danger_findings:
|
|
613
|
+
logger.info(f"\n{Colors.RED}{Colors.BOLD} - Security Issues{Colors.RESET}")
|
|
614
|
+
logger.info(f"{Colors.RED}{'=' * 16}{Colors.RESET}")
|
|
615
|
+
for i, d in enumerate(danger_findings[:20], 1):
|
|
616
|
+
rule_id = d.get("rule_id", "unknown_rule")
|
|
617
|
+
severity = d.get("severity", "UNKNOWN").upper()
|
|
618
|
+
message = d.get("message", "Issue detected")
|
|
619
|
+
file = d.get("file", "?")
|
|
620
|
+
line = d.get("line", "?")
|
|
621
|
+
logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{message} [{rule_id}] {Colors.GRAY}({file}:{line}){Colors.RESET} Severity: {severity}")
|
|
466
622
|
|
|
467
623
|
dead_code_count = len(unused_functions) + len(unused_imports) + len(unused_variables) + len(unused_classes) + len(unused_parameters)
|
|
468
624
|
|
|
469
|
-
|
|
625
|
+
danger_count = len(danger_findings) if args.danger else 0
|
|
626
|
+
print_badge(
|
|
627
|
+
dead_code_count,
|
|
628
|
+
logger,
|
|
629
|
+
danger_enabled=bool(args.danger),
|
|
630
|
+
danger_count=danger_count,
|
|
631
|
+
)
|
|
470
632
|
|
|
471
633
|
if unused_functions or unused_imports:
|
|
472
634
|
logger.info(f"\n{Colors.BOLD}Next steps:{Colors.RESET}")
|
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import ast
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .danger_sql.sql_flow import scan as scan_sql
|
|
6
|
+
from .danger_cmd.cmd_flow import scan as scan_cmd
|
|
7
|
+
from .danger_sql.sql_raw_flow import scan as scan_sql_raw
|
|
8
|
+
from .danger_net.ssrf_flow import scan as scan_ssrf
|
|
9
|
+
from .danger_fs.path_flow import scan as scan_path
|
|
10
|
+
from .danger_web.xss_flow import scan as scan_xss
|
|
11
|
+
|
|
12
|
+
ALLOWED_SUFFIXES = (".py", ".pyi", ".pyw")
|
|
13
|
+
|
|
14
|
+
DANGEROUS_CALLS = {
|
|
15
|
+
"eval": ("SKY-D201", "HIGH", "Use of eval()"),
|
|
16
|
+
"exec": ("SKY-D202", "HIGH", "Use of exec()"),
|
|
17
|
+
"pickle.load": ("SKY-D203", "CRITICAL", "Untrusted deserialization via pickle.load"),
|
|
18
|
+
"pickle.loads": ("SKY-D204", "CRITICAL", "Untrusted deserialization via pickle.loads"),
|
|
19
|
+
"yaml.load": ("SKY-D205", "HIGH", "yaml.load without SafeLoader"),
|
|
20
|
+
"hashlib.md5": ("SKY-D206", "MEDIUM", "Weak hash (MD5)"),
|
|
21
|
+
"hashlib.sha1": ("SKY-D207", "MEDIUM", "Weak hash (SHA1)"),
|
|
22
|
+
"requests.*": ("SKY-D208", "HIGH", "requests call with verify=False",
|
|
23
|
+
{"kw_equals": {"verify": False}}),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def _matches_rule(name, rule_key):
|
|
27
|
+
if not name:
|
|
28
|
+
return False
|
|
29
|
+
if rule_key.endswith(".*"):
|
|
30
|
+
return name.startswith(rule_key[:-2] + ".")
|
|
31
|
+
return name == rule_key
|
|
32
|
+
|
|
33
|
+
def _kw_equals(node: ast.Call, requirements):
|
|
34
|
+
if not requirements:
|
|
35
|
+
return True
|
|
36
|
+
kw_map = {}
|
|
37
|
+
for kw in (node.keywords or []):
|
|
38
|
+
if kw.arg:
|
|
39
|
+
kw_map[kw.arg] = kw.value
|
|
40
|
+
|
|
41
|
+
for key, expected in requirements.items():
|
|
42
|
+
val = kw_map.get(key)
|
|
43
|
+
if not isinstance(val, ast.Constant):
|
|
44
|
+
return False
|
|
45
|
+
if val.value != expected:
|
|
46
|
+
return False
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def qualified_name_from_call(node: ast.Call):
|
|
50
|
+
func = node.func
|
|
51
|
+
parts = []
|
|
52
|
+
while isinstance(func, ast.Attribute):
|
|
53
|
+
parts.append(func.attr)
|
|
54
|
+
func = func.value
|
|
55
|
+
if isinstance(func, ast.Name):
|
|
56
|
+
parts.append(func.id)
|
|
57
|
+
parts.reverse()
|
|
58
|
+
return ".".join(parts)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def _yaml_load_without_safeloader(node: ast.Call):
|
|
62
|
+
name = qualified_name_from_call(node)
|
|
63
|
+
if name != "yaml.load":
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
for kw in (node.keywords or []):
|
|
67
|
+
if kw.arg == "Loader":
|
|
68
|
+
text = ast.unparse(kw.value)
|
|
69
|
+
return "SafeLoader" not in text
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def _add_finding(findings,
|
|
73
|
+
file_path: Path,
|
|
74
|
+
node: ast.AST,
|
|
75
|
+
rule_id,
|
|
76
|
+
severity,
|
|
77
|
+
message):
|
|
78
|
+
findings.append({
|
|
79
|
+
"rule_id": rule_id,
|
|
80
|
+
"severity": severity,
|
|
81
|
+
"message": message,
|
|
82
|
+
"file": str(file_path),
|
|
83
|
+
"line": getattr(node, "lineno", 1),
|
|
84
|
+
"col": getattr(node, "col_offset", 0),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
def _scan_file(file_path: Path, findings):
|
|
88
|
+
src = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
89
|
+
tree = ast.parse(src)
|
|
90
|
+
|
|
91
|
+
scan_sql(tree, file_path, findings)
|
|
92
|
+
scan_cmd(tree, file_path, findings)
|
|
93
|
+
scan_sql_raw(tree, file_path, findings)
|
|
94
|
+
scan_ssrf(tree, file_path, findings)
|
|
95
|
+
scan_path(tree, file_path, findings)
|
|
96
|
+
scan_xss(tree, file_path, findings)
|
|
97
|
+
|
|
98
|
+
for node in ast.walk(tree):
|
|
99
|
+
if not isinstance(node, ast.Call):
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
name = qualified_name_from_call(node)
|
|
103
|
+
if not name:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
for rule_key, tup in DANGEROUS_CALLS.items():
|
|
107
|
+
rule_id = tup[0]
|
|
108
|
+
severity = tup[1]
|
|
109
|
+
message = tup[2]
|
|
110
|
+
|
|
111
|
+
if len(tup) > 3:
|
|
112
|
+
opts = tup[3]
|
|
113
|
+
else:
|
|
114
|
+
opts = None
|
|
115
|
+
|
|
116
|
+
if not _matches_rule(name, rule_key):
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if rule_key == "yaml.load":
|
|
120
|
+
if not _yaml_load_without_safeloader(node):
|
|
121
|
+
continue
|
|
122
|
+
if opts and "kw_equals" in opts:
|
|
123
|
+
if not _kw_equals(node, opts["kw_equals"]):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
_add_finding(findings, file_path, node, rule_id, severity, message)
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
def scan_ctx( _, files):
|
|
130
|
+
findings = []
|
|
131
|
+
|
|
132
|
+
for file_path in files:
|
|
133
|
+
if file_path.suffix.lower() not in ALLOWED_SUFFIXES:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
_scan_file(file_path, findings)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
print(f"Scan failed for {file_path}: {e}", file=sys.stderr)
|
|
140
|
+
|
|
141
|
+
return findings
|
|
File without changes
|