deepsecrets 2.0.0__tar.gz → 2.1.0__tar.gz

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.
Files changed (87) hide show
  1. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/PKG-INFO +5 -6
  2. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/cli.py +15 -4
  3. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/config.py +6 -1
  4. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/hashed_secret.py +1 -2
  5. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/variable_evaluator.py +9 -29
  6. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/file.py +38 -14
  7. deepsecrets-2.1.0/deepsecrets/core/model/internal/processing.py +36 -0
  8. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/response/base.py +11 -0
  9. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/response/builtin.py +10 -1
  10. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/response/dojo_sarif.py +65 -10
  11. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/regex.py +4 -5
  12. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/variable_scoring.py +32 -3
  13. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/token.py +8 -7
  14. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/modes/iscan_mode.py +155 -39
  15. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/hashed_secrets.py +2 -1
  16. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/deep_analyzer.py +20 -2
  17. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/var_detection/detector.py +2 -2
  18. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/single_token_improver.py +16 -9
  19. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/fs.py +10 -1
  20. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/guess_filetype.py +14 -1
  21. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/lifecycle_hooks.py +16 -4
  22. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/log.py +15 -8
  23. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/progress.py +3 -1
  24. deepsecrets-2.1.0/deepsecrets/diagnostics/__init__.py +5 -0
  25. deepsecrets-2.1.0/deepsecrets/diagnostics/__main__.py +62 -0
  26. deepsecrets-2.1.0/deepsecrets/diagnostics/trace.py +405 -0
  27. deepsecrets-2.1.0/deepsecrets/diagnostics/worker.py +39 -0
  28. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/variable_scoring_rules.json +35 -12
  29. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/scan_modes/cli.py +18 -19
  30. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/pyproject.toml +9 -8
  31. deepsecrets-2.0.0/deepsecrets/core/model/internal/processing.py +0 -16
  32. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/LICENSE +0 -0
  33. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/README.md +0 -0
  34. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/__init__.py +0 -0
  35. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/__main__.py +0 -0
  36. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/__init__.py +0 -0
  37. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/iengine.py +0 -0
  38. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/regex.py +0 -0
  39. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/semantic.py +0 -0
  40. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/content_analyzer.py +0 -0
  41. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/entropy.py +0 -0
  42. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/naturalness_scorer.py +0 -0
  43. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/__init__.py +0 -0
  44. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/finding.py +0 -0
  45. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/__init__.py +0 -0
  46. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/exlcuded_path.py +0 -0
  47. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/false_finding.py +0 -0
  48. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/hashed_secret.py +0 -0
  49. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/hashing.py +0 -0
  50. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/rule.py +0 -0
  51. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/semantic.py +0 -0
  52. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/tokenized_region.py +0 -0
  53. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/excluded_paths.py +0 -0
  54. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/false_findings.py +0 -0
  55. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/ibuilder.py +0 -0
  56. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/regex.py +0 -0
  57. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/variable_scoring.py +0 -0
  58. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/__init__.py +0 -0
  59. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/cheap_var_search.py +0 -0
  60. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/full_content.py +0 -0
  61. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/__init__.py +0 -0
  62. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/cheap_var_search/__init__.py +0 -0
  63. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/__init__.py +0 -0
  64. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/language.py +0 -0
  65. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/var_detection/__init__.py +0 -0
  66. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/var_detection/rules.py +0 -0
  67. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/subfile_regions_helper.py +0 -0
  68. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/type_stream.py +0 -0
  69. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/itokenizer.py +0 -0
  70. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/lexer.py +0 -0
  71. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/per_line.py +0 -0
  72. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/per_word.py +0 -0
  73. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/ui/floating_header.py +0 -0
  74. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/ui/progress_bar.py +0 -0
  75. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/ui/time_remaining_column.py +0 -0
  76. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/__init__.py +0 -0
  77. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/cpu.py +0 -0
  78. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/exceptions.py +0 -0
  79. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/file_analyzer.py +0 -0
  80. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/finding_merger.py +0 -0
  81. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/hashing.py +0 -0
  82. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/lexer_finder.py +0 -0
  83. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/string.py +0 -0
  84. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/excluded_paths.json +0 -0
  85. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/regexes.json +0 -0
  86. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/value_scoring_model.json +0 -0
  87. {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepsecrets
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: A better tool for secrets scanning
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -16,16 +16,15 @@ Classifier: Operating System :: OS Independent
16
16
  Classifier: Environment :: Console
17
17
  Classifier: Topic :: Security
18
18
  Requires-Dist: aenum (==3.1.17)
19
- Requires-Dist: dotwiz (==0.4.0)
20
19
  Requires-Dist: jschema-to-python (==1.2.3)
21
20
  Requires-Dist: jsx-lexer (==2.0.1)
22
- Requires-Dist: mmh3 (==5.2.1)
21
+ Requires-Dist: mmh3 (==5.3.0)
23
22
  Requires-Dist: ordered-set (==4.1.0)
24
23
  Requires-Dist: puppetparser (==0.2.14)
25
- Requires-Dist: pydantic (==2.13.4)
26
- Requires-Dist: pygments (==2.20.0)
24
+ Requires-Dist: pydantic (==2.13.5)
25
+ Requires-Dist: pygments (==2.21.0)
27
26
  Requires-Dist: pyyaml (==6.0.3)
28
- Requires-Dist: regex (==2026.5.9)
27
+ Requires-Dist: regex (==2026.9.3)
29
28
  Requires-Dist: rich (==15.0.0)
30
29
  Requires-Dist: sarif-om (==1.0.4)
31
30
  Project-URL: Bug Tracker, https://github.com/ntoskernel/deepsecrets/issues
@@ -7,6 +7,7 @@ from jschema_to_python.to_json import to_json
7
7
 
8
8
  from deepsecrets import MODULE_NAME, console
9
9
  from deepsecrets.config import SCANNER_VERSION, SCANNER_VERSION_NUMERIC, Config, config, Output
10
+ from deepsecrets.core.engines.hashed_secret import HashedSecretEngine
10
11
  from deepsecrets.core.engines.regex import RegexEngine
11
12
  from deepsecrets.core.engines.semantic import SemanticEngine
12
13
  from deepsecrets.core.model.finding import Finding
@@ -217,6 +218,13 @@ class DeepSecretsCliTool:
217
218
  'Use this flag if you want to render found secrets in plaintext but be extremely careful.',
218
219
  )
