skylos 2.1.2__py3-none-any.whl → 2.2.2__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 CHANGED
@@ -1,8 +1,10 @@
1
- from skylos.analyzer import analyze
1
+ __version__ = "2.2.2"
2
2
 
3
- __version__ = "2.1.2"
3
+ def analyze(*args, **kwargs):
4
+ from .analyzer import analyze as _analyze
5
+ return _analyze(*args, **kwargs)
4
6
 
5
7
  def debug_test():
6
8
  return "debug-ok"
7
9
 
8
- __all__ = ["analyze", "debug_test", "__version__"]
10
+ __all__ = ["analyze", "debug_test", "__version__"]
skylos/analyzer.py CHANGED
@@ -7,10 +7,11 @@ from pathlib import Path
7
7
  from collections import defaultdict
8
8
  from skylos.visitor import Visitor
9
9
  from skylos.constants import ( PENALTIES, AUTO_CALLED )
10
- from skylos.test_aware import TestAwareVisitor
10
+ from skylos.visitors.test_aware import TestAwareVisitor
11
+ from skylos.rules.secrets import scan_ctx as _secrets_scan_ctx
11
12
  import os
12
13
  import traceback
13
- from skylos.framework_aware import FrameworkAwareVisitor, detect_framework_usage
14
+ from skylos.visitors.framework_aware import FrameworkAwareVisitor, detect_framework_usage
14
15
 
15
16
  logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s - %(message)s')
16
17
  logger=logging.getLogger('Skylos')
@@ -237,7 +238,7 @@ class Skylos:
237
238
  if method.simple_name == "format" and cls.endswith("Formatter"):
238
239
  method.references += 1
239
240
 
240
- def analyze(self, path, thr=60, exclude_folders=None):
241
+ def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False):
241
242
  files, root = self._get_python_files(path, exclude_folders)
242
243
 
243
244
  if not files:
@@ -260,6 +261,7 @@ class Skylos:
260
261
  for f in files:
261
262
  modmap[f] = self._module(root, f)
262
263
 
264
+ all_secrets = []
263
265
  for file in files:
264
266
  mod = modmap[file]
265
267
  defs, refs, dyn, exports, test_flags, framework_flags = proc_file(file, mod)
@@ -272,6 +274,16 @@ class Skylos:
272
274
  self.dynamic.update(dyn)
273
275
  self.exports[mod].update(exports)
274
276
 
277
+ if enable_secrets and _secrets_scan_ctx is not None:
278
+ try:
279
+ src_lines = Path(file).read_text(encoding="utf-8", errors="ignore").splitlines(True)
280
+ ctx = {"relpath": str(file), "lines": src_lines, "tree": None}
281
+ findings = list(_secrets_scan_ctx(ctx))
282
+ if findings:
283
+ all_secrets.extend(findings)
284
+ except Exception:
285
+ pass
286
+
275
287
  self._mark_refs()
276
288
  self._apply_heuristics()
277
289
  self._mark_exports()
@@ -303,6 +315,9 @@ class Skylos:
303
315
  "excluded_folders": exclude_folders or [],
304
316
  }
305
317
  }
318
+
319
+ if enable_secrets and all_secrets:
320
+ result["secrets"] = all_secrets
306
321
 
307
322
  for u in unused:
308
323
  if u["type"] in ("function", "method"):
@@ -355,8 +370,8 @@ def proc_file(file_or_args, mod=None):
355
370
 
356
371
  return [], [], set(), set(), dummy_visitor, dummy_framework_visitor
357
372
 
358
- def analyze(path,conf=60, exclude_folders=None):
359
- return Skylos().analyze(path,conf, exclude_folders)
373
+ def analyze(path, conf=60, exclude_folders=None, enable_secrets=False):
374
+ return Skylos().analyze(path,conf, exclude_folders, enable_secrets)
360
375
 
361
376
  if __name__ == "__main__":
362
377
  if len(sys.argv)>1:
skylos/cli.py CHANGED
@@ -8,6 +8,8 @@ from skylos.analyzer import analyze as run_analyze
8
8
  from skylos.codemods import (
9
9
  remove_unused_import_cst,
10
10
  remove_unused_function_cst,
11
+ comment_out_unused_import_cst,
12
+ comment_out_unused_function_cst,
11
13
  )
12
14
  import pathlib
13
15
  import skylos
@@ -80,6 +82,32 @@ def remove_unused_function(file_path, function_name, line_number):
80
82
  except Exception as e:
81
83
  logging.error(f"Failed to remove function {function_name} from {file_path}: {e}")
82
84
  return False
85
+
86
+ def comment_out_unused_import(file_path, import_name, line_number, marker="SKYLOS DEADCODE"):
87
+ path = pathlib.Path(file_path)
88
+ try:
89
+ src = path.read_text(encoding="utf-8")
90
+ new_code, changed = comment_out_unused_import_cst(src, import_name, line_number, marker=marker)
91
+ if not changed:
92
+ return False
93
+ path.write_text(new_code, encoding="utf-8")
94
+ return True
95
+ except Exception as e:
96
+ logging.error(f"Failed to comment out import {import_name} from {file_path}: {e}")
97
+ return False
98
+
99
+ def comment_out_unused_function(file_path, function_name, line_number, marker="SKYLOS DEADCODE"):
100
+ path = pathlib.Path(file_path)
101
+ try:
102
+ src = path.read_text(encoding="utf-8")
103
+ new_code, changed = comment_out_unused_function_cst(src, function_name, line_number, marker=marker)
104
+ if not changed:
105
+ return False
106
+ path.write_text(new_code, encoding="utf-8")
107
+ return True
108
+ except Exception as e:
109
+ logging.error(f"Failed to comment out function {function_name} from {file_path}: {e}")
110
+ return False
83
111
 
84
112
  def interactive_selection(logger, unused_functions, unused_imports):
85
113
  if not INTERACTIVE_AVAILABLE:
@@ -110,7 +138,7 @@ def interactive_selection(logger, unused_functions, unused_imports):
110
138
  selected_functions = answers['functions']
111
139
 
112
140
  if unused_imports:
113
- logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}Select unused imports to remove (hit spacebar to select):{Colors.RESET}")
141
+ logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}Select unused imports to act on (hit spacebar to select):{Colors.RESET}")
114
142
 
