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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- 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"
|