219
220
 
221
+ parser.add_argument(
222
+ '--report-diagnostics',
223
+ action='store_true',
224
+ help='Add scan diagnostics to a SARIF report: every file found under the target directory in\n'
225
+ 'run.artifacts (scan time in ms, status, skip reason) and per-file errors in run.invocations.\n',
226
+ )
227
+
220
228
  parser.add_argument('--benchmarking-mode', help=argparse.SUPPRESS, action='store_true')
221
229
  parser.add_argument('--oneshot', help=argparse.SUPPRESS, type=str, default=None)
222
230
 
@@ -232,6 +240,9 @@ class DeepSecretsCliTool:
232
240
  if user_args.disable_masking:
233
241
  config.set_disable_masking(True)
234
242
 
243
+ # set on every parse: the config singleton outlives one run
244
+ config.set_report_diagnostics(user_args.report_diagnostics)
245
+
235
246
  if user_args.benchmarking_mode:
236
247
  config._set_benchmarking_mode(True)
237
248
 
@@ -274,7 +285,7 @@ class DeepSecretsCliTool:
274
285
 
275
286
  conf_hashed_ruleset = user_args.hashed_values
276
287
  if conf_hashed_ruleset is not None and conf_hashed_ruleset != DISABLED:
277
- config.engines.append(RegexEngine)
288
+ config.engines.append(HashedSecretEngine)
278
289
  config.add_ruleset(HashedSecretsRulesetBuilder, conf_hashed_ruleset)
279
290
 
280
291
  conf_false_findings_ruleset = user_args.false_findings