115
143
  import_choices = []
116
144
 
@@ -172,6 +200,13 @@ def main():
172
200
  action="store_true",
173
201
  help="Output raw JSON",
174
202
  )
203
+
204
+ parser.add_argument(
205
+ "--comment-out",
206
+ action="store_true",
207
+ help="Comment out selected dead code instead of deleting it",
208
+ )
209
+
175
210
  parser.add_argument(
176
211
  "--output",
177
212
  "-o",
@@ -232,12 +267,15 @@ def main():
232
267
  help="List the default excluded folders and exit."
233
268
  )
234
269
 
270
+ parser.add_argument("--secrets", action="store_true",
271
+ help="Scan for API keys. Off by default.")
272
+
235
273
  args = parser.parse_args()
236
274
 
237
275
  if args.list_default_excludes:
238
276
  print("Default excluded folders:")
239
277
  for folder in sorted(DEFAULT_EXCLUDE_FOLDERS):
240
- print(f" {folder}")
278
+ print(f" {folder}")
241
279
  print(f"\nTotal: {len(DEFAULT_EXCLUDE_FOLDERS)} folders")
242
280
  print("\nUse --no-default-excludes to disable these exclusions")
243
281
  print("Use --include-folder <folder> to force include specific folders")
@@ -266,7 +304,7 @@ def main():
266
304
  logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
267
305
 
268
306
  try:
269
- result_json = run_analyze(args.path, conf=args.confidence, exclude_folders=list(final_exclude_folders))
307
+ result_json = run_analyze(args.path, conf=args.confidence, enable_secrets=bool(args.secrets), exclude_folders=list(final_exclude_folders))
270
308
  result = json.loads(result_json)
271
309
 
272
310
  except Exception as e:
@@ -282,6 +320,7 @@ def main():
282
320
  unused_parameters = result.get("unused_parameters", [])
283
321
  unused_variables = result.get("unused_variables", [])
284
322
  unused_classes = result.get("unused_classes", [])
323
+ secrets_findings = result.get("secrets", [])
285
324
 
286
325
  logger.info(f"{Colors.CYAN}{Colors.BOLD} Python Static Analysis Results{Colors.RESET}")
287
326
  logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
@@ -292,49 +331,75 @@ def main():
292
331
  logger.info(f" * Unused parameters: {Colors.YELLOW}{len(unused_parameters)}{Colors.RESET}")
293
332
  logger.info(f" * Unused variables: {Colors.YELLOW}{len(unused_variables)}{Colors.RESET}")
294
333
  logger.info(f" * Unused classes: {Colors.YELLOW}{len(unused_classes)}{Colors.RESET}")
334
+ if secrets_findings:
335
+ logger.info(f" * Secrets: {Colors.RED}{len(secrets_findings)}{Colors.RESET}")
295
336
 
296
337
  if args.interactive and (unused_functions or unused_imports):
297
338
  logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
298
339
  selected_functions, selected_imports = interactive_selection(logger, unused_functions, unused_imports)
299
340
 
300
341
  if selected_functions or selected_imports:
301
- logger.info(f"\n{Colors.BOLD}Selected items to remove:{Colors.RESET}")
342
+ logger.info(f"\n{Colors.BOLD}Selected items to process:{Colors.RESET}")
302
343
 
303
344
  if selected_functions:
304
- logger.info(f" Functions: {len(selected_functions)}")
345
+ logger.info(f" Functions: {len(selected_functions)}")
305
346
  for func in selected_functions:
306
- logger.info(f" - {func['name']} ({func['file']}:{func['line']})")
347
+ logger.info(f" - {func['name']} ({func['file']}: {func['line']})")
307
348
 
308
349
  if selected_imports:
309
- logger.info(f" Imports: {len(selected_imports)}")
350
+ logger.info(f" Imports: {len(selected_imports)}")
310
351
  for imp in selected_imports:
311
- logger.info(f" - {imp['name']} ({imp['file']}:{imp['line']})")
352
+ logger.info(f" - {imp['name']} ({imp['file']}: {imp['line']})")
312
353
 
313
354
  if not args.dry_run:
355
+ if args.comment_out:
356
+ confirm_verb = "comment out"
357
+ else:
358
+ confirm_verb = "remove"
359
+
314
360
  questions = [
315
361
  inquirer.Confirm('confirm',
316
- message="Are you sure you want to remove these items?",
362
+ message="Are you sure you want to process these items?",
317
363
  default=False)
318
364
  ]
319
365
  answers = inquirer.prompt(questions)
320
366
 
321
367
  if answers and answers['confirm']:
322
- logger.info(f"\n{Colors.YELLOW}Removing selected items...{Colors.RESET}")
323
-
368
+ action = "Commenting out" if args.comment_out else "Removing"
369
+ logger.info(f"\n{Colors.YELLOW}{action} selected items...{Colors.RESET}")
370
+
371
+ action_func = comment_out_unused_function if args.comment_out else remove_unused_function
372
+ if args.comment_out:
373
+ action_past = "Commented out"
374
+ action_verb = "comment out"
375
+ else:
376
+ action_past = "Removed"
377
+ action_verb = "remove"
378
+
324
379
  for func in selected_functions:
325
- success = remove_unused_function(func['file'], func['name'], func['line'])
380
+ success = action_func(func['file'], func['name'], func['line'])
381
+
326
382
  if success:
327
- logger.info(f" {Colors.GREEN} {Colors.RESET} Removed function: {func['name']}")
383
+ logger.info(f" {Colors.GREEN} {Colors.RESET} {action_past} function: {func['name']}")
328
384
  else:
329
- logger.error(f" {Colors.RED} x {Colors.RESET} Failed to remove: {func['name']}")
330
-
385
+ logger.error(f" {Colors.RED} x {Colors.RESET} Failed to {action_verb}: {func['name']}")
386
+
387
+ import_func = comment_out_unused_import if args.comment_out else remove_unused_import
388
+ if args.comment_out:
389
+ action_past = "Commented out"
390
+ action_verb = "comment out"
391
+ else:
392
+ action_past = "Removed"
393
+ action_verb = "remove"
394
+
331
395
  for imp in selected_imports:
