secure-code-agent 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- secure_code_agent-0.2.0.dist-info/METADATA +328 -0
- secure_code_agent-0.2.0.dist-info/RECORD +33 -0
- secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
- secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
- secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
- secure_code_audit/__init__.py +3 -0
- secure_code_audit/baseline.py +110 -0
- secure_code_audit/cli.py +258 -0
- secure_code_audit/config.py +115 -0
- secure_code_audit/findings.py +165 -0
- secure_code_audit/git_tools.py +82 -0
- secure_code_audit/instructions.py +141 -0
- secure_code_audit/remediation.py +168 -0
- secure_code_audit/renderers.py +253 -0
- secure_code_audit/sarif.py +221 -0
- secure_code_audit/scanners/__init__.py +50 -0
- secure_code_audit/scanners/bandit_scanner.py +83 -0
- secure_code_audit/scanners/base.py +194 -0
- secure_code_audit/scanners/builtin_rules.py +183 -0
- secure_code_audit/scanners/checkov_scanner.py +69 -0
- secure_code_audit/scanners/gitleaks_scanner.py +86 -0
- secure_code_audit/scanners/hadolint_scanner.py +107 -0
- secure_code_audit/scanners/npm_audit_scanner.py +101 -0
- secure_code_audit/scanners/osv_scanner.py +108 -0
- secure_code_audit/scanners/pip_audit_scanner.py +83 -0
- secure_code_audit/scanners/scorecard_scanner.py +156 -0
- secure_code_audit/scanners/semgrep_scanner.py +119 -0
- secure_code_audit/scanners/trivy_scanner.py +87 -0
- secure_code_audit/scanners/trufflehog_scanner.py +91 -0
- secure_code_audit/scoring.py +280 -0
- secure_code_audit/standards.py +391 -0
- secure_code_audit/suppressions.py +175 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Scoring + gate evaluation.
|
|
2
|
+
|
|
3
|
+
Implements the model documented in docs/scoring.md:
|
|
4
|
+
· finding_score = severity × confidence × category × top25_bonus
|
|
5
|
+
· category_subtotal = Σ finding_score per category
|
|
6
|
+
· category_normalized = subtotal / sqrt(LOC / 1000)
|
|
7
|
+
· category_grade = clamp(5.0 - (normalized × 0.5), 0, 5)
|
|
8
|
+
· overall = min(category_grades)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
17
|
+
|
|
18
|
+
# --- weights ---------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
SEVERITY_WEIGHT: dict[Severity, float] = {
|
|
21
|
+
Severity.CRITICAL: 10.0,
|
|
22
|
+
Severity.HIGH: 4.0,
|
|
23
|
+
Severity.MEDIUM: 1.5,
|
|
24
|
+
Severity.LOW: 0.5,
|
|
25
|
+
Severity.INFORMATIONAL: 0.0,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
CONFIDENCE_WEIGHT: dict[Confidence, float] = {
|
|
29
|
+
Confidence.HIGH: 1.00,
|
|
30
|
+
Confidence.MEDIUM: 0.75,
|
|
31
|
+
Confidence.LOW: 0.50,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
CATEGORY_WEIGHT: dict[Category, float] = {
|
|
35
|
+
Category.SECRETS: 1.5,
|
|
36
|
+
Category.CODE_VULNERABILITIES: 1.5,
|
|
37
|
+
Category.AUTH_AUTHZ: 1.5,
|
|
38
|
+
Category.CRYPTO: 1.5,
|
|
39
|
+
Category.DEPENDENCIES: 1.0,
|
|
40
|
+
Category.CONFIG_IAC: 1.0,
|
|
41
|
+
Category.SUPPLY_CHAIN: 0.8,
|
|
42
|
+
Category.LOGGING_OBSERVABILITY: 0.8,
|
|
43
|
+
Category.POLICY_DOCS: 0.5,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
CWE_TOP25_BONUS = 1.25
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- letter-grade boundaries (mirrors maintainability-agent) ---------------
|
|
50
|
+
|
|
51
|
+
_LETTER_GRADE_TABLE = (
|
|
52
|
+
(4.85, "A+"),
|
|
53
|
+
(4.50, "A"),
|
|
54
|
+
(4.00, "A-"),
|
|
55
|
+
(3.50, "B+"),
|
|
56
|
+
(3.00, "B"),
|
|
57
|
+
(2.50, "B-"),
|
|
58
|
+
(2.00, "C"),
|
|
59
|
+
(1.00, "D"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def letter_grade(score: float) -> str:
|
|
64
|
+
for threshold, grade in _LETTER_GRADE_TABLE:
|
|
65
|
+
if score >= threshold:
|
|
66
|
+
return grade
|
|
67
|
+
return "F"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- per-finding score -----------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def finding_score(f: Finding) -> float:
|
|
73
|
+
"""Score one finding per the documented formula. Suppressed findings
|
|
74
|
+
score 0 (they're excluded by the caller before this normally fires,
|
|
75
|
+
but the defensive check is cheap)."""
|
|
76
|
+
if f.suppressed:
|
|
77
|
+
return 0.0
|
|
78
|
+
base = (
|
|
79
|
+
SEVERITY_WEIGHT[f.severity]
|
|
80
|
+
* CONFIDENCE_WEIGHT[f.confidence]
|
|
81
|
+
* CATEGORY_WEIGHT[f.category]
|
|
82
|
+
)
|
|
83
|
+
if f.cwe_top25:
|
|
84
|
+
base *= CWE_TOP25_BONUS
|
|
85
|
+
return base
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- per-category aggregation ---------------------------------------------
|
|
89
|
+
|
|
90
|
+
def category_subtotal(findings: Iterable[Finding], category: Category) -> float:
|
|
91
|
+
return sum(
|
|
92
|
+
finding_score(f) for f in findings if f.category == category and not f.suppressed
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def normalize(subtotal: float, loc_scanned: int) -> float:
|
|
97
|
+
"""sqrt(LOC/1000) dampener — see docs/scoring.md for the rationale."""
|
|
98
|
+
if loc_scanned <= 0:
|
|
99
|
+
return subtotal
|
|
100
|
+
return subtotal / math.sqrt(max(loc_scanned, 1) / 1000)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def category_grade(normalized: float) -> float:
|
|
104
|
+
return max(0.0, min(5.0, 5.0 - (normalized * 0.5)))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- overall score ---------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ScoreReport:
|
|
111
|
+
"""Per-category + overall score breakdown. Renderers consume this directly."""
|
|
112
|
+
|
|
113
|
+
per_category: dict[Category, float] # category → 0.0-5.0 grade
|
|
114
|
+
per_category_count: dict[Category, int] # category → unsuppressed finding count
|
|
115
|
+
per_severity_count: dict[Severity, int] # severity → unsuppressed finding count
|
|
116
|
+
overall: float # 0.0-5.0
|
|
117
|
+
letter: str # A+, A, A-, B+, ...
|
|
118
|
+
worst_category: Category | None # which category drove the grade
|
|
119
|
+
loc_scanned: int # for the report header
|
|
120
|
+
|
|
121
|
+
def as_table(self) -> list[tuple[str, str, float, int]]:
|
|
122
|
+
"""[(category_name, grade_letter, grade_score, finding_count), ...]
|
|
123
|
+
sorted worst → best for the operator report."""
|
|
124
|
+
rows = []
|
|
125
|
+
for cat in Category:
|
|
126
|
+
grade = self.per_category.get(cat, 5.0)
|
|
127
|
+
count = self.per_category_count.get(cat, 0)
|
|
128
|
+
rows.append((cat.value, letter_grade(grade), grade, count))
|
|
129
|
+
rows.sort(key=lambda r: r[2]) # worst first
|
|
130
|
+
return rows
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def score(findings: Iterable[Finding], loc_scanned: int) -> ScoreReport:
|
|
134
|
+
findings = list(findings)
|
|
135
|
+
per_category: dict[Category, float] = {}
|
|
136
|
+
per_category_count: dict[Category, int] = {}
|
|
137
|
+
per_severity_count: dict[Severity, int] = dict.fromkeys(Severity, 0)
|
|
138
|
+
|
|
139
|
+
for cat in Category:
|
|
140
|
+
subtotal = category_subtotal(findings, cat)
|
|
141
|
+
normalized = normalize(subtotal, loc_scanned)
|
|
142
|
+
per_category[cat] = category_grade(normalized)
|
|
143
|
+
per_category_count[cat] = sum(
|
|
144
|
+
1 for f in findings if f.category == cat and not f.suppressed
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for f in findings:
|
|
148
|
+
if not f.suppressed:
|
|
149
|
+
per_severity_count[f.severity] += 1
|
|
150
|
+
|
|
151
|
+
if findings:
|
|
152
|
+
worst_category = min(per_category.items(), key=lambda kv: kv[1])[0]
|
|
153
|
+
else:
|
|
154
|
+
worst_category = None
|
|
155
|
+
|
|
156
|
+
overall = min(per_category.values()) if per_category else 5.0
|
|
157
|
+
return ScoreReport(
|
|
158
|
+
per_category=per_category,
|
|
159
|
+
per_category_count=per_category_count,
|
|
160
|
+
per_severity_count=per_severity_count,
|
|
161
|
+
overall=overall,
|
|
162
|
+
letter=letter_grade(overall),
|
|
163
|
+
worst_category=worst_category,
|
|
164
|
+
loc_scanned=loc_scanned,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- gate evaluation -------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class GateResult:
|
|
172
|
+
passed: bool
|
|
173
|
+
reasons: tuple[str, ...] # human-readable trip reasons
|
|
174
|
+
tripped: tuple[str, ...] = field(default_factory=tuple)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def evaluate_gates(
|
|
178
|
+
findings: list[Finding],
|
|
179
|
+
report: ScoreReport,
|
|
180
|
+
gate_config: dict,
|
|
181
|
+
) -> GateResult:
|
|
182
|
+
"""Apply the configured gates. Any tripped gate → passed=False.
|
|
183
|
+
|
|
184
|
+
gate_config is the `gates` block of the loaded config. See
|
|
185
|
+
secure-code-agent.schema.json for shape; missing keys are treated
|
|
186
|
+
as 'gate not configured' (i.e. doesn't trip)."""
|
|
187
|
+
|
|
188
|
+
reasons: list[str] = []
|
|
189
|
+
tripped: list[str] = []
|
|
190
|
+
|
|
191
|
+
for check in (
|
|
192
|
+
_gate_fail_on_severity,
|
|
193
|
+
_gate_fail_on_category,
|
|
194
|
+
_gate_fail_on_new,
|
|
195
|
+
_gate_min_score,
|
|
196
|
+
_gate_max_unsuppressed,
|
|
197
|
+
):
|
|
198
|
+
check(findings, report, gate_config, tripped, reasons)
|
|
199
|
+
|
|
200
|
+
return GateResult(
|
|
201
|
+
passed=not tripped,
|
|
202
|
+
reasons=tuple(reasons),
|
|
203
|
+
tripped=tuple(tripped),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ----- per-gate evaluators -------------------------------------------------
|
|
208
|
+
# Each appends to the shared `tripped` and `reasons` lists. Splitting these
|
|
209
|
+
# keeps evaluate_gates() at cognitive complexity <= 15 (we ship the
|
|
210
|
+
# maintainability standard and dogfood it here).
|
|
211
|
+
|
|
212
|
+
def _gate_fail_on_severity(
|
|
213
|
+
findings: list[Finding], report: ScoreReport, gate_config: dict,
|
|
214
|
+
tripped: list[str], reasons: list[str],
|
|
215
|
+
) -> None:
|
|
216
|
+
fail_on = {s.lower() for s in gate_config.get("fail_on_severity", [])}
|
|
217
|
+
if not fail_on:
|
|
218
|
+
return
|
|
219
|
+
offenders = [f for f in findings if not f.suppressed and f.severity.value in fail_on]
|
|
220
|
+
if offenders:
|
|
221
|
+
tripped.append("fail_on_severity")
|
|
222
|
+
reasons.append(f"{len(offenders)} finding(s) at severity in {sorted(fail_on)}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _gate_fail_on_category(
|
|
226
|
+
findings: list[Finding], report: ScoreReport, gate_config: dict,
|
|
227
|
+
tripped: list[str], reasons: list[str],
|
|
228
|
+
) -> None:
|
|
229
|
+
fail_cats = {c.lower() for c in gate_config.get("fail_on_category", [])}
|
|
230
|
+
if not fail_cats:
|
|
231
|
+
return
|
|
232
|
+
offenders = [f for f in findings if not f.suppressed and f.category.value in fail_cats]
|
|
233
|
+
if offenders:
|
|
234
|
+
tripped.append("fail_on_category")
|
|
235
|
+
reasons.append(f"{len(offenders)} finding(s) in categories {sorted(fail_cats)}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _gate_fail_on_new(
|
|
239
|
+
findings: list[Finding], report: ScoreReport, gate_config: dict,
|
|
240
|
+
tripped: list[str], reasons: list[str],
|
|
241
|
+
) -> None:
|
|
242
|
+
# Informational findings (tool_unavailable, parse errors, etc.) never
|
|
243
|
+
# trip the gate — they're awareness signals, not security defects.
|
|
244
|
+
if not gate_config.get("fail_on_new"):
|
|
245
|
+
return
|
|
246
|
+
new_findings = [
|
|
247
|
+
f for f in findings
|
|
248
|
+
if f.is_new and not f.suppressed and f.severity is not Severity.INFORMATIONAL
|
|
249
|
+
]
|
|
250
|
+
if new_findings:
|
|
251
|
+
tripped.append("fail_on_new")
|
|
252
|
+
reasons.append(f"{len(new_findings)} new finding(s) since baseline")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _gate_min_score(
|
|
256
|
+
findings: list[Finding], report: ScoreReport, gate_config: dict,
|
|
257
|
+
tripped: list[str], reasons: list[str],
|
|
258
|
+
) -> None:
|
|
259
|
+
min_score = gate_config.get("min_score")
|
|
260
|
+
if min_score is None or report.overall >= min_score:
|
|
261
|
+
return
|
|
262
|
+
tripped.append("min_score")
|
|
263
|
+
reasons.append(
|
|
264
|
+
f"overall score {report.overall:.2f} below required minimum {min_score}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _gate_max_unsuppressed(
|
|
269
|
+
findings: list[Finding], report: ScoreReport, gate_config: dict,
|
|
270
|
+
tripped: list[str], reasons: list[str],
|
|
271
|
+
) -> None:
|
|
272
|
+
caps = gate_config.get("max_unsuppressed", {})
|
|
273
|
+
for sev_str, cap in caps.items():
|
|
274
|
+
sev = Severity.from_string(sev_str)
|
|
275
|
+
count = report.per_severity_count.get(sev, 0)
|
|
276
|
+
if count > cap:
|
|
277
|
+
tripped.append(f"max_unsuppressed.{sev_str}")
|
|
278
|
+
reasons.append(
|
|
279
|
+
f"{count} unsuppressed {sev.value} finding(s) exceeds cap {cap}"
|
|
280
|
+
)
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Standards taxonomy — CWE, OWASP Top 10, OWASP ASVS, NIST SSDF.
|
|
2
|
+
|
|
3
|
+
Maps scanner rule ids to canonical standards. The mapping table is the
|
|
4
|
+
source of truth for: dedupe (canonical_cwe), category routing, severity
|
|
5
|
+
weighting, and remediation-prompt context.
|
|
6
|
+
|
|
7
|
+
The map lives as Python data (not JSON-on-disk) for v0.1 so it ships in
|
|
8
|
+
the wheel without a packaging tweak. v0.2 may externalize to JSON to
|
|
9
|
+
support operator-defined rule packs.
|
|
10
|
+
|
|
11
|
+
See docs/standards.md for the citations.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from secure_code_audit.findings import Category, Confidence, Severity
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# --- CWE Top 25 (2025) -----------------------------------------------------
|
|
22
|
+
# Source: https://cwe.mitre.org/top25/
|
|
23
|
+
# Used as a 1.25× scoring multiplier — see docs/scoring.md.
|
|
24
|
+
|
|
25
|
+
CWE_TOP25_2025: frozenset[str] = frozenset({
|
|
26
|
+
"CWE-79", "CWE-787", "CWE-89", "CWE-352", "CWE-22",
|
|
27
|
+
"CWE-125", "CWE-78", "CWE-416", "CWE-862", "CWE-434",
|
|
28
|
+
"CWE-94", "CWE-20", "CWE-77", "CWE-287", "CWE-269",
|
|
29
|
+
"CWE-502", "CWE-200", "CWE-863", "CWE-918", "CWE-119",
|
|
30
|
+
"CWE-476", "CWE-798", "CWE-190", "CWE-400", "CWE-306",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- OWASP Top 10 (2021) bucket id → URL ----------------------------------
|
|
35
|
+
OWASP_TOP10_2021_URL = "https://owasp.org/Top10/2021/"
|
|
36
|
+
|
|
37
|
+
OWASP_TOP10_2021: dict[str, str] = {
|
|
38
|
+
"A01": "A01:2021-Broken Access Control",
|
|
39
|
+
"A02": "A02:2021-Cryptographic Failures",
|
|
40
|
+
"A03": "A03:2021-Injection",
|
|
41
|
+
"A04": "A04:2021-Insecure Design",
|
|
42
|
+
"A05": "A05:2021-Security Misconfiguration",
|
|
43
|
+
"A06": "A06:2021-Vulnerable and Outdated Components",
|
|
44
|
+
"A07": "A07:2021-Identification and Authentication Failures",
|
|
45
|
+
"A08": "A08:2021-Software and Data Integrity Failures",
|
|
46
|
+
"A09": "A09:2021-Security Logging and Monitoring Failures",
|
|
47
|
+
"A10": "A10:2021-Server-Side Request Forgery (SSRF)",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Standards mapping entry ----------------------------------------------
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class StandardsEntry:
|
|
54
|
+
canonical_cwe: Optional[str]
|
|
55
|
+
owasp_top10: Optional[str]
|
|
56
|
+
asvs_section: Optional[str]
|
|
57
|
+
nist_ssdf: Optional[str]
|
|
58
|
+
category: Category
|
|
59
|
+
severity: Severity # default; scanner-emitted severity overrides
|
|
60
|
+
confidence: Confidence # default
|
|
61
|
+
short_desc: str
|
|
62
|
+
fix_hint: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- The mapping table -----------------------------------------------------
|
|
66
|
+
# Indexed by (scanner_name, rule_id). Keys must be lowercase scanner name +
|
|
67
|
+
# exact rule id as the scanner emits it.
|
|
68
|
+
#
|
|
69
|
+
# Adding a rule:
|
|
70
|
+
# 1. Add the (CWE id, OWASP id, ASVS section, SSDF practice) tuple.
|
|
71
|
+
# 2. Cite the scanner's docs URL in `references=` if non-obvious.
|
|
72
|
+
# 3. Add an entry to the matching tier in docs/scanners.md.
|
|
73
|
+
# 4. Add a fixture in tests/fixtures/<scanner>/.
|
|
74
|
+
|
|
75
|
+
_MAP: dict[tuple[str, str], StandardsEntry] = {
|
|
76
|
+
|
|
77
|
+
# ----- Bandit ----------------------------------------------------------
|
|
78
|
+
# Source: https://bandit.readthedocs.io/en/latest/plugins/index.html
|
|
79
|
+
("bandit", "B102"): StandardsEntry(
|
|
80
|
+
canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
|
|
81
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
82
|
+
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
|
83
|
+
short_desc="Use of exec() — arbitrary code execution risk.",
|
|
84
|
+
fix_hint="Eliminate exec() entirely. If dynamic dispatch is required, use a typed registry / function map.",
|
|
85
|
+
),
|
|
86
|
+
("bandit", "B301"): StandardsEntry(
|
|
87
|
+
canonical_cwe="CWE-502", owasp_top10="A08", asvs_section="V5.5.1",
|
|
88
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
89
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
90
|
+
short_desc="pickle.loads() on possibly-untrusted input — arbitrary code execution.",
|
|
91
|
+
fix_hint="Replace pickle with JSON for data, or a signed/encrypted envelope for trusted state transfer.",
|
|
92
|
+
),
|
|
93
|
+
("bandit", "B303"): StandardsEntry(
|
|
94
|
+
canonical_cwe="CWE-327", owasp_top10="A02", asvs_section="V6.2.5",
|
|
95
|
+
nist_ssdf="PW.4.1", category=Category.CRYPTO,
|
|
96
|
+
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
|
97
|
+
short_desc="MD5/SHA-1 used. Insecure for security purposes (collisions).",
|
|
98
|
+
fix_hint="Use SHA-256 or BLAKE2 for non-password hashing. Use Argon2id for password hashing.",
|
|
99
|
+
),
|
|
100
|
+
("bandit", "B305"): StandardsEntry(
|
|
101
|
+
canonical_cwe="CWE-327", owasp_top10="A02", asvs_section="V6.2.2",
|
|
102
|
+
nist_ssdf="PW.4.1", category=Category.CRYPTO,
|
|
103
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
104
|
+
short_desc="Use of insecure cipher mode (ECB).",
|
|
105
|
+
fix_hint="Use authenticated encryption (AES-GCM or ChaCha20-Poly1305). Never ECB.",
|
|
106
|
+
),
|
|
107
|
+
("bandit", "B501"): StandardsEntry(
|
|
108
|
+
canonical_cwe="CWE-295", owasp_top10="A07", asvs_section="V9.2.1",
|
|
109
|
+
nist_ssdf="PW.4.1", category=Category.CRYPTO,
|
|
110
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
111
|
+
short_desc="Requests call with verify=False — TLS cert validation disabled.",
|
|
112
|
+
fix_hint="Remove verify=False. If self-signed cert is required, pass the trusted CA bundle explicitly.",
|
|
113
|
+
),
|
|
114
|
+
("bandit", "B602"): StandardsEntry(
|
|
115
|
+
canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
|
|
116
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
117
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
118
|
+
short_desc="subprocess with shell=True and shell metacharacters — command injection.",
|
|
119
|
+
fix_hint="Pass argv as a list and use shell=False. Never interpolate user input into a shell string.",
|
|
120
|
+
),
|
|
121
|
+
("bandit", "B608"): StandardsEntry(
|
|
122
|
+
canonical_cwe="CWE-89", owasp_top10="A03", asvs_section="V5.3.5",
|
|
123
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
124
|
+
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
|
125
|
+
short_desc="String-built SQL — possible injection.",
|
|
126
|
+
fix_hint="Use parameterized queries ($1, ?, :name). Never interpolate user input into SQL strings.",
|
|
127
|
+
),
|
|
128
|
+
|
|
129
|
+
# ----- pip-audit -------------------------------------------------------
|
|
130
|
+
# Source: https://github.com/pypa/pip-audit
|
|
131
|
+
# pip-audit emits per-CVE findings — they all share CWE-1104 (Use of
|
|
132
|
+
# Unmaintained Third Party Components) plus a per-CVE rule id.
|
|
133
|
+
("pip_audit", "*"): StandardsEntry(
|
|
134
|
+
canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
|
|
135
|
+
nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
|
|
136
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
137
|
+
short_desc="Known-vulnerable dependency in pinned set.",
|
|
138
|
+
fix_hint="Bump to the fixed version per the advisory. If no fix exists, document the residual risk in `.scignore.yaml` with an expires date.",
|
|
139
|
+
),
|
|
140
|
+
|
|
141
|
+
# ----- npm audit -------------------------------------------------------
|
|
142
|
+
("npm_audit", "*"): StandardsEntry(
|
|
143
|
+
canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
|
|
144
|
+
nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
|
|
145
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
146
|
+
short_desc="Known-vulnerable npm dependency.",
|
|
147
|
+
fix_hint="`npm audit fix` is often safe for patch-level bumps but can downgrade majors. Inspect the suggested fix before applying; for downgrades, track upstream.",
|
|
148
|
+
),
|
|
149
|
+
|
|
150
|
+
# ----- Gitleaks --------------------------------------------------------
|
|
151
|
+
# Source: https://github.com/gitleaks/gitleaks
|
|
152
|
+
("gitleaks", "*"): StandardsEntry(
|
|
153
|
+
canonical_cwe="CWE-798", owasp_top10="A07", asvs_section="V2.10.1",
|
|
154
|
+
nist_ssdf="PS.1.1", category=Category.SECRETS,
|
|
155
|
+
severity=Severity.CRITICAL, confidence=Confidence.HIGH,
|
|
156
|
+
short_desc="Hardcoded secret detected.",
|
|
157
|
+
fix_hint="Rotate the secret immediately. Move to an env var / secret manager. Run `git filter-repo` to scrub history if it's been pushed publicly.",
|
|
158
|
+
),
|
|
159
|
+
|
|
160
|
+
# ----- TruffleHog ------------------------------------------------------
|
|
161
|
+
("trufflehog", "*"): StandardsEntry(
|
|
162
|
+
canonical_cwe="CWE-798", owasp_top10="A07", asvs_section="V2.10.1",
|
|
163
|
+
nist_ssdf="PS.1.1", category=Category.SECRETS,
|
|
164
|
+
severity=Severity.CRITICAL, confidence=Confidence.HIGH,
|
|
165
|
+
short_desc="Verified secret detected.",
|
|
166
|
+
fix_hint="Same as Gitleaks: rotate, move to env / secret manager, scrub history if leaked publicly.",
|
|
167
|
+
),
|
|
168
|
+
|
|
169
|
+
# ----- Built-in regex rules -------------------------------------------
|
|
170
|
+
("builtin_rules", "sca.python.eval"): StandardsEntry(
|
|
171
|
+
canonical_cwe="CWE-95", owasp_top10="A03", asvs_section="V5.2.4",
|
|
172
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
173
|
+
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
|
174
|
+
short_desc="eval()/exec() on non-literal input.",
|
|
175
|
+
fix_hint="Eliminate eval. Use ast.literal_eval for safe data, or a typed registry for dispatch.",
|
|
176
|
+
),
|
|
177
|
+
("builtin_rules", "sca.python.yaml.unsafe_load"): StandardsEntry(
|
|
178
|
+
canonical_cwe="CWE-502", owasp_top10="A08", asvs_section="V5.5.2",
|
|
179
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
180
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
181
|
+
short_desc="yaml.load() without SafeLoader.",
|
|
182
|
+
fix_hint="Use yaml.safe_load() or yaml.load(stream, Loader=yaml.SafeLoader).",
|
|
183
|
+
),
|
|
184
|
+
("builtin_rules", "sca.python.requests.verify_false"): StandardsEntry(
|
|
185
|
+
canonical_cwe="CWE-295", owasp_top10="A07", asvs_section="V9.2.1",
|
|
186
|
+
nist_ssdf="PW.4.1", category=Category.CRYPTO,
|
|
187
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
188
|
+
short_desc="requests.* called with verify=False.",
|
|
189
|
+
fix_hint="Remove verify=False. Pass the trusted CA bundle if the upstream cert is self-signed.",
|
|
190
|
+
),
|
|
191
|
+
("builtin_rules", "sca.python.fstring_sql"): StandardsEntry(
|
|
192
|
+
canonical_cwe="CWE-89", owasp_top10="A03", asvs_section="V5.3.5",
|
|
193
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
194
|
+
severity=Severity.HIGH, confidence=Confidence.MEDIUM,
|
|
195
|
+
short_desc="f-string SQL — interpolated value in .execute()/.executemany().",
|
|
196
|
+
fix_hint="Parameterize: `await conn.execute('... WHERE id = $1', value)` instead of f-string.",
|
|
197
|
+
),
|
|
198
|
+
("builtin_rules", "sca.python.subprocess.shell_true"): StandardsEntry(
|
|
199
|
+
canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
|
|
200
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
201
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
202
|
+
short_desc="subprocess.* with shell=True and non-literal command.",
|
|
203
|
+
fix_hint="Pass argv as a list (e.g. ['git', 'log', '-n', '5']) and use shell=False.",
|
|
204
|
+
),
|
|
205
|
+
("builtin_rules", "sca.python.hashlib.md5_sha1_security"): StandardsEntry(
|
|
206
|
+
canonical_cwe="CWE-327", owasp_top10="A02", asvs_section="V6.2.5",
|
|
207
|
+
nist_ssdf="PW.4.1", category=Category.CRYPTO,
|
|
208
|
+
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
|
209
|
+
short_desc="MD5/SHA-1 in a non-test, non-checksum path.",
|
|
210
|
+
fix_hint="Use SHA-256 or BLAKE2. For password hashing, use Argon2id (via argon2-cffi or passlib).",
|
|
211
|
+
),
|
|
212
|
+
("builtin_rules", "sca.web.dangerously_set_inner_html"): StandardsEntry(
|
|
213
|
+
canonical_cwe="CWE-79", owasp_top10="A03", asvs_section="V5.3.3",
|
|
214
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
215
|
+
severity=Severity.HIGH, confidence=Confidence.LOW,
|
|
216
|
+
short_desc="React dangerouslySetInnerHTML with non-literal input.",
|
|
217
|
+
fix_hint="Render via React text nodes. If raw HTML is required, sanitize with DOMPurify and document the trust source inline.",
|
|
218
|
+
),
|
|
219
|
+
("builtin_rules", "sca.web.cors_wildcard"): StandardsEntry(
|
|
220
|
+
canonical_cwe="CWE-942", owasp_top10="A05", asvs_section="V14.5.3",
|
|
221
|
+
nist_ssdf="PW.5.1", category=Category.CODE_VULNERABILITIES,
|
|
222
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
223
|
+
short_desc="CORS Access-Control-Allow-Origin: * with credentials allowed.",
|
|
224
|
+
fix_hint="Scope Allow-Origin to a specific allowlist when credentials are in use. '*' + credentials is forbidden by spec.",
|
|
225
|
+
),
|
|
226
|
+
("builtin_rules", "sca.shell.curl_pipe_sh"): StandardsEntry(
|
|
227
|
+
canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
|
|
228
|
+
nist_ssdf="PW.4.4", category=Category.SUPPLY_CHAIN,
|
|
229
|
+
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
|
230
|
+
short_desc="curl | sh / wget | bash — opaque remote-script execution.",
|
|
231
|
+
fix_hint="Pin a checksum or use a package manager. If you must download a script, verify a SHA before executing.",
|
|
232
|
+
),
|
|
233
|
+
|
|
234
|
+
# ----- Trivy ----------------------------------------------------------
|
|
235
|
+
# Trivy emits per-CVE rule ids (CVE-/GHSA-/AVD-). The per-rule CWE is
|
|
236
|
+
# carried inside the SARIF properties; this wildcard covers what the
|
|
237
|
+
# SARIF doesn't.
|
|
238
|
+
("trivy", "*"): StandardsEntry(
|
|
239
|
+
canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
|
|
240
|
+
nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
|
|
241
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
242
|
+
short_desc="Trivy finding (vuln / misconfig / secret).",
|
|
243
|
+
fix_hint="Trivy routes vuln/misconfig/secret into different categories — see the finding's category field for the specific guidance.",
|
|
244
|
+
),
|
|
245
|
+
|
|
246
|
+
# ----- Checkov --------------------------------------------------------
|
|
247
|
+
# Checkov rule ids are like CKV_AWS_xxx, CKV_K8S_xxx, CKV_DOCKER_xxx.
|
|
248
|
+
# All map to config_iac with OWASP A05 (Security Misconfiguration).
|
|
249
|
+
("checkov", "*"): StandardsEntry(
|
|
250
|
+
canonical_cwe="CWE-1188", owasp_top10="A05", asvs_section="V14.1.1",
|
|
251
|
+
nist_ssdf="PW.6.1", category=Category.CONFIG_IAC,
|
|
252
|
+
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
|
253
|
+
short_desc="IaC misconfiguration detected by Checkov.",
|
|
254
|
+
fix_hint="Follow Checkov's documentation link in the finding message. Misconfigs are typically a one-property addition (encryption, public-access blockers, etc.).",
|
|
255
|
+
),
|
|
256
|
+
|
|
257
|
+
# ----- Hadolint -------------------------------------------------------
|
|
258
|
+
# Most-flagged security-relevant rules. Style rules fall through to the
|
|
259
|
+
# wildcard.
|
|
260
|
+
("hadolint", "hadolint.DL3002"): StandardsEntry(
|
|
261
|
+
canonical_cwe="CWE-250", owasp_top10="A05", asvs_section="V14.2.5",
|
|
262
|
+
nist_ssdf="PW.6.1", category=Category.CONFIG_IAC,
|
|
263
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
264
|
+
short_desc="Dockerfile sets USER root — privileged container.",
|
|
265
|
+
fix_hint="Add `USER <non-root-uid>` near the end of the Dockerfile. Or run with `--user` at the container runtime.",
|
|
266
|
+
),
|
|
267
|
+
("hadolint", "hadolint.DL3025"): StandardsEntry(
|
|
268
|
+
canonical_cwe="CWE-78", owasp_top10="A03", asvs_section="V5.3.8",
|
|
269
|
+
nist_ssdf="PW.5.1", category=Category.CONFIG_IAC,
|
|
270
|
+
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
|
271
|
+
short_desc="Dockerfile CMD/ENTRYPOINT in shell form — argv injection surface.",
|
|
272
|
+
fix_hint="Use JSON-array form: `CMD [\"node\", \"server.js\"]`. Avoids the shell wrapper that interprets metacharacters.",
|
|
273
|
+
),
|
|
274
|
+
("hadolint", "*"): StandardsEntry(
|
|
275
|
+
canonical_cwe="CWE-1188", owasp_top10="A05", asvs_section="V14.1.1",
|
|
276
|
+
nist_ssdf="PW.6.1", category=Category.CONFIG_IAC,
|
|
277
|
+
severity=Severity.LOW, confidence=Confidence.HIGH,
|
|
278
|
+
short_desc="Dockerfile lint finding.",
|
|
279
|
+
fix_hint="See https://github.com/hadolint/hadolint/wiki for the specific rule.",
|
|
280
|
+
),
|
|
281
|
+
|
|
282
|
+
# ----- OSV-Scanner ----------------------------------------------------
|
|
283
|
+
("osv_scanner", "*"): StandardsEntry(
|
|
284
|
+
canonical_cwe="CWE-1104", owasp_top10="A06", asvs_section="V14.2.1",
|
|
285
|
+
nist_ssdf="PW.4.4", category=Category.DEPENDENCIES,
|
|
286
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
287
|
+
short_desc="Vulnerable dependency reported by osv.dev.",
|
|
288
|
+
fix_hint="Bump to the fixed version per the advisory. If no fix exists, document the residual risk in `.scignore.yaml`.",
|
|
289
|
+
),
|
|
290
|
+
|
|
291
|
+
# ----- TruffleHog -----------------------------------------------------
|
|
292
|
+
("trufflehog", "*"): StandardsEntry(
|
|
293
|
+
canonical_cwe="CWE-798", owasp_top10="A07", asvs_section="V2.10.1",
|
|
294
|
+
nist_ssdf="PS.1.1", category=Category.SECRETS,
|
|
295
|
+
severity=Severity.CRITICAL, confidence=Confidence.HIGH,
|
|
296
|
+
short_desc="Verified secret detected by TruffleHog.",
|
|
297
|
+
fix_hint="Rotate the secret immediately. Move to an env var / secret manager. Scrub history with `git filter-repo` if the leak reached a public branch.",
|
|
298
|
+
),
|
|
299
|
+
|
|
300
|
+
# ----- OpenSSF Scorecard ----------------------------------------------
|
|
301
|
+
# Scorecard's check names are stable — map each to its standards refs.
|
|
302
|
+
("scorecard", "scorecard.Branch-Protection"): StandardsEntry(
|
|
303
|
+
canonical_cwe="CWE-732", owasp_top10="A05", asvs_section="V14.1.4",
|
|
304
|
+
nist_ssdf="PO.5.1", category=Category.SUPPLY_CHAIN,
|
|
305
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
306
|
+
short_desc="Branch protection insufficient on the default branch.",
|
|
307
|
+
fix_hint="Enable required PR reviews, required status checks, and prevent force-pushes on the default branch.",
|
|
308
|
+
),
|
|
309
|
+
("scorecard", "scorecard.Signed-Releases"): StandardsEntry(
|
|
310
|
+
canonical_cwe="CWE-345", owasp_top10="A08", asvs_section="V10.3.2",
|
|
311
|
+
nist_ssdf="PS.2.1", category=Category.SUPPLY_CHAIN,
|
|
312
|
+
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
|
313
|
+
short_desc="Releases are not signed with Sigstore/cosign.",
|
|
314
|
+
fix_hint="Sign releases via Sigstore/cosign. Publish provenance with `slsa-github-generator` or equivalent.",
|
|
315
|
+
),
|
|
316
|
+
("scorecard", "scorecard.Pinned-Dependencies"): StandardsEntry(
|
|
317
|
+
canonical_cwe="CWE-829", owasp_top10="A08", asvs_section="V14.2.2",
|
|
318
|
+
nist_ssdf="PW.4.4", category=Category.SUPPLY_CHAIN,
|
|
319
|
+
severity=Severity.MEDIUM, confidence=Confidence.HIGH,
|
|
320
|
+
short_desc="Dependencies (esp. GitHub Actions) are not pinned by SHA.",
|
|
321
|
+
fix_hint="Pin third-party Actions to a commit SHA, not a tag. Pin Docker base images by digest.",
|
|
322
|
+
),
|
|
323
|
+
("scorecard", "scorecard.Token-Permissions"): StandardsEntry(
|
|
324
|
+
canonical_cwe="CWE-272", owasp_top10="A01", asvs_section="V4.1.5",
|
|
325
|
+
nist_ssdf="PO.5.2", category=Category.SUPPLY_CHAIN,
|
|
326
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
327
|
+
short_desc="GitHub workflow tokens granted excess permissions.",
|
|
328
|
+
fix_hint="Add `permissions: contents: read` at workflow root; elevate per-job only as needed.",
|
|
329
|
+
),
|
|
330
|
+
("scorecard", "scorecard.Security-Policy"): StandardsEntry(
|
|
331
|
+
canonical_cwe="CWE-1059", owasp_top10="A09", asvs_section="V0.2.1",
|
|
332
|
+
nist_ssdf="PO.4.1", category=Category.POLICY_DOCS,
|
|
333
|
+
severity=Severity.LOW, confidence=Confidence.HIGH,
|
|
334
|
+
short_desc="Repository is missing SECURITY.md.",
|
|
335
|
+
fix_hint="Add SECURITY.md with a vulnerability disclosure path. Use `github.com/<repo>/security/advisories/new` for the form.",
|
|
336
|
+
),
|
|
337
|
+
("scorecard", "scorecard.Dangerous-Workflow"): StandardsEntry(
|
|
338
|
+
canonical_cwe="CWE-94", owasp_top10="A03", asvs_section="V5.2.4",
|
|
339
|
+
nist_ssdf="PW.5.1", category=Category.SUPPLY_CHAIN,
|
|
340
|
+
severity=Severity.HIGH, confidence=Confidence.HIGH,
|
|
341
|
+
short_desc="Workflow uses untrusted input in a dangerous context.",
|
|
342
|
+
fix_hint="Avoid `${{ github.event.pull_request.title }}` in `run:` blocks. Use env vars instead.",
|
|
343
|
+
),
|
|
344
|
+
("scorecard", "*"): StandardsEntry(
|
|
345
|
+
canonical_cwe=None, owasp_top10="A08", asvs_section=None,
|
|
346
|
+
nist_ssdf="PO.5.1", category=Category.SUPPLY_CHAIN,
|
|
347
|
+
severity=Severity.MEDIUM, confidence=Confidence.MEDIUM,
|
|
348
|
+
short_desc="OpenSSF Scorecard check failed.",
|
|
349
|
+
fix_hint="See the documentation link in the finding message.",
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def lookup(scanner: str, rule_id: str) -> Optional[StandardsEntry]:
|
|
355
|
+
"""Resolve (scanner, rule_id) → StandardsEntry, with wildcard fallback.
|
|
356
|
+
|
|
357
|
+
Order: exact match → (scanner, "*") wildcard → None.
|
|
358
|
+
"""
|
|
359
|
+
exact = _MAP.get((scanner.lower(), rule_id))
|
|
360
|
+
if exact is not None:
|
|
361
|
+
return exact
|
|
362
|
+
return _MAP.get((scanner.lower(), "*"))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_top25(canonical_cwe: Optional[str]) -> bool:
|
|
366
|
+
"""Is this CWE on the MITRE Top 25 (2025) list? Used for scoring boost."""
|
|
367
|
+
return canonical_cwe is not None and canonical_cwe in CWE_TOP25_2025
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cwe_url(canonical_cwe: str) -> str:
|
|
371
|
+
"""Cite a CWE id — e.g. 'CWE-89' → MITRE definition URL."""
|
|
372
|
+
num = canonical_cwe.split("-", 1)[1] if "-" in canonical_cwe else canonical_cwe
|
|
373
|
+
return f"https://cwe.mitre.org/data/definitions/{num}.html"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def owasp_url(owasp_id: str) -> str:
|
|
377
|
+
"""OWASP Top 10 bucket id → deep-link URL. Falls back to the index
|
|
378
|
+
when the id doesn't map to a known bucket (e.g. legacy 2017 ids)."""
|
|
379
|
+
bucket = owasp_id.split(":", 1)[0] if ":" in owasp_id else owasp_id
|
|
380
|
+
label = OWASP_TOP10_2021.get(bucket)
|
|
381
|
+
if not label:
|
|
382
|
+
return OWASP_TOP10_2021_URL
|
|
383
|
+
# 'A03:2021-Injection' → 'A03_2021-Injection' for the URL slug.
|
|
384
|
+
slug = label.replace(":", "_").replace(" ", "_")
|
|
385
|
+
return f"{OWASP_TOP10_2021_URL}{slug}/"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def owasp_label(owasp_id: str) -> str:
|
|
389
|
+
"""'A03' → 'A03:2021-Injection'. Returns the id unchanged if not mapped."""
|
|
390
|
+
bucket = owasp_id.split(":", 1)[0] if ":" in owasp_id else owasp_id
|
|
391
|
+
return OWASP_TOP10_2021.get(bucket, owasp_id)
|