traceforge-toolkit 0.1.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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,553 @@
1
+ """Risk scoring for shell commands — analytics enrichment metadata.
2
+
3
+ Produces a 0-100 risk score by combining:
4
+ Layer 1: Structural score from Classification (effect × scope)
5
+ Layer 2: Flag modifiers from parsed command tokens
6
+ Layer 3: Injection/evasion pattern bonuses (capped)
7
+ Pipeline taint: Escalation bonus for pipe-connected source→sink flows
8
+ Context: Adjustments for project-relative path targeting
9
+
10
+ All rule data comes from risk.yaml via ClassificationEngine.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from dataclasses import dataclass
18
+ from fnmatch import fnmatch
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ if TYPE_CHECKING:
22
+ from traceforge.classify.config import ClassificationEngine
23
+ from traceforge.classify.core import Classification
24
+
25
+
26
+ # ── Module-level constants for O(1) lookups ──
27
+
28
+ _DEFAULT_SENSITIVITY_BONUSES: dict[str, int] = {"secrets": 20, "system": 14}
29
+
30
+ _EXECUTION_SINKS = frozenset(
31
+ {
32
+ "bash",
33
+ "sh",
34
+ "zsh",
35
+ "python",
36
+ "python3",
37
+ "perl",
38
+ "ruby",
39
+ "node",
40
+ "eval",
41
+ }
42
+ )
43
+ _NETWORK_SINKS = frozenset({"curl", "wget", "nc", "ncat", "socat", "telnet"})
44
+ _EXECUTION_EFFECTS = frozenset({"destructive", "mutating"})
45
+ _NETWORK_SOURCES = frozenset({"curl", "wget", "fetch"})
46
+ _SENSITIVE_CATEGORIES = frozenset({"secrets", "system"})
47
+ _RELEVANT_CAPABILITIES = frozenset({"network_outbound", "elevated_privilege"})
48
+ _BUILD_DIRS = frozenset(("build/", "dist/", "target/", "node_modules/", "__pycache__/", ".git/"))
49
+
50
+
51
+ class Confidence:
52
+ """Risk assessment confidence levels."""
53
+
54
+ HIGH = "high" # Known binary + known flags + classified effect
55
+ MEDIUM = "medium" # Known binary but unknown flags, or known effect only
56
+ LOW = "low" # Unknown binary or unclassified effect
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class RiskAssessment:
61
+ """Immutable risk scoring result."""
62
+
63
+ score: int # 0-100
64
+ level: str # safe / caution / danger / critical
65
+ confidence: str # high / medium / low
66
+ factors: tuple[str, ...]
67
+ mitre: tuple[str, ...]
68
+ version: str
69
+
70
+
71
+ def assess_risk(
72
+ classification: Classification,
73
+ command: str,
74
+ *,
75
+ engine: ClassificationEngine,
76
+ binary: str = "",
77
+ flags: list[str] | None = None,
78
+ targets: list[str] | None = None,
79
+ pipe_segments: list[dict[str, Any]] | None = None,
80
+ project_root: str | None = None,
81
+ ) -> RiskAssessment:
82
+ """Score a shell command's risk from its classification and parsed tokens.
83
+
84
+ Args:
85
+ classification: The existing Classification for this command.
86
+ command: Raw command string (used for pattern matching).
87
+ engine: ClassificationEngine with risk config loaded.
88
+ binary: Primary binary name (already extracted by shell classifier).
89
+ flags: Parsed flags from _unwrap_binary (already expanded).
90
+ targets: File path arguments extracted from the command.
91
+ pipe_segments: Optional list of per-segment info for pipeline taint.
92
+ Each dict has keys: binary, effect, targets (list[str]).
93
+ project_root: Optional project root path for context adjustments.
94
+
95
+ Returns:
96
+ RiskAssessment with score, level, factors, and MITRE mappings.
97
+ """
98
+ risk_cfg = engine.risk_config
99
+ if risk_cfg is None:
100
+ return RiskAssessment(
101
+ score=0,
102
+ level="safe",
103
+ confidence=Confidence.LOW,
104
+ factors=(),
105
+ mitre=(),
106
+ version="risk-v2",
107
+ )
108
+
109
+ factors: list[str] = []
110
+ mitre_ids: list[str] = []
111
+ flags = flags or []
112
+ targets = targets or []
113
+
114
+ # ── Layer 1: Structural score ──
115
+ effect_str = classification.effect or "unknown"
116
+ intent_weights: dict[str, int] = risk_cfg.get("intent_weights", {})
117
+ base_score = intent_weights.get(effect_str, intent_weights.get("unknown", 50))
118
+ scope_bonus = _compute_scope_bonus(classification, targets, risk_cfg)
119
+
120
+ structural = base_score + scope_bonus
121
+
122
+ # ── Layer 2: Flag modifiers ──
123
+ flag_modifiers: dict[str, list[dict]] = risk_cfg.get("flag_modifiers", {})
124
+ flag_bonus = _compute_flag_bonus(binary, flags, flag_modifiers, factors, mitre_ids)
125
+ structural += flag_bonus
126
+
127
+ # GTFOBins modifier
128
+ gtfobins = risk_cfg.get("gtfobins", {})
129
+ gtfobins_mod = risk_cfg.get("gtfobins_modifier", 10)
130
+ if _is_gtfobins_relevant(binary, classification, gtfobins):
131
+ structural += gtfobins_mod
132
+ factors.append("gtfobins_capability")
133
+
134
+ # ── Layer 3: Injection patterns (capped) ──
135
+ max_pattern_bonus: int = risk_cfg.get("max_pattern_bonus", 30)
136
+ injection_patterns: list[dict] = risk_cfg.get("injection_patterns", [])
137
+ pattern_bonus = _compute_pattern_bonus(
138
+ command, injection_patterns, max_pattern_bonus, factors, mitre_ids
139
+ )
140
+
141
+ # ── Pipeline taint ──
142
+ taint_bonus = 0
143
+ if pipe_segments and len(pipe_segments) >= 2:
144
+ taint_rules: list[dict] = risk_cfg.get("taint_rules", [])
145
+ encoding_commands: list[str] = risk_cfg.get("encoding_commands", [])
146
+ sensitive_paths: dict[str, list[str]] = risk_cfg.get("sensitive_paths", {})
147
+ taint_bonus = _compute_taint_bonus(
148
+ pipe_segments, taint_rules, encoding_commands, sensitive_paths, factors, mitre_ids
149
+ )
150
+
151
+ # ── Context adjustment ──
152
+ context_adj = 0
153
+ context_adjustments: dict[str, int] = risk_cfg.get("context_adjustments", {})
154
+ if project_root and targets:
155
+ context_adj = _compute_context_adjustment(targets, project_root, context_adjustments)
156
+ elif not project_root:
157
+ context_adj = context_adjustments.get("no_context", 5)
158
+
159
+ # ── Final score ──
160
+ raw_score = structural + pattern_bonus + taint_bonus + context_adj
161
+ final_score = max(0, min(100, raw_score))
162
+
163
+ # Determine level
164
+ levels: dict[str, list[int]] = risk_cfg.get("levels", {})
165
+ level = _score_to_level(final_score, levels)
166
+
167
+ # Determine confidence
168
+ confidence = _compute_confidence(classification, binary, flags)
169
+
170
+ version: str = risk_cfg.get("version", "risk-v2")
171
+
172
+ return RiskAssessment(
173
+ score=final_score,
174
+ level=level,
175
+ confidence=confidence,
176
+ factors=tuple(dict.fromkeys(factors)), # dedupe preserving order
177
+ mitre=tuple(dict.fromkeys(mitre_ids)),
178
+ version=version,
179
+ )
180
+
181
+
182
+ def assess_tool_risk(
183
+ classification: Classification,
184
+ *,
185
+ engine: ClassificationEngine,
186
+ targets: list[str] | None = None,
187
+ project_root: str | None = None,
188
+ ) -> RiskAssessment:
189
+ """Score a native/MCP tool's risk from its classification and targets.
190
+
191
+ Simpler model than shell scoring — no flag parsing, pipe taint, or
192
+ injection patterns. Scores from:
193
+ - Intent base (effect)
194
+ - Scope modifier (classification scope)
195
+ - Capability escalation (network, elevated privilege, subprocess)
196
+ - Target sensitivity (file path sensitivity)
197
+ - Context adjustment (project-relative targeting)
198
+
199
+ Args:
200
+ classification: The Classification for this tool invocation.
201
+ engine: ClassificationEngine with risk config loaded.
202
+ targets: File path arguments extracted from the event payload.
203
+ project_root: Optional project root path for context adjustments.
204
+
205
+ Returns:
206
+ RiskAssessment with score, level, confidence, factors, and version.
207
+ """
208
+ risk_cfg = engine.risk_config
209
+ if risk_cfg is None:
210
+ return RiskAssessment(
211
+ score=0,
212
+ level="safe",
213
+ confidence=Confidence.LOW,
214
+ factors=(),
215
+ mitre=(),
216
+ version="risk-v2",
217
+ )
218
+
219
+ factors: list[str] = []
220
+ mitre_ids: list[str] = []
221
+ targets = targets or []
222
+
223
+ # ── Intent base ──
224
+ effect_str = classification.effect or "unknown"
225
+ intent_weights: dict[str, int] = risk_cfg.get("intent_weights", {})
226
+ base_score = intent_weights.get(effect_str, intent_weights.get("unknown", 24))
227
+ scope_bonus = _compute_scope_bonus(classification, targets, risk_cfg)
228
+
229
+ structural = base_score + scope_bonus
230
+
231
+ # ── Capability escalation ──
232
+ cap_weights: dict[str, int] = risk_cfg.get("capability_weights", {})
233
+ for cap in classification.capability:
234
+ cap_mod = cap_weights.get(cap, 0)
235
+ if cap_mod > 0:
236
+ structural += cap_mod
237
+ factors.append(f"capability_{cap}")
238
+
239
+ # ── Context adjustment ──
240
+ context_adj = 0
241
+ context_adjustments: dict[str, int] = risk_cfg.get("context_adjustments", {})
242
+ if project_root and targets:
243
+ context_adj = _compute_context_adjustment(targets, project_root, context_adjustments)
244
+ elif not project_root:
245
+ context_adj = context_adjustments.get("no_context", 4)
246
+
247
+ # ── Final score ──
248
+ raw_score = structural + context_adj
249
+ final_score = max(0, min(100, raw_score))
250
+
251
+ levels: dict[str, list[int]] = risk_cfg.get("levels", {})
252
+ level = _score_to_level(final_score, levels)
253
+ confidence = _compute_confidence(classification, "", [])
254
+ version: str = risk_cfg.get("version", "risk-v2")
255
+
256
+ return RiskAssessment(
257
+ score=final_score,
258
+ level=level,
259
+ confidence=confidence,
260
+ factors=tuple(dict.fromkeys(factors)),
261
+ mitre=tuple(dict.fromkeys(mitre_ids)),
262
+ version=version,
263
+ )
264
+
265
+
266
+ # ── Private helpers ──
267
+
268
+
269
+ def _compute_confidence(
270
+ classification: Classification,
271
+ binary: str,
272
+ flags: list[str],
273
+ ) -> str:
274
+ """Determine confidence level of the risk assessment.
275
+
276
+ High: known binary with classified effect and parsed flags.
277
+ Medium: known binary but unclassified effect, or classified effect but no binary.
278
+ Low: unknown binary and unclassified effect.
279
+ """
280
+ has_binary = bool(binary)
281
+ has_effect = classification.effect is not None
282
+ has_flags = bool(flags)
283
+
284
+ if has_binary and has_effect:
285
+ return Confidence.HIGH if has_flags else Confidence.MEDIUM
286
+ if has_binary or has_effect:
287
+ return Confidence.MEDIUM
288
+ return Confidence.LOW
289
+
290
+
291
+ def _expand_short_flags(flags: list[str]) -> list[str]:
292
+ """Expand combined short flags: -rf → -r, -f.
293
+
294
+ Preserves single-dash long flags (e.g., -delete, -exec) which are common
295
+ in GNU tools like find. Heuristic: only expand if ALL chars after '-' are
296
+ ASCII letters and the flag is 2-4 chars total (typical combined range).
297
+ """
298
+ expanded: list[str] = []
299
+ for flag in flags:
300
+ if (
301
+ flag.startswith("-")
302
+ and not flag.startswith("--")
303
+ and len(flag) > 2
304
+ and len(flag) <= 4
305
+ and all(c.isascii() and c.isalpha() for c in flag[1:])
306
+ ):
307
+ for char in flag[1:]:
308
+ expanded.append(f"-{char}")
309
+ else:
310
+ expanded.append(flag)
311
+ return expanded
312
+
313
+
314
+ def _compute_flag_bonus(
315
+ binary: str,
316
+ flags: list[str],
317
+ flag_modifiers: dict[str, list[dict]],
318
+ factors: list[str],
319
+ mitre_ids: list[str],
320
+ ) -> int:
321
+ """Compute additive flag-based risk modifier."""
322
+ rules = flag_modifiers.get(binary, [])
323
+ if not rules:
324
+ return 0
325
+
326
+ expanded = _expand_short_flags(flags)
327
+ # Union of expanded and originals for matching (handles both -rf and -delete)
328
+ all_flags_set = set(expanded) | set(flags)
329
+ bonus = 0
330
+
331
+ for rule in rules:
332
+ rule_flags: list[str] = rule.get("flags", [])
333
+ if not rule_flags:
334
+ # Empty flags list means "binary presence alone triggers"
335
+ bonus += rule.get("modifier", 0)
336
+ if rule.get("factor"):
337
+ factors.append(rule["factor"])
338
+ if rule.get("mitre"):
339
+ mitre_ids.append(rule["mitre"])
340
+ continue
341
+
342
+ requires_all: bool = rule.get("requires_all", False)
343
+ if requires_all:
344
+ matched = all(f in all_flags_set for f in rule_flags)
345
+ else:
346
+ matched = any(f in all_flags_set for f in rule_flags)
347
+
348
+ if matched:
349
+ bonus += rule.get("modifier", 0)
350
+ if rule.get("factor"):
351
+ factors.append(rule["factor"])
352
+ if rule.get("mitre"):
353
+ mitre_ids.append(rule["mitre"])
354
+
355
+ return bonus
356
+
357
+
358
+ def _compute_pattern_bonus(
359
+ command: str,
360
+ patterns: list[dict],
361
+ max_bonus: int,
362
+ factors: list[str],
363
+ mitre_ids: list[str],
364
+ ) -> int:
365
+ """Compute capped injection/evasion pattern bonus."""
366
+ total = 0
367
+ for pat_def in patterns:
368
+ pattern_str = pat_def.get("pattern", "")
369
+ if not pattern_str:
370
+ continue
371
+ try:
372
+ if re.search(pattern_str, command):
373
+ total += pat_def.get("score", 0)
374
+ if pat_def.get("factor"):
375
+ factors.append(pat_def["factor"])
376
+ if pat_def.get("mitre"):
377
+ mitre_ids.append(pat_def["mitre"])
378
+ except re.error:
379
+ continue
380
+ return min(total, max_bonus)
381
+
382
+
383
+ def _compute_scope_bonus(
384
+ classification: Classification,
385
+ targets: list[str],
386
+ risk_cfg: dict[str, Any],
387
+ ) -> int:
388
+ """Compute scope modifier from classification scope and target sensitivity."""
389
+ scope_modifiers: dict[str, int] = risk_cfg.get("scope_modifiers", {})
390
+ scope_bonus = max(
391
+ (scope_modifiers.get(s, 0) for s in classification.scope),
392
+ default=0,
393
+ )
394
+ sensitive_paths = risk_cfg.get("sensitive_paths", {})
395
+ path_sensitivity = _check_sensitive_paths(targets, sensitive_paths)
396
+ if path_sensitivity:
397
+ bonuses = risk_cfg.get("sensitive_path_bonuses", _DEFAULT_SENSITIVITY_BONUSES)
398
+ scope_bonus = max(scope_bonus, bonuses.get(path_sensitivity, 0))
399
+ return scope_bonus
400
+
401
+
402
+ def _check_sensitive_paths(targets: list[str], sensitive_paths: dict[str, list[str]]) -> str | None:
403
+ """Check if any target matches a sensitive path pattern. Returns category or None.
404
+
405
+ Builds a flat (pattern, category) index on first call per invocation to
406
+ avoid O(n³) nested iteration.
407
+ """
408
+ if not targets or not sensitive_paths:
409
+ return None
410
+ # Flatten to list of (pattern, suffix, category) for single-pass matching
411
+ flat_rules: list[tuple[str, str, str]] = [
412
+ (pat, pat.lstrip("*"), category)
413
+ for category, patterns in sensitive_paths.items()
414
+ for pat in patterns
415
+ ]
416
+ for target in targets:
417
+ for pattern, suffix, category in flat_rules:
418
+ if fnmatch(target, pattern) or target.endswith(suffix):
419
+ return category
420
+ return None
421
+
422
+
423
+ def _compute_taint_bonus(
424
+ pipe_segments: list[dict[str, Any]],
425
+ taint_rules: list[dict],
426
+ encoding_commands: list[str],
427
+ sensitive_paths: dict[str, list[str]],
428
+ factors: list[str],
429
+ mitre_ids: list[str],
430
+ ) -> int:
431
+ """Compute pipeline taint escalation bonus.
432
+
433
+ Only applies across pipe-connected segments (not ; or &&).
434
+ """
435
+ # Check all adjacent pairs: any segment can be a source feeding the next as sink.
436
+ # Also check middle segments for dangerous sinks (e.g., cat foo | sh | tee out).
437
+ best_escalation = 0
438
+ has_encoding = any(seg.get("binary") in encoding_commands for seg in pipe_segments[1:-1])
439
+
440
+ for i in range(len(pipe_segments) - 1):
441
+ source_seg = pipe_segments[i]
442
+ sink_seg = pipe_segments[i + 1]
443
+
444
+ source_type = _classify_taint_source(source_seg, sensitive_paths)
445
+ sink_type = _classify_taint_sink(sink_seg)
446
+
447
+ for rule in taint_rules:
448
+ if rule["source"] == source_type or (
449
+ rule["source"] == "any_read" and source_type in ("sensitive_read", "any_read")
450
+ ):
451
+ if rule["sink"] == sink_type:
452
+ if rule.get("has_encoding") and not has_encoding:
453
+ continue
454
+ escalation = rule.get("escalation", 0)
455
+ if escalation > best_escalation:
456
+ best_escalation = escalation
457
+ factors.append(rule.get("factor", "taint_flow"))
458
+ if rule.get("mitre"):
459
+ mitre_ids.append(rule["mitre"])
460
+
461
+ return best_escalation
462
+
463
+
464
+ def _classify_taint_source(segment: dict[str, Any], sensitive_paths: dict[str, list[str]]) -> str:
465
+ """Classify a pipe source segment."""
466
+ targets = segment.get("targets", [])
467
+ if targets:
468
+ sensitivity = _check_sensitive_paths(targets, sensitive_paths)
469
+ if sensitivity in _SENSITIVE_CATEGORIES:
470
+ return "sensitive_read"
471
+
472
+ if segment.get("binary", "") in _NETWORK_SOURCES:
473
+ return "network"
474
+
475
+ return "any_read"
476
+
477
+
478
+ def _classify_taint_sink(segment: dict[str, Any]) -> str:
479
+ """Classify a pipe sink segment."""
480
+ binary = segment.get("binary", "")
481
+ if binary in _EXECUTION_SINKS:
482
+ return "execution"
483
+ if segment.get("effect", "") in _EXECUTION_EFFECTS:
484
+ return "execution"
485
+ if binary in _NETWORK_SINKS:
486
+ return "network"
487
+ return "other"
488
+
489
+
490
+ def _is_gtfobins_relevant(
491
+ binary: str,
492
+ classification: Classification,
493
+ gtfobins: dict[str, list[str]],
494
+ ) -> bool:
495
+ """Check if binary has GTFOBins capability relevant to the classification."""
496
+ if not binary:
497
+ return False
498
+ # Build flat set from all categories for O(1) lookup
499
+ all_bins = {b for bins in gtfobins.values() for b in bins}
500
+ if binary not in all_bins:
501
+ return False
502
+ return bool(classification.capability & _RELEVANT_CAPABILITIES)
503
+
504
+
505
+ def _compute_context_adjustment(
506
+ targets: list[str],
507
+ project_root: str,
508
+ adjustments: dict[str, int],
509
+ ) -> int:
510
+ """Compute context-based score adjustment."""
511
+ if not targets:
512
+ return 0
513
+
514
+ adj = 0
515
+ # Normalize project_root for consistent comparison
516
+ norm_root = os.path.normpath(project_root) if project_root else ""
517
+ for target in targets:
518
+ if not target or not isinstance(target, str):
519
+ continue
520
+ norm_target = os.path.normpath(target)
521
+ # Resolve relative paths: ./foo, foo/bar, ../etc → check if they escape
522
+ is_relative = not os.path.isabs(norm_target)
523
+ if is_relative:
524
+ # Relative targets that traverse upward could escape the project
525
+ if norm_target.startswith(".."):
526
+ return max(adj, adjustments.get("escapes_project", 20))
527
+ adj = min(adj, adjustments.get("inside_project", -10))
528
+ if any(d in norm_target.split(os.sep) for d in _BUILD_DIRS):
529
+ adj = min(
530
+ adj,
531
+ adjustments.get("inside_build_dir", -5)
532
+ + adjustments.get("inside_project", -10),
533
+ )
534
+ elif norm_root and norm_target.startswith(norm_root):
535
+ adj = min(adj, adjustments.get("inside_project", -10))
536
+ if any(d in norm_target.split(os.sep) for d in _BUILD_DIRS):
537
+ adj = min(
538
+ adj,
539
+ adjustments.get("inside_build_dir", -5)
540
+ + adjustments.get("inside_project", -10),
541
+ )
542
+ else:
543
+ return max(adj, adjustments.get("escapes_project", 20))
544
+
545
+ return adj
546
+
547
+
548
+ def _score_to_level(score: int, levels: dict[str, list[int]]) -> str:
549
+ """Map a numeric score to a risk level name."""
550
+ for level_name, (low, high) in levels.items():
551
+ if low <= score <= high:
552
+ return level_name
553
+ return "critical" if score > 80 else "safe"