Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions src/skillspector/nodes/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ def _severity_to_sarif_level(severity: str) -> Literal["error", "warning", "note


def _compute_risk_score(
findings: list[Finding], has_executable_scripts: bool
findings: list[Finding],
has_executable_scripts: bool,
component_metadata: list[dict[str, object]] | None = None,
) -> tuple[int, str, str]:
"""
Compute risk score (0-100), severity band, and recommendation.
Expand All @@ -98,8 +100,16 @@ def _compute_risk_score(
This prevents repeated pattern matches from inflating the score unboundedly.

Base points per severity: CRITICAL=50, HIGH=25, MEDIUM=10, LOW=5.
Multiplier: 1.3x if has_executable_scripts.
1.3x multiplier applied only to findings from executable script files;
findings from documentation files (markdown, text, json, yaml, toml)
are scored at base weight to avoid punishing security documentation.
"""
# Build lookup: file path -> is_executable
file_executable: dict[str, bool] = {}
if component_metadata:
for cm in component_metadata:
file_executable[str(cm.get("path", ""))] = bool(cm.get("executable", False))

rule_occurrence_count: dict[str, int] = {}
score = 0.0

Expand All @@ -119,10 +129,13 @@ def _compute_risk_score(
continue

weight = _DIMINISHING_WEIGHTS[count]
score += base_points * weight * confidence
contribution = base_points * weight * confidence

# Apply 1.3x multiplier only to findings from executable files
if has_executable_scripts and file_executable.get(f.file, False):
contribution *= 1.3

if has_executable_scripts:
score *= 1.3
score += contribution

final_score = min(100, max(0, int(score)))

Expand Down Expand Up @@ -552,7 +565,7 @@ def report(state: SkillspectorState) -> dict[str, object]:
# additionally de-duplicates so the same issue is not counted twice.
findings_for_scoring = deduplicate(active_findings)
risk_score, risk_severity, risk_recommendation = _compute_risk_score(
findings_for_scoring, has_executable_scripts
findings_for_scoring, has_executable_scripts, component_metadata
)
sarif_report = _build_sarif(active_findings, suppressed)
analysis_completeness = _build_analysis_completeness(
Expand Down Expand Up @@ -618,4 +631,4 @@ def report(state: SkillspectorState) -> dict[str, object]:
"filtered_findings": filtered_findings,
"suppressed_findings": suppressed,
}
return out
return out
53 changes: 49 additions & 4 deletions tests/nodes/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,9 @@ class TestComputeRiskScoreExecutableMultiplier:
"""Tests for the executable scripts multiplier."""

def test_executable_multiplier_applies(self) -> None:
findings = [_finding("R1", "HIGH", confidence=1.0)]
score, _, _ = _compute_risk_score(findings, True)
findings = [_finding("R1", "HIGH", confidence=1.0, file="run.py")]
component_metadata = [{"path": "run.py", "executable": True}]
score, _, _ = _compute_risk_score(findings, True, component_metadata)
# 25 * 1.3 = 32.5 -> 32
assert score == 32

Expand Down Expand Up @@ -387,8 +388,8 @@ def test_report_executable_scripts_multiplier(self) -> None:
"""has_executable_scripts applies 1.3x to risk score."""
state: SkillspectorState = {
"filtered_findings": [
_finding("E2", "HIGH", confidence=1.0),
_finding("PE3", "HIGH", confidence=1.0),
_finding("E2", "HIGH", confidence=1.0, file="run.py"),
_finding("PE3", "HIGH", confidence=1.0, file="run.py"),
],
"component_metadata": [
{
Expand Down Expand Up @@ -626,3 +627,47 @@ def test_report_no_baseline_unchanged() -> None:
result = report(state)
assert result["risk_score"] == 50
assert result["suppressed_findings"] == []

def test_report_executable_scripts_multiplier() -> None:
"""1.3x multiplier applied only to findings from executable files."""
# 2 HIGH findings in run.py = 2 × 25 × 1.3 = 65 (float-based accumulation)
state: SkillspectorState = {
"filtered_findings": [
_finding("E2", "HIGH", file="run.py"),
_finding("PE3", "HIGH", file="run.py"),
],
"component_metadata": [
{"path": "run.py", "type": "python", "lines": 5, "executable": True, "size_bytes": 200}
],
"has_executable_scripts": True,
"manifest": {},
"skill_path": "/tmp/skill",
"output_format": "json",
}
result = report(state)
assert result["risk_score"] == 65
assert result["risk_severity"] == "HIGH"
assert result["risk_recommendation"] == "DO_NOT_INSTALL"


def test_report_doc_findings_no_multiplier() -> None:
"""Findings from non-executable files (markdown/docs) are not multiplied."""
# 2 HIGH in SKILL.md (non-executable) = 2 × 25 = 50 (no 1.3x)
state: SkillspectorState = {
"filtered_findings": [
_finding("P1", "HIGH", file="SKILL.md"),
_finding("P2", "HIGH", file="SKILL.md"),
],
"component_metadata": [
{"path": "SKILL.md", "type": "markdown", "lines": 10, "executable": False, "size_bytes": 500},
{"path": "run.py", "type": "python", "lines": 5, "executable": True, "size_bytes": 200}
],
"has_executable_scripts": True,
"manifest": {},
"skill_path": "/tmp/skill",
"output_format": "json",
}
result = report(state)
# Without the multiplier: 2 HIGH = 50, not 65
assert result["risk_score"] == 50
assert result["risk_severity"] == "MEDIUM"