@@ -297,11 +308,11 @@ class DeepSecretsCliTool:
297
308
 
298
309
  if config.output.type == 'json':
299
310
  console.print('\n')
300
- if SCANNER_VERSION_NUMERIC[0] == 2 and SCANNER_VERSION_NUMERIC[1] < 1:
311
+ if SCANNER_VERSION_NUMERIC[0] == 2 and SCANNER_VERSION_NUMERIC[1] < 2:
301
312
  console.print(
302
313
  Align(
303
314
  Panel(
304
- "The internal JSON report format is now DEPRECATED and will be removed in release 2.1.0\n\nConsider switching now.",
315
+ "The internal JSON report format is now DEPRECATED and will be removed in release 2.2.0\n\nConsider switching now.",
305
316
  padding=(1, 2),
306
317
  title=Text('SARIF IS NOW DEFAULT OUTPUT FORMAT', style='reverse'),
307
318
  highlight=True,
@@ -321,7 +332,7 @@ class DeepSecretsCliTool:
321
332
  console.print(
322
333
  Align(
323
334
  Panel(
324
- f"The internal JSON report format was DEPRECATED since the release 2.0.0.\nNow ({SCANNER_VERSION}) it is REMOVED. Switch to SARIF\n.",
335
+ f"The internal JSON report format was DEPRECATED since the release 2.2.0.\nNow ({SCANNER_VERSION}) it is REMOVED. Switch to SARIF\n.",
325
336
  padding=(1, 2),
326
337
  title=Text('SARIF IS NOW DEFAULT OUTPUT FORMAT', style='reverse'),
327
338
  highlight=True,
@@ -11,7 +11,7 @@ from deepsecrets.core.utils.fs import get_abspath, path_exists
11
11
  FALLBACK_PROCESS_COUNT = 4
12
12
 
13
13
  SCANNER_NAME = "DeepSecrets"
14
- SCANNER_VERSION = "2.0.0"
14
+ SCANNER_VERSION = "2.1.0"
15
15
  SCANNER_VERSION_NUMERIC = [int(subver) for subver in SCANNER_VERSION.split('.')]
16
16
  SCANNER_URL = "https://github.com/ntoskernel/deepsecrets"
17
17
 
@@ -36,6 +36,7 @@ class Config:
36
36
  process_count: int
37
37
  return_code_if_findings: bool
38
38
  disable_masking: bool
39
+ report_diagnostics: bool = False
39
40
  verbose: bool = False
40
41
 
41
42
  _benchmarking_mode: bool
@@ -46,6 +47,7 @@ class Config:
46
47
  self.global_exclusion_paths = []
47
48
  self.return_code_if_findings = False
48
49
  self.disable_masking = False
50
+ self.report_diagnostics = False
49
51
 
50
52
  self._benchmarking_mode = False
51
53
  self.oneshot_path = None
@@ -66,6 +68,9 @@ class Config:
66
68
  def set_disable_masking(self, state: bool):
67
69
  self.disable_masking = state
68
70
 
71
+ def set_report_diagnostics(self, state: bool):
72
+ self.report_diagnostics = state
73
+
69
74
  def _set_path(self, path: str, field: str) -> None:
70
75
  if not path_exists(path):
71
76
  raise FileNotFoundException(f'{field} path does not exist ({path})')
@@ -17,7 +17,6 @@ class HashedSecretEngine(IEngine):
17
17
  if not self.is_rule_applicable(token=token, rule=rule):
18
18
  continue
19
19
 
20
- token.calculate_hashed_value(rule.algorithm)
21
20
  results.extend(self._check_rule(token, rule))
22
21
 
23
22
  return results
@@ -30,7 +29,7 @@ class HashedSecretEngine(IEngine):
30
29
  def _check_rule(self, token: Token, rule: HashedSecretRule) -> List[Finding]:
31
30
  findings: List[Finding] = []
32
31
 
33
- if token.hashed_value != rule.hashed_val:
32
+ if token.calculate_hashed_value(rule.algorithm) != rule.hashed_val:
34
33
  return findings
35
34
 
36
35
  findings.append(
@@ -103,32 +103,12 @@ class VariableEvaluator:
103
103
  return result
104
104
 
105
105
  def confidence_from_evaluation_result(self, result: EvaluationResult):
106
- entropy_part = 0
107
- var_part = result.naming_and_content_score / 25 * 10
108
-
109
- if result.entropy_score > 0:
110
- if result.naming_and_content_score <= 20:
111
- # var_naming: 0 -> 5
112
- # entropy: 0 -> 5
113
- var_part = result.naming_and_content_score / 25 * 5
114
- entropy_part = result.entropy_score / 40 * 5
115
- else:
116
- var_part = 5
117
- entropy_part = result.entropy_score / 40 * 5
118
-
119
- entropy_part = entropy_part * min(result.nonsence_value_score + 0.5, 1)
120
- # ep non
121
-
122
- if entropy_part > 5:
123
- entropy_part = 5
124
-
125
- if entropy_part < 0:
126
- entropy_part = 0
127
-
128
- if var_part > 10:
129
- var_part = 10
130
-
131
- if var_part < 0:
132
- var_part = 0
133
-
134
- return round(var_part + entropy_part)
106
+ # Monotonic in every input: more naming evidence, more entropy or a less natural value never lowers it.
107
+ # Naming: 0.2 per point up to 20, then 0.6 per point up to 25, so a strong name alone reaches 7 (HIGH)
108
+ # and needs a random-looking value to reach VERY-HIGH. Value: entropy score 0..40 -> 0..5, halved for
109
+ # natural-looking values. See docs/research/variable-scoring-balance.md.
110
+ naming = min(max(result.naming_and_content_score, 0), 25)
111
+ var_part = 0.2 * min(naming, 20) + 0.6 * max(naming - 20, 0)
112
+ entropy_part = min(max(result.entropy_score, 0), 40) / 40 * 5 * min(result.nonsence_value_score + 0.5, 1)
113
+
114
+ return round(min(var_part + entropy_part, 10))
@@ -1,3 +1,4 @@
1
+ import bisect
1
2
  import regex as re
2
3
  from typing import Dict, List, Optional, Tuple
3
4
 
@@ -16,6 +17,9 @@ class File:
16
17
  name: str
17
18
  extension: Optional[str]
18
19
 
20
+ # (line_offsets it was built from, line numbers, running maximum of line ends); rebuilt on demand
21
+ _line_index: Optional[Tuple[Dict, List[int], List[int]]] = None
22
+
19
23
  def __init__(
20
24
  self,
21
25
  path: str,
@@ -62,7 +66,8 @@ class File:
62
66
  if self.path is None:
63
67
  return None
64
68
 
65
- by_dot = self.path.split('.')
69
+ # split the file name only: a dot in a parent directory is not an extension
70
+ by_dot = self.path.split('/')[-1].split('.')
66
71
  if len(by_dot) == 1:
67
72
  return None
68
73
 
@@ -92,11 +97,30 @@ class File:
92
97
  return self._get_line_number_for_position(position=position)
93
98
 
94
99
  def _get_line_number_for_position(self, position: int) -> Optional[int]:
95
- for linum, offsets in self.line_offsets.items():
96
- if offsets[1] < position:
97
- continue
98
- return linum
99
- return None
100
+ # The first line (in line_offsets order) whose end is at or after the position. Bisecting a running
101
+ # maximum of the line ends finds exactly that line, even where the ends are not ascending (KI-DM-03).
102
+ line_numbers, max_ends = self._get_line_index()
103
+ index = bisect.bisect_left(max_ends, position)
104
+ if index == len(max_ends):
105
+ return None
106
+ return line_numbers[index]
107
+
108
+ def _get_line_index(self) -> Tuple[List[int], List[int]]:
109
+ index = self._line_index
110
+ if index is None or index[0] is not self.line_offsets or len(index[1]) != len(self.line_offsets):
111
+ line_numbers = list(self.line_offsets.keys())
112
+ max_ends: List[int] = []
113
+ for linum in line_numbers:
114
+ end = self.line_offsets[linum][1]
115
+ max_ends.append(end if not max_ends or end > max_ends[-1] else max_ends[-1])
116
+ self._line_index = (self.line_offsets, line_numbers, max_ends)
117
+ return self._line_index[1], self._line_index[2]
118
+
119
+ def __getstate__(self) -> Dict:
120
+ # Files travel back from workers with every finding; the index is cheap to rebuild
121
+ state = self.__dict__.copy()
122
+ state.pop('_line_index', None)
123
+ return state
100
124
 
101
125
  def get_line_contents(self, line_number: int) -> Optional[str]:
102
126
  if line_number is None:
@@ -125,15 +149,15 @@ class File:
125
149
  if between[1] > self.length:
126
150
  between[1] = self.length
127
151
 
128
- search_window = self.content[between[0] : between[1]]
152
+ # The needle is a literal, so a substring search returns the same first occurrence inside the window
153
+ # as the escaped regex this replaced, without compiling one pattern per token.
154
+ if not str:
155
+ return (between[0], between[0])
129
156
 
130
- pattern = re.escape(str)
131
- pattern = pattern.replace('\\\n', '\n').replace('\\\t', '\t')
132
- detects = re.finditer(pattern, search_window)
133
- for detect in detects:
134
- span = detect.span()
135
- return (between[0] + span[0], between[0] + span[1])
136
- return None
157
+ start = self.content.find(str, between[0], between[1])
158
+ if start == -1:
159
+ return None
160
+ return (start, start + len(str))
137
161
 
138
162
  def get_column_number(self, position: int) -> int:
139
163
  line_number = self.get_line_number(position=position)
@@ -0,0 +1,36 @@
1
+ from dataclasses import field, dataclass
2
+ from typing import Dict, List, Optional, Sequence
3
+ from deepsecrets.core.model.file import File
4
+ from deepsecrets.core.model.finding import Finding
5
+ from deepsecrets.core.model.rules.rule import Rule
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AnalyzerBundle:
10
+ """What every worker needs to analyse a file. Sent to each worker once, by the pool initializer.
11
+
12
+ It crosses the process boundary by pickling, so every value must be picklable under `spawn`:
13
+ no lambdas, closures or open handles, and only classes importable from a module.
14
+ """
15
+
16
+ workdir: str
17
+ # engine name -> enabled
18
+ engines: Dict[str, bool] = field(default_factory=dict)
19
+ # ruleset name -> rules
20
+ rulesets: Dict[str, Sequence[Rule]] = field(default_factory=dict)
21
+ benchmarking_mode: bool = False
22
+
23
+
24
+ @dataclass
25
+ class PerFileAnalysisResult:
26
+ internal_task_id: int
27
+ findings: List[Finding]
28
+ errors: List[str]
29
+
30
+ # milliseconds: the whole-second field this replaced recorded 0 for almost every file
31
+ processing_time_ms: float = field(default=0.0)
32
+ # 'ok', 'empty' (0 bytes) or 'unreadable' (the file could not be opened)
33
+ status: str = field(default='ok')
34
+
35
+ # ONLY FOR BENCHMARKING MODE
36
+ _file: Optional[File] = None
@@ -61,3 +61,14 @@ class BaseResponseBuilder:
61
61
  masked_detection = detection[:start_len] + symbol * mask_len + detection[start_len + mask_len :]
62
62
 
63
63
  return snippet.replace(detection, masked_detection)
64
+
65
+ def _mask_by_offsets(self, snippet: str, snippet_start: int, finding: Finding, symbol: str = '*') -> str:
66
+ # For a detection that is not wholly inside the snippet (a multi-line secret
67
+ # seen through a single-line context), text matching cannot find it,
68
+ # so the part of the finding's span that falls inside the snippet is masked.
69
+ lo = max(finding.start_offset - snippet_start, 0)
70
+ hi = min(finding.end_offset - snippet_start, len(snippet))
71
+ if hi <= lo:
72
+ return snippet
73
+
74
+ return snippet[:lo] + symbol * (hi - lo) + snippet[hi:]
@@ -51,7 +51,16 @@ class BuiltinFormatResponseBuilder(BaseResponseBuilder):
51
51
 
52
52
  if self.masking_enabled:
53
53
  if resp_finding.line is not None:
54
- resp_finding.line = resp_finding.line.replace(resp_finding.string, '*' * len(resp_finding.string))
54
+ if resp_finding.string in resp_finding.line:
55
+ resp_finding.line = resp_finding.line.replace(
56
+ resp_finding.string, '*' * len(resp_finding.string)
57
+ )
58
+ else:
59
+ resp_finding.line = self._mask_by_offsets(
60
+ snippet=resp_finding.line,
61
+ snippet_start=finding.file.get_line_start_offset(finding.start_line_number),
62
+ finding=finding,
63
+ )
55
64
 
56
65
  resp_finding.string = '*' * len(resp_finding.string)
57
66
 
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from dataclasses import dataclass
2
3
  from typing import List, Set
3
4
 
@@ -11,11 +12,15 @@ from sarif_om import (
11
12
  Message,
12
13
  Location,
13
14
  PhysicalLocation,
15
+ Artifact,
14
16
  ArtifactLocation,
15
17
  ArtifactContent,
18
+ Invocation,
19
+ Notification,
16
20
  Region,
17
21
  )
18
22
  from deepsecrets.config import SCANNER_NAME, SCANNER_URL, SCANNER_VERSION
23
+ from deepsecrets.core.utils.fs import get_relative_path
19
24
 
20
25
  from deepsecrets.core.model.finding import Finding
21
26
  from deepsecrets.core.model.response.base import BaseResponseBuilder
@@ -90,8 +95,8 @@ class DojoSarifResponseBuilder(BaseResponseBuilder):
90
95
  return TierAwareSarifRuleMeta(
91
96
  id=f'{base_rule_id}{suffix}',
92
97
  payload={
93
- 'shortDescription': {'text': f'{base_description} ({tier.get('label')} Confidence)'},
94
- 'properties': {'precision': tier.get('precision'), 'security-severity': tier.get('severity')},
98
+ 'shortDescription': {'text': f'{base_description} ({tier.get("label")} Confidence)'},
99
+ 'properties': {'precision': tier.get("precision"), 'security-severity': tier.get("severity")},
95
100
  },
96
101
  )
97
102
 
@@ -117,13 +122,11 @@ class DojoSarifResponseBuilder(BaseResponseBuilder):
117
122
 
118
123
  def with_current_mode(self, mode: ScanMode):
119
124
  super().with_current_mode(mode)
120
- self.report.runs[0].original_uri_base_ids = (
121
- {
122
- SRC_PATH_BASE_ID: {
123
- 'uri': self.mode.config.workdir_path,
124
- },
125
+ self.report.runs[0].original_uri_base_ids = {
126
+ SRC_PATH_BASE_ID: {
127
+ 'uri': self.mode.config.workdir_path,
125
128
  },
126
- )
129
+ }
127
130
  return self
128
131
 
129
132
  def _convert_rules(self, rules: Set[TierAwareSarifRuleMeta]) -> List[ReportingDescriptor]:
@@ -175,8 +178,56 @@ class DojoSarifResponseBuilder(BaseResponseBuilder):
175
178
 
176
179
  sarif_rules = self._convert_rules(set([self._sarif_rule_meta_from_rule(rule) for rule in rules]))
177
180
  self.report.runs[0].tool.driver.rules = sarif_rules
181
+
182
+ mode = getattr(self, 'mode', None)
183
+ if mode is not None and getattr(mode.config, 'report_diagnostics', False):
184
+ self._add_diagnostics(self.report.runs[0])
178
185
  return self.report
179
186
 
187
+ def _add_diagnostics(self, run: Run) -> None:
188
+ """Every file found under the target dir as an artifact, and per-file errors as notifications."""
189
+ workdir = self.mode.config.workdir_path
190
+ outcomes = getattr(self.mode, 'files', {})
191
+
192
+ artifacts = []
193
+ notifications = []
194
+ for path, outcome in sorted(outcomes.items()):
195
+ uri = get_relative_path(path, workdir) if workdir else path
196
+ # a file that was scheduled but never came back has no result of its own
197
+ properties = {'status': 'error' if outcome.status == 'scheduled' else outcome.status}
198
+ if outcome.collected:
199
+ properties['scanTimeMs'] = outcome.time_ms
200
+ if outcome.skip_reason:
201
+ properties['skipReason'] = outcome.skip_reason
202
+ try:
203
+ length = os.path.getsize(path)
204
+ except OSError:
205
+ length = -1
206
+ artifacts.append(
207
+ Artifact(
208
+ location=ArtifactLocation(uri=uri, uri_base_id='%SRCROOT%'),
209
+ length=length,
210
+ properties=properties,
211
+ )
212
+ )
213
+ for error in outcome.errors:
214
+ notifications.append(
215
+ Notification(
216
+ level='error',
217
+ message=Message(text=error),
218
+ locations=[
219
+ Location(
220
+ physical_location=PhysicalLocation(
221
+ artifact_location=ArtifactLocation(uri=uri, uri_base_id='%SRCROOT%')
222
+ )
223
+ )
224
+ ],
225
+ )
226
+ )
227
+
228
+ run.artifacts = artifacts
229
+ run.invocations = [Invocation(execution_successful=True, tool_execution_notifications=notifications)]
230
+
180
231
  def get_context_region(self, finding: Finding, masking: bool = True):
181
232
 
182
233
  start_column = finding.file.get_column_number(position=finding.start_offset)
@@ -184,10 +235,14 @@ class DojoSarifResponseBuilder(BaseResponseBuilder):
184
235
 
185
236
  boundaries, _ = self._get_context_boundaries(finding, start_column, end_column)
186
237
  base_offset = finding.file.get_line_start_offset(finding.start_line_number)
187
- snippet = finding.file.content[base_offset + boundaries[0] : base_offset + boundaries[1]]
238
+ snippet_start = base_offset + boundaries[0]
239
+ snippet = finding.file.content[snippet_start : base_offset + boundaries[1]]
188
240
 
189
241
  if masking:
190
- snippet = self._mask(snippet=snippet, detection=finding.detection)
242
+ if finding.detection in snippet:
243
+ snippet = self._mask(snippet=snippet, detection=finding.detection)
244
+ else:
245
+ snippet = self._mask_by_offsets(snippet=snippet, snippet_start=snippet_start, finding=finding)
191
246
 
192
247
  return Region(
193
248
  start_line=finding.start_line_number,
@@ -73,14 +73,13 @@ class RegexRule(Rule): # type: ignore
73
73
  contents.append(token.content if isinstance(token, Token) else token)
74
74
  contents.extend(token.uncovered_content if isinstance(token, Token) else [])
75
75
 
76
+ # Call the compiled patterns directly: the module-level regex functions re-enter the pattern
77
+ # cache on every call, which costs several times the match itself on short tokens.
76
78
  for i, content in enumerate(contents):
77
- if (
78
- self.negative_pattern is not None
79
- and re.search(pattern=self.negative_pattern, string=content) is not None
80
- ):
79
+ if self.negative_pattern is not None and self.negative_pattern.search(content) is not None:
81
80
  continue
82
81
 
83
- matches = re.finditer(self.pattern, content)
82
+ matches = self.pattern.finditer(content)
84
83
 
85
84
  for match in matches:
86
85
  if not self._verify(match):
@@ -1,8 +1,8 @@
1
1
  from enum import Enum
2
2
  import operator
3
- from typing import Dict, Optional
3
+ from typing import Dict, List, Optional
4
4
 
5
- from pydantic import model_validator
5
+ from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator
6
6
  from deepsecrets.core.model.rules.regex import RegexRule
7
7
  from deepsecrets.core.model.semantic import Context
8
8
  import regex as re
@@ -38,11 +38,35 @@ ops = {
38
38
  }
39
39
 
40
40
 
41
+ class ScoringCondition(BaseModel):
42
+ """A case-insensitive regex check against one context field, used by `when` / `unless`."""
43
+
44
+ target: Target
45
+ pattern: re.Pattern
46
+
47
+ model_config = ConfigDict(arbitrary_types_allowed=True)
48
+
49
+ @field_validator('pattern', mode='before')
50
+ @classmethod
51
+ def compile_pattern(cls, pattern):
52
+ return re.compile(pattern, re.IGNORECASE) if isinstance(pattern, str) else pattern
53
+
54
+ @field_serializer('pattern')
55
+ def serialize_pattern(self, pattern: re.Pattern, _info):
56
+ return pattern.pattern
57
+
58
+ def matches(self, context: Context) -> bool:
59
+ return self.pattern.search(str(getattr(context, target_to_fields[self.target]))) is not None
60
+
61
+
41
62
  class VariableScoringRule(RegexRule):
42
63
  score: int
43
64
  target: Target
44
65
  method: Optional[str] = None
45
66
  threshold: Optional[float] = None
67
+ # the rule can only fire when every `when` condition matches and no `unless` condition does
68
+ when: List[ScoringCondition] = Field(default=[])
69
+ unless: List[ScoringCondition] = Field(default=[])
46
70
 
47
71
  def _is_threshold_type(self):
48
72
  return self.threshold is not None and self.method is not None
@@ -60,11 +84,16 @@ class VariableScoringRule(RegexRule):
60
84
  return getattr(context, field)
61
85
 
62
86
  def match_by_context(self, context: Context) -> bool:
87
+ if not all(condition.matches(context) for condition in self.when):
88
+ return False
89
+ if any(condition.matches(context) for condition in self.unless):
90
+ return False
91
+
63
92
  content = self._get_content_for_matching(context)
64
93
  if self._is_threshold_type():
65
94
  match = ops.get(self.method)(content, self.threshold)
66
95
  else:
67
- match = re.search(self.pattern, content)
96
+ match = self.pattern.search(content)
68
97
  if match is not None and match is not False:
69
98
  return True
70
99
  return False
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from enum import Enum, auto
4
- from typing import Any, List, Optional, Type
4
+ from typing import Any, Dict, List, Optional, Type
5
5
 
6
6
  from deepsecrets.core.model.file import File
7
7
  from deepsecrets.core.model.rules.hashing import HashingAlgorithm
@@ -34,7 +34,7 @@ class Token:
34
34
  file: 'File'
35
35
  type: List[Type]
36
36
  length: int
37
- hashed_value: Optional[str]
37
+ hashed_values: Dict[HashingAlgorithm, str]
38
38
  semantic: Optional[Semantic]
39
39
  previous: Optional['Token']
40
40
  next: Optional['Token']
@@ -44,7 +44,7 @@ class Token:
44
44
  self.content = content
45
45
  self.span = span
46
46
  self.length = len(content) if self.content else 0
47
- self.hashed_value = None
47
+ self.hashed_values = {}
48
48
  self.previous = None
49
49
  self.next = None
50
50
  self.type: List[Type] = [] # type: ignore
@@ -57,11 +57,12 @@ class Token:
57
57
  def val_hash(self) -> int:
58
58
  return hash(self.content)
59
59
 
60
- def calculate_hashed_value(self, algorithm: HashingAlgorithm) -> None:
61
- if self.hashed_value:
62
- return
60
+ def calculate_hashed_value(self, algorithm: HashingAlgorithm) -> str:
61
+ # cached per algorithm: rules for the same token length may use different algorithms
62
+ if algorithm not in self.hashed_values:
63
+ self.hashed_values[algorithm] = get_hash(payload=self.content, algorithm=algorithm)
63
64
 
64
- self.hashed_value = get_hash(payload=self.content, algorithm=algorithm)
65
+ return self.hashed_values[algorithm]
65
66
 
66
67
  def __repr__(self) -> str: # pragma: no cover
67
68
  if self.semantic is None and self.type is not None: