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.
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/PKG-INFO +5 -6
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/cli.py +15 -4
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/config.py +6 -1
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/hashed_secret.py +1 -2
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/variable_evaluator.py +9 -29
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/file.py +38 -14
- deepsecrets-2.1.0/deepsecrets/core/model/internal/processing.py +36 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/response/base.py +11 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/response/builtin.py +10 -1
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/response/dojo_sarif.py +65 -10
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/regex.py +4 -5
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/variable_scoring.py +32 -3
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/token.py +8 -7
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/modes/iscan_mode.py +155 -39
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/hashed_secrets.py +2 -1
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/deep_analyzer.py +20 -2
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/var_detection/detector.py +2 -2
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/single_token_improver.py +16 -9
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/fs.py +10 -1
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/guess_filetype.py +14 -1
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/lifecycle_hooks.py +16 -4
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/log.py +15 -8
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/progress.py +3 -1
- deepsecrets-2.1.0/deepsecrets/diagnostics/__init__.py +5 -0
- deepsecrets-2.1.0/deepsecrets/diagnostics/__main__.py +62 -0
- deepsecrets-2.1.0/deepsecrets/diagnostics/trace.py +405 -0
- deepsecrets-2.1.0/deepsecrets/diagnostics/worker.py +39 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/variable_scoring_rules.json +35 -12
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/scan_modes/cli.py +18 -19
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/pyproject.toml +9 -8
- deepsecrets-2.0.0/deepsecrets/core/model/internal/processing.py +0 -16
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/LICENSE +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/README.md +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/__main__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/iengine.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/regex.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/engines/semantic.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/content_analyzer.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/entropy.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/helpers/naturalness_scorer.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/finding.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/exlcuded_path.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/false_finding.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/hashed_secret.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/hashing.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/rules/rule.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/semantic.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/model/tokenized_region.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/excluded_paths.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/false_findings.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/ibuilder.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/regex.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/rulesets/variable_scoring.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/cheap_var_search.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/full_content.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/cheap_var_search/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/language.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/var_detection/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/semantic/var_detection/rules.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/subfile_regions_helper.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/helpers/type_stream.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/itokenizer.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/lexer.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/per_line.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/tokenizers/per_word.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/ui/floating_header.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/ui/progress_bar.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/ui/time_remaining_column.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/__init__.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/cpu.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/exceptions.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/file_analyzer.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/finding_merger.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/hashing.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/lexer_finder.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/core/utils/string.py +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/excluded_paths.json +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/regexes.json +0 -0
- {deepsecrets-2.0.0 → deepsecrets-2.1.0}/deepsecrets/rules/value_scoring_model.json +0 -0
- {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.
|
|
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.
|
|
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.
|
|
26
|
-
Requires-Dist: pygments (==2.
|
|
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.
|
|
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(
|
|
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] <
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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(
|
|
94
|
-
'properties': {'precision': tier.get(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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) ->
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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:
|