skylos 2.1.2__py3-none-any.whl → 2.2.3__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.3"
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
@@ -1,6 +1,160 @@
1
1
  from __future__ import annotations
2
2
  import libcst as cst
3
3
  from libcst.metadata import PositionProvider
4
+ from libcst.helpers import get_full_name_for_node
5
+
6
+ class _CommentOutBlock(cst.CSTTransformer):
7
+
8
+ METADATA_DEPENDENCIES = (PositionProvider,)
9
+
10
+ def __init__(self, module_code, marker = "SKYLOS DEADCODE"):
11
+ self.module_code = module_code.splitlines(True)
12
+ self.marker = marker
13
+
14
+ def _comment_block(self, start_line, end_line):
15
+ lines = self.module_code[start_line - 1:end_line]
16
+ out = []
17
+ out.append(cst.EmptyLine(comment=cst.Comment(f"# {self.marker} START (lines {start_line}-{end_line})")))
18
+ for raw in lines:
19
+ out.append(cst.EmptyLine(comment=cst.Comment("# " + raw.rstrip("\n"))))
20
+ out.append(cst.EmptyLine(comment=cst.Comment(f"# {self.marker} END")))
21
+ return out
22
+
23
+ class _CommentOutFunctionAtLine(_CommentOutBlock):
24
+ def __init__(self, func_name, target_line, module_code, marker):
25
+ super().__init__(module_code, marker)
26
+ self.func_name = func_name
27
+ self.target_line = target_line
28
+ self.changed = False
29
+
30
+ def _is_target(self, node: cst.CSTNode):
31
+ pos = self.get_metadata(PositionProvider, node, None)
32
+ return pos and pos.start.line == self.target_line
33
+
34
+ def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
35
+ target = self.func_name.split(".")[-1]
36
+ if self._is_target(orig) and (orig.name.value == target):
37
+ self.changed = True
38
+ pos = self.get_metadata(PositionProvider, orig)
39
+ return cst.FlattenSentinel(self._comment_block(pos.start.line, pos.end.line))
40
+ return updated
41
+
42
+ def leave_AsyncFunctionDef(self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef):
43
+ target = self.func_name.split(".")[-1]
44
+ if self._is_target(orig) and (orig.name.value == target):
45
+ self.changed = True
46
+ pos = self.get_metadata(PositionProvider, orig)
47
+ return cst.FlattenSentinel(self._comment_block(pos.start.line, pos.end.line))
48
+ return updated
49
+
50
+ class _CommentOutImportAtLine(_CommentOutBlock):
51
+
52
+ def __init__(self, target_name, target_line, module_code, marker):
53
+ super().__init__(module_code, marker)
54
+ self.target_name = target_name
55
+ self.target_line = target_line
56
+ self.changed = False
57
+
58
+ def _is_target_line(self, node: cst.CSTNode):
59
+ pos = self.get_metadata(PositionProvider, node, None)
60
+ return bool(pos and (pos.start.line <= self.target_line <= pos.end.line))
61
+
62
+ def _render_single_alias_text(self, head, alias: cst.ImportAlias, is_from):
63
+ if is_from:
64
+ alias_txt = alias.name.code
65
+ if alias.asname:
66
+ alias_txt += f" as {alias.asname.name.value}"
67
+ return f"from {head} import {alias_txt}"
68
+ else:
69
+ alias_txt = alias.name.code
70
+ if alias.asname:
71
+ alias_txt += f" as {alias.asname.name.value}"
72
+ return f"import {alias_txt}"
73
+
74
+ def _split_aliases(self, aliases, head, is_from):
75
+ kept = []
76
+ removed_for_comment= []
77
+ for alias in list(aliases):
78
+ bound = _bound_name_for_import_alias(alias)
79
+ name_code = get_full_name_for_node(alias.name)
80
+ tail = name_code.split(".")[-1]
81
+ if self.target_name in (bound, tail):
82
+ self.changed = True
83
+ removed_for_comment.append(self._render_single_alias_text(head, alias, is_from))
84
+ else:
85
+ kept.append(alias)
86
+ return kept, removed_for_comment
87
+
88
+ def leave_Import(self, orig: cst.Import, updated: cst.Import):
89
+ if not self._is_target_line(orig):
90
+ return updated
91
+
92
+ head = ""
93
+ kept, removed = self._split_aliases(updated.names, head, is_from=False)
94
+
95
+ if not removed:
96
+ return updated
97
+
98
+ pos = self.get_metadata(PositionProvider, orig)
99
+ if not kept:
100
+ return cst.FlattenSentinel(self._comment_block(pos.start.line, pos.end.line))
101
+
102
+ commented = []
103
+ for txt in removed:
104
+ comment = cst.Comment(f"# {self.marker}: {txt}")
105
+ commented.append(cst.EmptyLine(comment=comment))
106
+
107
+ kept_import = updated.with_changes(names=tuple(kept))
108
+ all_nodes = [kept_import] + commented
109
+ return cst.FlattenSentinel(all_nodes)
110
+
111
+ def leave_ImportFrom(self, orig: cst.ImportFrom, updated: cst.ImportFrom):
112
+ if not self._is_target_line(orig) or isinstance(updated.names, cst.ImportStar):
113
+ return updated
114
+
115
+ if updated.relative:
116
+ dots = "." * len(updated.relative)
117
+ else:
118
+ dots = ""
119
+
120
+ if updated.module is not None:
121
+ modname = updated.module.code
122
+ else:
123
+ modname = ""
124
+
125
+ mod = f"{dots}{modname}"
126
+
127
+ kept, removed = self._split_aliases(list(updated.names), mod, is_from=True)
128
+
129
+ if not removed:
130
+ return updated
131
+ pos = self.get_metadata(PositionProvider, orig)
132
+
133
+ if not kept:
134
+ comment_block = self._comment_block(pos.start.line, pos.end.line)
135
+ return cst.FlattenSentinel(comment_block)
136
+
137
+ commented = []
138
+ for txt in removed:
139
+ comment = cst.Comment(f"# {self.marker}: {txt}")
140
+ commented.append(cst.EmptyLine(comment=comment))
141
+
142
+ updated_import = updated.with_changes(names=tuple(kept))
143
+ all_nodes = [updated_import] + commented
144
+
145
+ return cst.FlattenSentinel(all_nodes)
146
+
147
+ def comment_out_unused_function_cst(code, func_name, line_number, marker = "SKYLOS DEADCODE"):
148
+ wrapper = cst.MetadataWrapper(cst.parse_module(code))
149
+ tx = _CommentOutFunctionAtLine(func_name, line_number, code, marker)
150
+ new_mod = wrapper.visit(tx)
151
+ return new_mod.code, tx.changed
152
+
153
+ def comment_out_unused_import_cst(code, import_name, line_number, marker = "SKYLOS DEADCODE"):
154
+ wrapper = cst.MetadataWrapper(cst.parse_module(code))
155
+ tx = _CommentOutImportAtLine(import_name, line_number, code, marker)
156
+ new_mod = wrapper.visit(tx)
157
+ return new_mod.code, tx.changed
4
158
 