332
- success = remove_unused_import(imp['file'], imp['name'], imp['line'])
396
+ success = import_func(imp['file'], imp['name'], imp['line'])
397
+
333
398
  if success:
334
- logger.info(f" {Colors.GREEN} {Colors.RESET} Removed import: {imp['name']}")
399
+ logger.info(f" {Colors.GREEN} {Colors.RESET} {action_past} import: {imp['name']}")
335
400
  else:
336
- logger.error(f" {Colors.RED} x {Colors.RESET} Failed to remove: {imp['name']}")
337
-
401
+ logger.error(f" {Colors.RED} x {Colors.RESET} Failed to {action_verb}: {imp['name']}")
402
+
338
403
  logger.info(f"\n{Colors.GREEN}Cleanup complete!{Colors.RESET}")
339
404
  else:
340
405
  logger.info(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
@@ -389,6 +454,16 @@ def main():
389
454
  else:
390
455
  logger.info(f"\n{Colors.GREEN}✓ All classes are being used!{Colors.RESET}")
391
456
 
457
+ if secrets_findings:
458
+ logger.info(f"\n{Colors.RED}{Colors.BOLD} - Secrets{Colors.RESET}")
459
+ logger.info(f"{Colors.RED}{'=' * 9}{Colors.RESET}")
460
+ for i, s in enumerate(secrets_findings[:20], 1):
461
+ provider = s.get("provider", "generic")
462
+ where = f"{s.get('file','?')}:{s.get('line','?')}"
463
+ prev = s.get("preview", "****")
464
+ msg = s.get("message", "Secret detected")
465
+ logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{msg} [{provider}] {Colors.GRAY}({where}){Colors.RESET} -> {prev}")
466
+
392
467
  dead_code_count = len(unused_functions) + len(unused_imports) + len(unused_variables) + len(unused_classes) + len(unused_parameters)
393
468
 
394
469
  print_badge(dead_code_count, logger)
skylos/codemods.py CHANGED
@@ -2,6 +2,155 @@ from __future__ import annotations
2
2
  import libcst as cst
3
3
  from libcst.metadata import PositionProvider
4
4
 
5
+ class _CommentOutBlock(cst.CSTTransformer):
6
+
7
+ METADATA_DEPENDENCIES = (PositionProvider,)
8
+
9
+ def __init__(self, module_code, marker = "SKYLOS DEADCODE"):
10
+ self.module_code = module_code.splitlines(True)
11
+ self.marker = marker
12
+
13
+ def _comment_block(self, start_line, end_line):
14
+ lines = self.module_code[start_line - 1:end_line]
15
+ out = []
16
+ out.append(cst.EmptyLine(comment=cst.Comment(f"# {self.marker} START (lines {start_line}-{end_line})")))
17
+ for raw in lines:
18
+ out.append(cst.EmptyLine(comment=cst.Comment("# " + raw.rstrip("\n"))))
19
+ out.append(cst.EmptyLine(comment=cst.Comment(f"# {self.marker} END")))
20
+ return out
21
+
22
+ class _CommentOutFunctionAtLine(_CommentOutBlock):
23
+ def __init__(self, func_name, target_line, module_code, marker):
24
+ super().__init__(module_code, marker)
25
+ self.func_name = func_name
26
+ self.target_line = target_line
27
+ self.changed = False
28
+
29
+ def _is_target(self, node: cst.CSTNode):
30
+ pos = self.get_metadata(PositionProvider, node, None)
31
+ return pos and pos.start.line == self.target_line
32
+
33
+ def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
34
+ if self._is_target(orig) and (orig.name.value == self.func_name):
35
+ self.changed = True
36
+ pos = self.get_metadata(PositionProvider, orig)
37
+ return cst.FlattenSentinel(self._comment_block(pos.start.line, pos.end.line))
38
+ return updated
39
+
40
+ def leave_AsyncFunctionDef(self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef):
41
+ if self._is_target(orig) and (orig.name.value == self.func_name):
42
+ self.changed = True
43
+ pos = self.get_metadata(PositionProvider, orig)
44
+ return cst.FlattenSentinel(self._comment_block(pos.start.line, pos.end.line))
45
+ return updated
46
+
47
+ class _CommentOutImportAtLine(_CommentOutBlock):
48
+
49
+ def __init__(self, target_name, target_line, module_code, marker):
50
+ super().__init__(module_code, marker)
51
+ self.target_name = target_name
52
+ self.target_line = target_line
53
+ self.changed = False
54
+
55
+ def _is_target_line(self, node: cst.CSTNode):
56
+ pos = self.get_metadata(PositionProvider, node, None)
57
+ return bool(pos and (pos.start.line <= self.target_line <= pos.end.line))
58
+
59
+ def _render_single_alias_text(self, head, alias: cst.ImportAlias, is_from):
60
+ if is_from:
61
+ alias_txt = alias.name.code
62
+ if alias.asname:
63
+ alias_txt += f" as {alias.asname.name.value}"
64
+ return f"from {head} import {alias_txt}"
65
+ else:
66
+ alias_txt = alias.name.code
67
+ if alias.asname:
68
+ alias_txt += f" as {alias.asname.name.value}"
69
+ return f"import {alias_txt}"
70
+
71
+ def _split_aliases(self, aliases, head, is_from):
72
+ kept = []
73
+ removed_for_comment= []
74
+ for alias in list(aliases):
75
+ bound = _bound_name_for_import_alias(alias)
76
+ if bound == self.target_name:
77
+ self.changed = True
78
+ removed_for_comment.append(self._render_single_alias_text(head, alias, is_from))
79
+ else:
80
+ kept.append(alias)
81
+ return kept, removed_for_comment
82
+
83
+ def leave_Import(self, orig: cst.Import, updated: cst.Import):
84
+ if not self._is_target_line(orig):
85
+ return updated
86
+
87
+ head = ""
88
+ kept, removed = self._split_aliases(updated.names, head, is_from=False)
89
+
90
+ if not removed:
91
+ return updated
92
+
93
+ pos = self.get_metadata(PositionProvider, orig)
94
+ if not kept:
95
+ return cst.FlattenSentinel(self._comment_block(pos.start.line, pos.end.line))
96
+
97
+ commented = []
98
+ for txt in removed:
99
+ comment = cst.Comment(f"# {self.marker}: {txt}")
100
+ commented.append(cst.EmptyLine(comment=comment))
101
+
102
+ kept_import = updated.with_changes(names=tuple(kept))
103
+ all_nodes = [kept_import] + commented
104
+ return cst.FlattenSentinel(all_nodes)
105
+
106
+ def leave_ImportFrom(self, orig: cst.ImportFrom, updated: cst.ImportFrom):
107
+ if not self._is_target_line(orig) or isinstance(updated.names, cst.ImportStar):
108
+ return updated
109
+
110
+ if updated.relative:
111
+ dots = "." * len(updated.relative)
112
+ else:
113
+ dots = ""
114
+
115
+ if updated.module is not None:
116
+ modname = updated.module.code
117
+ else:
118
+ modname = ""
119
+
120
+ mod = f"{dots}{modname}"
121
+
122
+ kept, removed = self._split_aliases(list(updated.names), mod, is_from=True)
123
+
124
+ if not removed:
125
+ return updated
126
+ pos = self.get_metadata(PositionProvider, orig)
127
+
128
+ if not kept:
129
+ comment_block = self._comment_block(pos.start.line, pos.end.line)
130
+ return cst.FlattenSentinel(comment_block)
131
+
132
+ commented = []
133
+ for txt in removed:
134
+ comment = cst.Comment(f"# {self.marker}: {txt}")
135
+ commented.append(cst.EmptyLine(comment=comment))
136
+
137
+ updated_import = updated.with_changes(names=tuple(kept))
138
+ all_nodes = [updated_import] + commented
139
+
140
+ return cst.FlattenSentinel(all_nodes)
141
+
142
+ def comment_out_unused_function_cst(code, func_name, line_number, marker = "SKYLOS DEADCODE"):
143
+ wrapper = cst.MetadataWrapper(cst.parse_module(code))
144
+ tx = _CommentOutFunctionAtLine(func_name, line_number, code, marker)
145
+ new_mod = wrapper.visit(tx)
146
+ return new_mod.code, tx.changed
147
+
148
+ def comment_out_unused_import_cst(code, import_name, line_number, marker = "SKYLOS DEADCODE"):
149
+ wrapper = cst.MetadataWrapper(cst.parse_module(code))
150
+ tx = _CommentOutImportAtLine(import_name, line_number, code, marker)
151
+ new_mod = wrapper.visit(tx)
152
+ return new_mod.code, tx.changed
153
+
5
154
  def _bound_name_for_import_alias(alias: cst.ImportAlias):
6
155
  if alias.asname:
7
156
  return alias.asname.name.value
@@ -20,8 +169,8 @@ class _RemoveImportAtLine(cst.CSTTransformer):
20
169
 
21
170
  def _is_target_line(self, node: cst.CSTNode):
22
171
  pos = self.get_metadata(PositionProvider, node, None)
23
- return bool(pos and pos.start.line == self.target_line)
24
-
172
+ return bool(pos and (pos.start.line <= self.target_line <= pos.end.line))
173
+
25
174
  def _filter_aliases(self, aliases):
26
175
  kept = []
27
176
  for alias in aliases:
@@ -61,16 +210,16 @@ class _RemoveFunctionAtLine(cst.CSTTransformer):
61
210
 
62
211
  def _is_target(self, node: cst.CSTNode):
63
212
  pos = self.get_metadata(PositionProvider, node, None)
64
- return bool(pos and pos.start.line == self.target_line)
213
+ return pos and pos.start.line == self.target_line
65
214
 
66
215
  def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
67
- if self._is_target(orig) and (orig.name.value in self.func_name):
216
+ if self._is_target(orig) and (orig.name.value == self.func_name):
68
217
  self.changed = True
69
218
  return cst.RemoveFromParent()
70
219
  return updated
71
220
 
72
221
  def leave_AsyncFunctionDef(self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef):
73
- if self._is_target(orig) and (orig.name.value in self.func_name):
222
+ if self._is_target(orig) and (orig.name.value == self.func_name):
74
223
  self.changed = True
75
224
  return cst.RemoveFromParent()
76
225
 
File without changes
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+ import re, ast
3
+ from math import log2
4
+ from typing import Dict, Any, Iterable, List, Optional
5
+
6
+ __all__ = ["scan_ctx"]
7
+
8
+ ALLOWED_FILE_SUFFIXES = (".py", ".pyi", ".pyw")
9
+
10
+ PROVIDER_PATTERNS = [
11
+ ("github", re.compile(r"(ghp|gho|ghu|ghs|ghr|gpat)_[A-Za-z0-9]{36,}")),
12
+ ("gitlab", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
13
+ ("slack", re.compile(r"xox[abprs]-[A-Za-z0-9-]{10,48}")),
14
+ ("stripe", re.compile(r"sk_(live|test)_[A-Za-z0-9]{16,}")),
15
+ ("aws_access_key_id", re.compile(r"\b(AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[0-9A-Z]{16}\b")),
16
+ ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
17
+ ("sendgrid", re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b")),
18
+ ("twilio", re.compile(r"\bSK[0-9a-fA-F]{32}\b")),
19
+ ("private_key_block", re.compile(r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY-----")),
20
+ ]
21
+
22
+ GENERIC_VALUE = re.compile(r"""(?ix)
23
+ (?:
24
+ (token|api[_-]?key|secret|password|passwd|pwd|bearer|auth[_-]?token|access[_-]?token)
25
+ \s*[:=]\s*(?P<q>['"])(?P<val>[^'"]{16,})(?P=q)
26
+ )|(?P<bare>[A-Za-z0-9_\-]{24,})
27
+ """)
28
+
29
+ SAFE_TEST_HINTS = {
30
+ "example", "sample", "fake", "placeholder", "dummy", "test_", "_test", "test_test_",
31
+ "changeme", "password", "secret", "not_a_real", "do_not_use",
32
+ }
33
+
34
+ IGNORE_DIRECTIVE = "skylos: ignore[SKY-S101]"
35
+ DEFAULT_MIN_ENTROPY = 3.6
36
+
37
+ def _entropy(s):
38
+ if len(s) == 0:
39
+ return 0.0
40
+
41
+ char_counts = {}
42
+ for character in s:
43
+ if character in char_counts:
44
+ char_counts[character] += 1
45
+ else:
46
+ char_counts[character] = 1
47
+
48
+ total_chars = len(s)
49
+ entropy = 0.0
50
+
51
+ for count in char_counts.values():
52
+ probability = count / total_chars
53
+ entropy -= probability * log2(probability)
54
+
55
+ return entropy
56
+
57
+ def _mask(tok):
58
+ token_length = len(tok)
59
+
60
+ if token_length <= 8:
61
+ return "*" * token_length
62
+
63
+ else:
64
+ first_part = tok[:4]
65
+ last_part = tok[-4:]
66
+ return first_part + "…" + last_part
67
+
68
+ def _docstring_lines(tree):
69
+ if tree is None:
70
+ return set()
71
+
72
+ docstring_line_numbers = set()
73
+
74
+ def find_docstring_lines(node):
75
+ if not hasattr(node, "body") or not node.body:
76
+ return
77
+
78
+ first_statement = node.body[0]
79
+
80
+ is_expression = isinstance(first_statement, ast.Expr)
81
+ if not is_expression:
82
+ return
83
+
84
+ value = getattr(first_statement, "value", None)
85
+ if not isinstance(value, ast.Constant):
86
+ return
87
+
88
+ if not isinstance(value.value, str):
89
+ return
90
+
91
+ start_line = getattr(first_statement, "lineno", None)
92
+ end_line = getattr(first_statement, "end_lineno", start_line)
93
+
94
+ if start_line is not None:
95
+ if end_line is None:
96
+ end_line = start_line
97
+
98
+ for line_num in range(start_line, end_line + 1):
99
+ docstring_line_numbers.add(line_num)
100
+
101
+ if isinstance(tree, ast.Module):
102
+ find_docstring_lines(tree)
103
+
104
+ for node in ast.walk(tree):
105
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
106
+ find_docstring_lines(node)
107
+
108
+ return docstring_line_numbers
109
+
110
+ def scan_ctx(ctx, *, min_entropy= DEFAULT_MIN_ENTROPY, scan_comments= True,
111
+ scan_docstrings= True, allowlist_patterns= None, ignore_path_substrings= None):
112
+
113
+ rel_path = ctx.get("relpath", "")
114
+ if not rel_path.endswith(ALLOWED_FILE_SUFFIXES):
115
+ return []
116
+
117
+ if ignore_path_substrings:
118
+ for substring in ignore_path_substrings:
119
+ if substring and substring in rel_path:
120
+ return []
121
+
122
+ file_lines = ctx.get("lines") or []
123
+ syntax_tree = ctx.get("tree")
124
+
125
+ allowlist_regexes = []
126
+ if allowlist_patterns:
127
+ for pattern in allowlist_patterns:
128
+ compiled_regex = re.compile(pattern)
129
+ allowlist_regexes.append(compiled_regex)
130
+
131
+ if scan_docstrings:
132
+ docstring_lines = set()
133
+ else:
134
+ docstring_lines = _docstring_lines(syntax_tree)
135
+
136
+ findings = []
137
+
138
+ for line_number, raw_line in enumerate(file_lines, start=1):
139
+ line_content = raw_line.rstrip("\n")
140
+
141
+ if IGNORE_DIRECTIVE in line_content:
142
+ continue
143
+
144
+ stripped_line = line_content.lstrip()
145
+ if not scan_comments and stripped_line.startswith("#"):
146
+ continue
147
+
148
+ if not scan_docstrings and line_number in docstring_lines:
149
+ continue
150
+
151
+ should_skip_line = False
152
+ for regex_pattern in allowlist_regexes:
153
+ if regex_pattern.search(line_content):
154
+ should_skip_line = True
155
+ break
156
+
157
+ if should_skip_line:
158
+ continue
159
+
160
+ for provider_name, pattern_regex in PROVIDER_PATTERNS:
161
+ pattern_matches = pattern_regex.finditer(line_content)
162
+
163
+ for regex_match in pattern_matches:
164
+ potential_secret = regex_match.group(0)
165
+
166
+ token_lowercase = potential_secret.lower()
167
+ has_safe_hint = False
168
+
169
+ for safe_hint in SAFE_TEST_HINTS:
170
+ if safe_hint in token_lowercase:
171
+ has_safe_hint = True
172
+ break
173
+
174
+ if has_safe_hint:
175
+ continue
176
+
177
+ col_pos = line_content.find(potential_secret)
178
+
179
+ finding = {
180
+ "rule_id": "SKY-S101",
181
+ "severity": "CRITICAL",
182
+ "provider": provider_name,
183
+ "message": f"Potential {provider_name} secret detected",
184
+ "file": rel_path,
185
+ "line": line_number,
186
+ "col": max(0, col_pos),
187
+ "end_col": max(1, col_pos + len(potential_secret)),
188
+ "preview": _mask(potential_secret),
189
+ }
190
+ findings.append(finding)
191
+
192
+ aws_key_indicators = ["AWS_SECRET_ACCESS_KEY", "aws_secret_access_key"]
193
+ line_has_aws_key = False
194
+
195
+ for indicator in aws_key_indicators:
196
+ if indicator in line_content or indicator in line_content.lower():
197
+ line_has_aws_key = True
198
+ break
199
+
200
+ if line_has_aws_key:
201
+ aws_secret_pattern = r"['\"]?([A-Za-z0-9/+=]{40})['\"]?"
202
+ aws_match = re.search(aws_secret_pattern, line_content)
203
+
204
+ if aws_match:
205
+ aws_token = aws_match.group(1)
206
+ tok_entropy = _entropy(aws_token)
207
+
208
+ if tok_entropy >= min_entropy:
209
+ col_pos = line_content.find(aws_token)
210
+
211
+ aws_finding = {
212
+ "rule_id": "SKY-S101",
213
+ "severity": "CRITICAL",
214
+ "provider": "aws_secret_access_key",
215
+ "message": "Potential AWS secret access key detected",
216
+ "file": rel_path,
217
+ "line": line_number,
218
+ "col": max(0, col_pos),
219
+ "end_col": max(1, col_pos + len(aws_token)),
220
+ "preview": _mask(aws_token),
221
+ "entropy": round(tok_entropy, 2),
222
+ }
223
+ findings.append(aws_finding)
224
+
225
+ generic_match = GENERIC_VALUE.search(line_content)
226
+ if generic_match:
227
+ val_group = generic_match.group("val")
228
+ bare_group = generic_match.group("bare")
229
+
230
+ if val_group:
231
+ extracted_token = val_group
232
+ elif bare_group:
233
+ extracted_token = bare_group
234
+ else:
235
+ extracted_token = ""
236
+
237
+ clean_token = extracted_token.strip()
238
+
239
+ if clean_token:
240
+ token_lowercase = clean_token.lower()
241
+ has_safe_hint = False
242
+
243
+ for safe_hint in SAFE_TEST_HINTS:
244
+ if safe_hint in token_lowercase:
245
+ has_safe_hint = True
246
+ break
247
+
248
+ if not has_safe_hint:
249
+ tok_entropy = _entropy(clean_token)
250
+
251
+ if tok_entropy >= min_entropy and len(clean_token) >= 20:
252
+ col_pos = line_content.find(clean_token)
253
+
254
+ generic_finding = {
255
+ "rule_id": "SKY-S101",
256
+ "severity": "CRITICAL",
257
+ "provider": "generic",
258
+ "message": f"High-entropy value detected (entropy={tok_entropy:.2f})",
259
+ "file": rel_path,
260
+ "line": line_number,
261
+ "col": max(0, col_pos),
262
+ "end_col": max(1, col_pos + len(clean_token)),
263
+ "preview": _mask(clean_token),
264
+ "entropy": round(tok_entropy, 2),
265
+ }
266
+ findings.append(generic_finding)
267
+
268
+ return findings
skylos/server.py CHANGED
@@ -1,9 +1,3 @@
1
- #!/usr/bin/env python3
2
- """
3
- Skylos Web Server
4
- Serves the frontend and provides API to analyze projects using skylos
5
- """
6
-
7
1
  from flask import Flask, request, jsonify
8
2
  from flask_cors import CORS
9
3
  import skylos
@@ -17,7 +11,6 @@ CORS(app)
17
11
 
18
12
  @app.route('/')
19
13
  def serve_frontend():
20
- """Serve the frontend HTML"""
21
14
  return """<!DOCTYPE html>
22
15
  <html lang="en">
23
16
  <head>
@@ -461,7 +454,6 @@ def serve_frontend():
461
454
  classes: analysisData.unused_classes || []
462
455
  };
463
456
 
464
- // Filter by confidence threshold
465
457
  Object.keys(data).forEach(key => {
466
458
  data[key] = data[key].filter(item => item.confidence >= confidenceThreshold);
467
459
  });
@@ -481,12 +473,11 @@ def serve_frontend():
481
473
  const listElement = document.getElementById('deadCodeList');
482
474
  const allItems = [];
483
475
 
484
- // Combine all items with their categories
485
476
  Object.keys(data).forEach(category => {
486
477
  data[category].forEach(item => {
487
478
  allItems.push({
488
479
  ...item,
489
- category: category.slice(0, -1) // Remove 's' from end
480
+ category: category.slice(0, -1)
490
481
  });
491
482
  });
492
483
  });
@@ -500,7 +491,6 @@ def serve_frontend():
500
491
  return;
501
492
  }
502
493
 
503
- // Sort by confidence (highest first)
504
494
  allItems.sort((a, b) => b.confidence - a.confidence);
505
495
 
506
496
  listElement.innerHTML = allItems.map(item => `
@@ -551,7 +541,6 @@ def start_server():
551
541
  print(" Starting Skylos Web Interface...")
552
542
  print("Opening browser at: http://localhost:5090")
553
543
 
554
- # Open browser after a short delay
555
544
  Timer(1.5, open_browser).start()
556
545
 
557
546
  app.run(debug=False, host='0.0.0.0', port=5090, use_reloader=False)
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skylos
3
- Version: 2.1.2
3
+ Version: 2.2.2
4
4
  Summary: A static analysis tool for Python codebases
5
5
  Author-email: oha <aaronoh2015@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -1,12 +1,15 @@
1
- skylos/__init__.py,sha256=GEEzpCo5orfkiOXwdwQpFZjRldxDjmjHIDt54ROCdKk,151
2
- skylos/analyzer.py,sha256=xeKF2_Jj9ivmUZwc9hTlI_P62rQR22mMA0WLIjKNayE,15997
3
- skylos/cli.py,sha256=hT1lWhV6Bzx7M_HOSyrpBaCrDQOSsnA4IHGQgUoAjIk,16166
4
- skylos/codemods.py,sha256=A5dNwTJiYtgP3Mv8NQ03etdfi9qNHSECv1GRpLyDCYU,3213
1
+ skylos/__init__.py,sha256=C4oz_FAjQTq_0XiSSxpDnngdL9YlWLpuam7NNvB_vPs,229
2
+ skylos/analyzer.py,sha256=G_8pw7GmChATc5h5XXij2pcHirhh_5G9Y8dlAC1dx38,16735
3
+ skylos/cli.py,sha256=DOV6nwPbi5zh-OJ3wXjIaPxCfXRtiPMNqo9Zp2nvDBA,19475
4
+ skylos/codemods.py,sha256=QdOwtbE2PpLsgCqpeScFg-pCfcpfHw9hFu-0WKKKBQg,9084
5
5
  skylos/constants.py,sha256=kU-2FKQAV-ju4rYw62Tw25uRvqauzjZFUqtvGWaI6Es,1571
6
- skylos/framework_aware.py,sha256=eX4oU0jQwDWejkkW4kjRNctre27sVLHK1CTDDiqPqRw,13054
7
- skylos/server.py,sha256=5Rlgy3LdE8I5TWRJJh0n19JqhVYaAOc9fUtRjL-PpX8,16270
8
- skylos/test_aware.py,sha256=kxYoMG2m02kbMlxtFOM-MWJO8qqxHviP9HgAMbKRfvU,2304
6
+ skylos/server.py,sha256=oHuevjdDFvJVbvpTtCDjSLOJ6Zy1jL4BYLYV4VFNMXs,15903
9
7
  skylos/visitor.py,sha256=o_2JxjXKXAcWLQr8nmoatGAz2EhBk225qE_piHf3hDg,20458
8
+ skylos/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ skylos/rules/secrets.py,sha256=FD7cXZ4_Zfg_Si1qFXLVK-5OIX1HXtCr_yJM0OlRsbI,9425
10
+ skylos/visitors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ skylos/visitors/framework_aware.py,sha256=eX4oU0jQwDWejkkW4kjRNctre27sVLHK1CTDDiqPqRw,13054
12
+ skylos/visitors/test_aware.py,sha256=kxYoMG2m02kbMlxtFOM-MWJO8qqxHviP9HgAMbKRfvU,2304
10
13
  test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
14
  test/compare_tools.py,sha256=0g9PDeJlbst-7hOaQzrL4MiJFQKpqM8q8VeBGzpPczg,22738
12
15
  test/conftest.py,sha256=57sTF6vLL5U0CVKwGQFJcRs6n7t1dEHIriQoSluNmAI,6418
@@ -19,6 +22,7 @@ test/test_constants.py,sha256=pMuDy0UpC81zENMDCeK6Bqmm3BR_HHZQSlMG-9TgOm0,12602
19
22
  test/test_framework_aware.py,sha256=G9va1dEQ31wsvd4X7ROf_1YhhAQG5CogB7v0hYCojQ8,8802
20
23
  test/test_integration.py,sha256=bNKGUe-w0xEZEdnoQNHbssvKMGs9u9fmFQTOz1lX9_k,12398
21
24
  test/test_new_behaviours.py,sha256=vAON8rR09RXawZT1knYtZHseyRcECKz5bdOspJoMjUA,1472
25
+ test/test_secrets.py,sha256=rjC-iQD9s8U296PyHP0vMEjC6CC9mJwKiFj7Sm-xsuo,5711
22
26
  test/test_skylos.py,sha256=kz77STrS4k3Eez5RDYwGxOg2WH3e7zNZPUYEaTLbGTs,15608
23
27
  test/test_test_aware.py,sha256=VmbR_MQY0m941CAxxux8OxJHIr7l8crfWRouSeBMhIo,9390
24
28
  test/test_visitor.py,sha256=xAbGv-XaozKm_0WJJhr0hMb6mLaJcbPz57G9-SWkxFU,22764
@@ -29,8 +33,8 @@ test/sample_repo/sample_repo/commands.py,sha256=b6gQ9YDabt2yyfqGbOpLo0osF7wya8O4
29
33
  test/sample_repo/sample_repo/models.py,sha256=xXIg3pToEZwKuUCmKX2vTlCF_VeFA0yZlvlBVPIy5Qw,3320
30
34
  test/sample_repo/sample_repo/routes.py,sha256=8yITrt55BwS01G7nWdESdx8LuxmReqop1zrGUKPeLi8,2475
31
35
  test/sample_repo/sample_repo/utils.py,sha256=S56hEYh8wkzwsD260MvQcmUFOkw2EjFU27nMLFE6G2k,1103
32
- skylos-2.1.2.dist-info/METADATA,sha256=8oDAO7pv-fV0lB3JFRiX_SdqOpUJSa-P_ecR71MyLC8,314
33
- skylos-2.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
34
- skylos-2.1.2.dist-info/entry_points.txt,sha256=zzRpN2ByznlQoLeuLolS_TFNYSQxUGBL1EXQsAd6bIA,43
35
- skylos-2.1.2.dist-info/top_level.txt,sha256=f8GA_7KwfaEopPMP8-EXDQXaqd4IbsOQPakZy01LkdQ,12
36
- skylos-2.1.2.dist-info/RECORD,,
36
+ skylos-2.2.2.dist-info/METADATA,sha256=LfA3i21DFqm9HGGJLXEkSwkK1Adv-58LPnIAmwguvwQ,314
37
+ skylos-2.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
38
+ skylos-2.2.2.dist-info/entry_points.txt,sha256=zzRpN2ByznlQoLeuLolS_TFNYSQxUGBL1EXQsAd6bIA,43
39
+ skylos-2.2.2.dist-info/top_level.txt,sha256=f8GA_7KwfaEopPMP8-EXDQXaqd4IbsOQPakZy01LkdQ,12
40
+ skylos-2.2.2.dist-info/RECORD,,
test/test_secrets.py ADDED
@@ -0,0 +1,179 @@
1
+ import ast
2
+ import pytest
3
+ from skylos.rules.secrets import scan_ctx
4
+
5
+ ELLIPSIS = "…"
6
+
7
+ def _ctx_from_source(src, rel="app.py", with_ast=False):
8
+ if with_ast:
9
+ tree = ast.parse(src)
10
+ else:
11
+ tree = None
12
+
13
+ lines = src.splitlines(True)
14
+
15
+ context = {
16
+ "relpath": rel,
17
+ "lines": lines,
18
+ "tree": tree
19
+ }
20
+
21
+ return context
22
+
23
+ def test_github_and_generic_both_fire_on_token_assignment():
24
+ src = 'GITHUB_TOKEN = "ghp_1234567890abcdef1234567890abcdef1234"\n'
25
+
26
+ findings = list(scan_ctx(_ctx_from_source(src)))
27
+
28
+ providers = set()
29
+ for finding in findings:
30
+ provider_name = finding["provider"]
31
+ providers.add(provider_name)
32
+
33
+ assert "github" in providers
34
+ assert "generic" in providers
35
+
36
+ github_previews = []
37
+ for finding in findings:
38
+ if finding["provider"] == "github":
39
+ preview = finding["preview"]
40
+ github_previews.append(preview)
41
+
42
+ assert len(github_previews) > 0
43
+ first_preview = github_previews[0]
44
+ assert first_preview.startswith("ghp_")
45
+ assert ELLIPSIS in first_preview
46
+
47
+ @pytest.mark.parametrize(
48
+ "line,provider",
49
+ [
50
+ ('GITLAB_PAT = "glpat-A1b2C3d4E5f6G7h8I9j0"\n', "gitlab"),
51
+ ('SLACK_BOT = "xoxb-1234567890ABCDEF12"\n', "slack"),
52
+ ('STRIPE = "sk_live_a1B2c3D4e5F6g7H8"\n', "stripe"),
53
+ ('GOOGLE = "AIzaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"\n', "google_api_key"),
54
+ ('SENDGRID = "SG.AAAAABBBBBCCCCCC.DDDDDEEEEEFFFFFFF"\n', "sendgrid"),
55
+ ('TWILIO = "SK0123456789abcdef0123456789abcdef"\n', "twilio"),
56
+ ('PK = "-----BEGIN RSA PRIVATE KEY-----"\n', "private_key_block"),
57
+ ('AWS_ACCESS_KEY_ID = "AKIAABCDEFGHIJKLMNOP"\n', "aws_access_key_id"),
58
+ ],
59
+ )
60
+ def test_provider_patterns(line, provider):
61
+ findings = list(scan_ctx(_ctx_from_source(line)))
62
+ assert any(f["provider"] == provider for f in findings)
63
+
64
+
65
+ def test_aws_secret_access_key_special_case():
66
+ src = (
67
+ 'AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"\n'
68
+ )
69
+ findings = list(scan_ctx(_ctx_from_source(src)))
70
+ hit = None
71
+ for finding in findings:
72
+ if finding["provider"] == "aws_secret_access_key":
73
+ hit = finding
74
+ break
75
+
76
+ assert hit is not None
77
+ assert "entropy" in hit and isinstance(hit["entropy"], float)
78
+ assert ELLIPSIS in hit["preview"]
79
+
80
+
81
+ def test_generic_entropy_detection_and_threshold():
82
+ src = 'X = "o2uV7Ew1kZ9Q3nR8sT5yU6pX4cJ2mL7a"\n'
83
+ findings = list(scan_ctx(_ctx_from_source(src)))
84
+ assert any(f["provider"] == "generic" for f in findings)
85
+
86
+ findings_high_thr = list(scan_ctx(_ctx_from_source(src), min_entropy=8.0))
87
+ assert not any(f["provider"] == "generic" for f in findings_high_thr)
88
+
89
+
90
+ def test_ignore_directive_suppresses_matches():
91
+ src = 'GITHUB_TOKEN = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # skylos: ignore[SKY-S101]\n'
92
+ findings = list(scan_ctx(_ctx_from_source(src)))
93
+ assert findings == []
94
+
95
+
96
+ def test_allowlist_patterns_suppresses_line():
97
+ src = 'TWILIO = "SKabcdefabcdefabcdefabcdefabcdefabcd"\n'
98
+ allow = [r"TWILIO\s*="]
99
+ findings = list(scan_ctx(_ctx_from_source(src), allowlist_patterns=allow))
100
+ assert findings == []
101
+
102
+
103
+ def test_scan_comments_toggle():
104
+ line = '# cred: xoxb-1234567890ABCDEF12 appears only in comment\n'
105
+ findings_default = list(scan_ctx(_ctx_from_source(line)))
106
+
107
+ found_slack = False
108
+ for finding in findings_default:
109
+ if finding["provider"] == "slack":
110
+ found_slack = True
111
+ break
112
+
113
+ assert found_slack
114
+
115
+ findings_off = list(scan_ctx(_ctx_from_source(line), scan_comments=False))
116
+ assert findings_off == []
117
+
118
+
119
+ def test_scan_docstrings_toggle_with_ast():
120
+ src = '''"""
121
+ module docstring with a GITHUB token: ghp_1234567890abcdef1234567890abcdef1234
122
+ """
123
+ def f():
124
+ """Function docstring with AWS AKIAABCDEFGHIJKLMNOP key."""
125
+ return 1
126
+ '''
127
+ f1 = list(scan_ctx(_ctx_from_source(src, with_ast=True)))
128
+ providers_in_f1 = set()
129
+ for finding in f1:
130
+ provider_name = finding["provider"]
131
+ providers_in_f1.add(provider_name)
132
+ assert "github" in providers_in_f1 or "aws_access_key_id" in providers_in_f1
133
+
134
+ f2 = list(scan_ctx(_ctx_from_source(src, with_ast=True), scan_docstrings=False))
135
+ assert f2 == []
136
+
137
+
138
+ def test_suffix_and_path_filters():
139
+ ctx_txt = _ctx_from_source('X="ghp_1234567890abcdef1234567890abcdef1234"\n', rel="notes.txt")
140
+ assert list(scan_ctx(ctx_txt)) == []
141
+
142
+ ctx_vendor = _ctx_from_source('X="AKIAABCDEFGHIJKLMNOP"\n', rel="vendor/app.py")
143
+ out = list(scan_ctx(ctx_vendor, ignore_path_substrings=["vendor"]))
144
+ assert out == []
145
+
146
+
147
+ def test_masking_behavior_short_and_long():
148
+ short = 'X = "ABCDEFGH"\n'
149
+ long = 'token = "ABCDEFGHIJKLMNOPKLMN"\n'
150
+
151
+ short_findings = scan_ctx(_ctx_from_source(short))
152
+
153
+ f_short = None
154
+ for finding in short_findings:
155
+ if finding["provider"] == "generic":
156
+ f_short = finding
157
+ break
158
+
159
+ long_findings = scan_ctx(_ctx_from_source(long))
160
+
161
+ f_long = None
162
+ for finding in long_findings:
163
+ if finding["provider"] == "generic":
164
+ f_long = finding
165
+ break
166
+
167
+ assert f_short is None
168
+
169
+ long_preview = f_long["preview"]
170
+ starts_with_abcd = long_preview.startswith("ABCD")
171
+ ends_with_klmn = long_preview.endswith("KLMN")
172
+ contains_ellipsis = ELLIPSIS in long_preview
173
+
174
+ assert starts_with_abcd and ends_with_klmn and contains_ellipsis
175
+
176
+ def test_safe_hints_suppress_detection():
177
+ safe_line = 'EXAMPLE_TOKEN = "sk_test_this_is_example_value_not_real_123456"\n'
178
+ out = list(scan_ctx(_ctx_from_source(safe_line)))
179
+ assert out == []
File without changes
File without changes