5
159
  def _bound_name_for_import_alias(alias: cst.ImportAlias):
6
160
  if alias.asname:
@@ -20,13 +174,15 @@ class _RemoveImportAtLine(cst.CSTTransformer):
20
174
 
21
175
  def _is_target_line(self, node: cst.CSTNode):
22
176
  pos = self.get_metadata(PositionProvider, node, None)
23
- return bool(pos and pos.start.line == self.target_line)
24
-
177
+ return bool(pos and (pos.start.line <= self.target_line <= pos.end.line))
178
+
25
179
  def _filter_aliases(self, aliases):
26
180
  kept = []
27
181
  for alias in aliases:
28
182
  bound = _bound_name_for_import_alias(alias)
29
- if bound == self.target_name:
183
+ name_code = get_full_name_for_node(alias.name) or ""
184
+ tail = name_code.split(".")[-1]
185
+ if self.target_name in (bound, tail):
30
186
  self.changed = True
31
187
  continue
32
188
  kept.append(alias)
@@ -61,16 +217,18 @@ class _RemoveFunctionAtLine(cst.CSTTransformer):
61
217
 
62
218
  def _is_target(self, node: cst.CSTNode):
63
219
  pos = self.get_metadata(PositionProvider, node, None)
64
- return bool(pos and pos.start.line == self.target_line)
220
+ return pos and pos.start.line == self.target_line
65
221
 
66
222
  def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
67
- if self._is_target(orig) and (orig.name.value in self.func_name):
223
+ target = self.func_name.split(".")[-1]
224
+ if self._is_target(orig) and (orig.name.value == target):
68
225
  self.changed = True
69
226
  return cst.RemoveFromParent()
70
227
  return updated
71
228
 
72
229
  def leave_AsyncFunctionDef(self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef):
73
- if self._is_target(orig) and (orig.name.value in self.func_name):
230
+ target = self.func_name.split(".")[-1]
231
+ if self._is_target(orig) and (orig.name.value == target):
74
232
  self.changed = True
75
233
  return cst.RemoveFromParent()
76
234
 
File without changes
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+ import re, ast
3
+ from math import log2
4
+
5
+ __all__ = ["scan_ctx"]
6
+
7
+ ALLOWED_FILE_SUFFIXES = (".py", ".pyi", ".pyw")
8
+
9
+ PROVIDER_PATTERNS = [
10
+ ("github", re.compile(r"(ghp|gho|ghu|ghs|ghr|gpat)_[A-Za-z0-9]{36,}")),
11
+ ("gitlab", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
12
+ ("slack", re.compile(r"xox[abprs]-[A-Za-z0-9-]{10,48}")),
13
+ ("stripe", re.compile(r"sk_(live|test)_[A-Za-z0-9]{16,}")),
14
+ ("aws_access_key_id", re.compile(r"\b(AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[0-9A-Z]{16}\b")),
15
+ ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
16
+ ("sendgrid", re.compile(r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b")),
17
+ ("twilio", re.compile(r"\bSK[0-9a-fA-F]{32}\b")),
18
+ ("private_key_block", re.compile(r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY-----")),
19
+ ]
20
+
21
+ GENERIC_VALUE = re.compile(r"""(?ix)
22
+ (?:
23
+ (token|api[_-]?key|secret|password|passwd|pwd|bearer|auth[_-]?token|access[_-]?token)
24
+ \s*[:=]\s*(?P<q>['"])(?P<val>[^'"]{16,})(?P=q)
25
+ )|(?P<bare>[A-Za-z0-9_\-]{24,})
26
+ """)
27
+
28
+ SAFE_TEST_HINTS = {
29
+ "example", "sample", "fake", "placeholder", "dummy", "test_", "_test", "test_test_",
30
+ "changeme", "password", "secret", "not_a_real", "do_not_use",
31
+ }
32
+
33
+ IGNORE_DIRECTIVE = "skylos: ignore[SKY-S101]"
34
+ DEFAULT_MIN_ENTROPY = 3.6
35
+
36
+ def _entropy(s):
37
+ if len(s) == 0:
38
+ return 0.0
39
+
40
+ char_counts = {}
41
+ for character in s:
42
+ if character in char_counts:
43
+ char_counts[character] += 1
44
+ else:
45
+ char_counts[character] = 1
46
+
47
+ total_chars = len(s)
48
+ entropy = 0.0
49
+
50
+ for count in char_counts.values():
51
+ probability = count / total_chars
52
+ entropy -= probability * log2(probability)
53
+
54
+ return entropy
55
+
56
+ def _mask(tok):
57
+ token_length = len(tok)
58
+
59
+ if token_length <= 8:
60
+ return "*" * token_length
61
+
62
+ else:
63
+ first_part = tok[:4]
64
+ last_part = tok[-4:]
65
+ return first_part + "…" + last_part
66
+
67
+ def _docstring_lines(tree):
68
+ if tree is None:
69
+ return set()
70
+
71
+ docstring_line_numbers = set()
72
+
73
+ def find_docstring_lines(node):
74
+ if not hasattr(node, "body") or not node.body:
75
+ return
76
+
77
+ first_statement = node.body[0]
78
+
79
+ is_expression = isinstance(first_statement, ast.Expr)
80
+ if not is_expression:
81
+ return
82
+
83
+ value = getattr(first_statement, "value", None)
84
+ if not isinstance(value, ast.Constant):
85
+ return
86
+
87
+ if not isinstance(value.value, str):
88
+ return
89
+
90
+ start_line = getattr(first_statement, "lineno", None)
91
+ end_line = getattr(first_statement, "end_lineno", start_line)
92
+
93
+ if start_line is not None:
94
+ if end_line is None:
95
+ end_line = start_line
96
+
97
+ for line_num in range(start_line, end_line + 1):
98
+ docstring_line_numbers.add(line_num)
99
+
100
+ if isinstance(tree, ast.Module):
101
+ find_docstring_lines(tree)
102
+
103
+ for node in ast.walk(tree):
104
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
105
+ find_docstring_lines(node)
106
+
107
+ return docstring_line_numbers
108
+
109
+ def scan_ctx(ctx, *, min_entropy= DEFAULT_MIN_ENTROPY, scan_comments= True,
110
+ scan_docstrings= True, allowlist_patterns= None, ignore_path_substrings= None):
111
+
112
+ rel_path = ctx.get("relpath", "")
113
+ if not rel_path.endswith(ALLOWED_FILE_SUFFIXES):
114
+ return []
115
+
116
+ if ignore_path_substrings:
117
+ for substring in ignore_path_substrings:
118
+ if substring and substring in rel_path:
119
+ return []
120
+
121
+ file_lines = ctx.get("lines") or []
122
+ syntax_tree = ctx.get("tree")
123
+
124
+ allowlist_regexes = []
125
+ if allowlist_patterns:
126
+ for pattern in allowlist_patterns:
127
+ compiled_regex = re.compile(pattern)
128
+ allowlist_regexes.append(compiled_regex)
129
+
130
+ if scan_docstrings:
131
+ docstring_lines = set()
132
+ else:
133
+ docstring_lines = _docstring_lines(syntax_tree)
134
+
135
+ findings = []
136
+
137
+ for line_number, raw_line in enumerate(file_lines, start=1):
138
+ line_content = raw_line.rstrip("\n")
139
+
140
+ if IGNORE_DIRECTIVE in line_content:
141
+ continue
142
+
143
+ stripped_line = line_content.lstrip()
144
+ if not scan_comments and stripped_line.startswith("#"):
145
+ continue
146
+
147
+ if not scan_docstrings and line_number in docstring_lines:
148
+ continue
149
+
150
+ should_skip_line = False
151
+ for regex_pattern in allowlist_regexes:
152
+ if regex_pattern.search(line_content):
153
+ should_skip_line = True
154
+ break
155
+
156
+ if should_skip_line:
157
+ continue
158
+
159
+ for provider_name, pattern_regex in PROVIDER_PATTERNS:
160
+ pattern_matches = pattern_regex.finditer(line_content)
161
+
162
+ for regex_match in pattern_matches:
163
+ potential_secret = regex_match.group(0)
164
+
165
+ token_lowercase = potential_secret.lower()
166
+ has_safe_hint = False
167
+
168
+ for safe_hint in SAFE_TEST_HINTS:
169
+ if safe_hint in token_lowercase:
170
+ has_safe_hint = True
171
+ break
172
+
173
+ if has_safe_hint:
174
+ continue
175
+
176
+ col_pos = line_content.find(potential_secret)
177
+
178
+ finding = {
179
+ "rule_id": "SKY-S101",
180
+ "severity": "CRITICAL",
181
+ "provider": provider_name,
182
+ "message": f"Potential {provider_name} secret detected",
183
+ "file": rel_path,
184
+ "line": line_number,
185
+ "col": max(0, col_pos),
186
+ "end_col": max(1, col_pos + len(potential_secret)),
187
+ "preview": _mask(potential_secret),
188
+ }
189
+ findings.append(finding)
190
+
191
+ aws_key_indicators = ["AWS_SECRET_ACCESS_KEY", "aws_secret_access_key"]
192
+ line_has_aws_key = False
193
+
194
+ for indicator in aws_key_indicators:
195
+ if indicator in line_content or indicator in line_content.lower():
196
+ line_has_aws_key = True
197
+ break
198
+
199
+ if line_has_aws_key:
200
+ aws_secret_pattern = r"['\"]?([A-Za-z0-9/+=]{40})['\"]?"
201
+ aws_match = re.search(aws_secret_pattern, line_content)
202
+
203
+ if aws_match:
204
+ aws_token = aws_match.group(1)
205
+ tok_entropy = _entropy(aws_token)
206
+
207
+ if tok_entropy >= min_entropy:
208
+ col_pos = line_content.find(aws_token)
209
+
210
+ aws_finding = {
211
+ "rule_id": "SKY-S101",
212
+ "severity": "CRITICAL",
213
+ "provider": "aws_secret_access_key",
214
+ "message": "Potential AWS secret access key detected",
215
+ "file": rel_path,
216
+ "line": line_number,
217
+ "col": max(0, col_pos),
218
+ "end_col": max(1, col_pos + len(aws_token)),
219
+ "preview": _mask(aws_token),
220
+ "entropy": round(tok_entropy, 2),
221
+ }
222
+ findings.append(aws_finding)
223
+
224
+ generic_match = GENERIC_VALUE.search(line_content)
225
+ if generic_match:
226
+ val_group = generic_match.group("val")
227
+ bare_group = generic_match.group("bare")
228
+
229
+ if val_group:
230
+ extracted_token = val_group
231
+ elif bare_group:
232
+ extracted_token = bare_group
233
+ else:
234
+ extracted_token = ""
235
+
236
+ clean_token = extracted_token.strip()
237
+
238
+ if clean_token:
239
+ token_lowercase = clean_token.lower()
240
+ has_safe_hint = False
241
+
242
+ for safe_hint in SAFE_TEST_HINTS:
243
+ if safe_hint in token_lowercase:
244
+ has_safe_hint = True
245
+ break
246
+
247
+ if not has_safe_hint:
248
+ tok_entropy = _entropy(clean_token)
249
+
250
+ if tok_entropy >= min_entropy and len(clean_token) >= 20:
251
+ col_pos = line_content.find(clean_token)
252
+
253
+ generic_finding = {
254
+ "rule_id": "SKY-S101",
255
+ "severity": "CRITICAL",
256
+ "provider": "generic",
257
+ "message": f"High-entropy value detected (entropy={tok_entropy:.2f})",
258
+ "file": rel_path,
259
+ "line": line_number,
260
+ "col": max(0, col_pos),
261
+ "end_col": max(1, col_pos + len(clean_token)),
262
+ "preview": _mask(clean_token),
263
+ "entropy": round(tok_entropy, 2),
264
+ }
265
+ findings.append(generic_finding)
266
+
267
+ 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.3
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=cFxP_uUY-acB9iRs7SE3ccyWiX6v8mI-mJo9bfhC2aU,229
2
+ skylos/analyzer.py,sha256=G_8pw7GmChATc5h5XXij2pcHirhh_5G9Y8dlAC1dx38,16735
3
+ skylos/cli.py,sha256=DOV6nwPbi5zh-OJ3wXjIaPxCfXRtiPMNqo9Zp2nvDBA,19475
4
+ skylos/codemods.py,sha256=-1ehjFLc1MmtK3inoSZF_RyBNWF7XZSOq2KH5_MDzuA,9519
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=_cvi-NETScQWMPexb8R2_QtgTRMQGbb09BT1Os246DA,9370
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.3.dist-info/METADATA,sha256=ug19tHc6winuwwnlmWqv87j18KfrnAKs14KvFRA-ivo,314
37
+ skylos-2.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
38
+ skylos-2.2.3.dist-info/entry_points.txt,sha256=zzRpN2ByznlQoLeuLolS_TFNYSQxUGBL1EXQsAd6bIA,43
39
+ skylos-2.2.3.dist-info/top_level.txt,sha256=f8GA_7KwfaEopPMP8-EXDQXaqd4IbsOQPakZy01LkdQ,12
40
+ skylos-2.2.3.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