reporails-cli 0.0.1__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.
Potentially problematic release.
This version of reporails-cli might be problematic. Click here for more details.
- reporails_cli/.env.example +1 -0
- reporails_cli/__init__.py +24 -0
- reporails_cli/bundled/.semgrepignore +51 -0
- reporails_cli/bundled/__init__.py +31 -0
- reporails_cli/bundled/capability-patterns.yml +54 -0
- reporails_cli/bundled/levels.yml +99 -0
- reporails_cli/core/__init__.py +35 -0
- reporails_cli/core/agents.py +147 -0
- reporails_cli/core/applicability.py +150 -0
- reporails_cli/core/bootstrap.py +147 -0
- reporails_cli/core/cache.py +352 -0
- reporails_cli/core/capability.py +245 -0
- reporails_cli/core/discover.py +362 -0
- reporails_cli/core/engine.py +177 -0
- reporails_cli/core/init.py +309 -0
- reporails_cli/core/levels.py +177 -0
- reporails_cli/core/models.py +329 -0
- reporails_cli/core/opengrep/__init__.py +34 -0
- reporails_cli/core/opengrep/runner.py +203 -0
- reporails_cli/core/opengrep/semgrepignore.py +39 -0
- reporails_cli/core/opengrep/templates.py +138 -0
- reporails_cli/core/registry.py +155 -0
- reporails_cli/core/sarif.py +181 -0
- reporails_cli/core/scorer.py +178 -0
- reporails_cli/core/semantic.py +193 -0
- reporails_cli/core/utils.py +139 -0
- reporails_cli/formatters/__init__.py +19 -0
- reporails_cli/formatters/json.py +137 -0
- reporails_cli/formatters/mcp.py +68 -0
- reporails_cli/formatters/text/__init__.py +32 -0
- reporails_cli/formatters/text/box.py +89 -0
- reporails_cli/formatters/text/chars.py +42 -0
- reporails_cli/formatters/text/compact.py +119 -0
- reporails_cli/formatters/text/components.py +117 -0
- reporails_cli/formatters/text/full.py +135 -0
- reporails_cli/formatters/text/rules.py +50 -0
- reporails_cli/formatters/text/violations.py +92 -0
- reporails_cli/interfaces/__init__.py +1 -0
- reporails_cli/interfaces/cli/__init__.py +7 -0
- reporails_cli/interfaces/cli/main.py +352 -0
- reporails_cli/interfaces/mcp/__init__.py +5 -0
- reporails_cli/interfaces/mcp/server.py +194 -0
- reporails_cli/interfaces/mcp/tools.py +136 -0
- reporails_cli/py.typed +0 -0
- reporails_cli/templates/__init__.py +65 -0
- reporails_cli/templates/cli_box.txt +10 -0
- reporails_cli/templates/cli_cta.txt +4 -0
- reporails_cli/templates/cli_delta.txt +1 -0
- reporails_cli/templates/cli_file_header.txt +1 -0
- reporails_cli/templates/cli_legend.txt +1 -0
- reporails_cli/templates/cli_pending.txt +3 -0
- reporails_cli/templates/cli_violation.txt +1 -0
- reporails_cli/templates/cli_working.txt +2 -0
- reporails_cli-0.0.1.dist-info/METADATA +108 -0
- reporails_cli-0.0.1.dist-info/RECORD +58 -0
- reporails_cli-0.0.1.dist-info/WHEEL +4 -0
- reporails_cli-0.0.1.dist-info/entry_points.txt +3 -0
- reporails_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Template resolution for OpenGrep rule files.
|
|
2
|
+
|
|
3
|
+
Handles {{placeholder}} substitution in .yml rule configs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Template placeholder pattern
|
|
12
|
+
TEMPLATE_PATTERN = re.compile(r"\{\{(\w+)\}\}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _glob_to_regex(glob_pattern: str, for_yaml: bool = True) -> str:
|
|
16
|
+
"""Convert a glob pattern to a regex pattern.
|
|
17
|
+
|
|
18
|
+
Handles common glob syntax:
|
|
19
|
+
- ** -> .* (match any path)
|
|
20
|
+
- * -> [^/]* (match any chars except /)
|
|
21
|
+
- . -> \\. (escape literal dot)
|
|
22
|
+
- Other special regex chars are escaped
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
glob_pattern: Glob pattern like "**/CLAUDE.md"
|
|
26
|
+
for_yaml: If True, double-escape backslashes for YAML double-quoted strings
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Regex pattern like ".*CLAUDE\\.md"
|
|
30
|
+
"""
|
|
31
|
+
# Remove leading **/ (matches any directory prefix)
|
|
32
|
+
pattern = glob_pattern
|
|
33
|
+
if pattern.startswith("**/"):
|
|
34
|
+
pattern = pattern[3:]
|
|
35
|
+
|
|
36
|
+
# Escape regex special chars except * and ?
|
|
37
|
+
result = ""
|
|
38
|
+
i = 0
|
|
39
|
+
while i < len(pattern):
|
|
40
|
+
c = pattern[i]
|
|
41
|
+
if c == "*":
|
|
42
|
+
if i + 1 < len(pattern) and pattern[i + 1] == "*":
|
|
43
|
+
# ** matches anything including /
|
|
44
|
+
result += ".*"
|
|
45
|
+
i += 2
|
|
46
|
+
# Skip trailing / after **
|
|
47
|
+
if i < len(pattern) and pattern[i] == "/":
|
|
48
|
+
i += 1
|
|
49
|
+
else:
|
|
50
|
+
# * matches anything except /
|
|
51
|
+
result += "[^/]*"
|
|
52
|
+
i += 1
|
|
53
|
+
elif c == "?":
|
|
54
|
+
result += "."
|
|
55
|
+
i += 1
|
|
56
|
+
elif c in ".^$+{}[]|()":
|
|
57
|
+
# Escape for regex, double-escape for YAML if needed
|
|
58
|
+
escape = "\\\\" if for_yaml else "\\"
|
|
59
|
+
result += escape + c
|
|
60
|
+
i += 1
|
|
61
|
+
else:
|
|
62
|
+
result += c
|
|
63
|
+
i += 1
|
|
64
|
+
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def has_templates(yml_path: Path) -> bool:
|
|
69
|
+
"""Check if yml file contains template placeholders.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
yml_path: Path to yml file
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if templates found
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
content = yml_path.read_text(encoding="utf-8")
|
|
79
|
+
return bool(TEMPLATE_PATTERN.search(content))
|
|
80
|
+
except OSError:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_templates(yml_path: Path, context: dict[str, str | list[str]]) -> str:
|
|
85
|
+
"""Resolve template placeholders in yml content.
|
|
86
|
+
|
|
87
|
+
Replaces {{placeholder}} with values from context.
|
|
88
|
+
Context-aware resolution:
|
|
89
|
+
- In array context (paths.include), expands list to multiple items
|
|
90
|
+
- In pattern-regex context, converts globs to regex and joins with |
|
|
91
|
+
- For string values, does simple substitution
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
yml_path: Path to yml file
|
|
95
|
+
context: Dict mapping placeholder names to string or list values
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Resolved yml content
|
|
99
|
+
"""
|
|
100
|
+
content = yml_path.read_text(encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
for key, value in context.items():
|
|
103
|
+
placeholder = "{{" + key + "}}"
|
|
104
|
+
if placeholder not in content:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if isinstance(value, list):
|
|
108
|
+
# Find the line with the placeholder and its indentation
|
|
109
|
+
lines = content.split("\n")
|
|
110
|
+
new_lines = []
|
|
111
|
+
for line in lines:
|
|
112
|
+
if placeholder in line:
|
|
113
|
+
stripped = line.lstrip()
|
|
114
|
+
indent = len(line) - len(stripped)
|
|
115
|
+
indent_str = " " * indent
|
|
116
|
+
|
|
117
|
+
# Check context: array (starts with -) or pattern-regex
|
|
118
|
+
if stripped.startswith("- "):
|
|
119
|
+
# Array context: expand to multiple list items
|
|
120
|
+
for item in value:
|
|
121
|
+
new_lines.append(f'{indent_str}- "{item}"')
|
|
122
|
+
elif "pattern-regex:" in line or "pattern-not-regex:" in line:
|
|
123
|
+
# Regex context: convert globs to regex, join with |
|
|
124
|
+
regex_patterns = [_glob_to_regex(g) for g in value]
|
|
125
|
+
combined = "(" + "|".join(regex_patterns) + ")"
|
|
126
|
+
new_lines.append(line.replace(placeholder, combined))
|
|
127
|
+
else:
|
|
128
|
+
# Other scalar context: use first item
|
|
129
|
+
first_item = value[0] if value else ""
|
|
130
|
+
new_lines.append(line.replace(placeholder, first_item))
|
|
131
|
+
else:
|
|
132
|
+
new_lines.append(line)
|
|
133
|
+
content = "\n".join(new_lines)
|
|
134
|
+
else:
|
|
135
|
+
# Simple string substitution
|
|
136
|
+
content = content.replace(placeholder, str(value))
|
|
137
|
+
|
|
138
|
+
return content
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Rule loading from markdown frontmatter. Pure functions where possible."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from reporails_cli.core.bootstrap import get_rules_path
|
|
9
|
+
from reporails_cli.core.models import Category, Check, Rule, RuleType, Severity
|
|
10
|
+
from reporails_cli.core.utils import parse_frontmatter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_rules_dir() -> Path:
|
|
14
|
+
"""Get rules directory (~/.reporails/rules/).
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Path to rules directory
|
|
18
|
+
"""
|
|
19
|
+
return get_rules_path()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_rules(rules_dir: Path | None = None) -> dict[str, Rule]:
|
|
23
|
+
"""Load all rules from rules directory.
|
|
24
|
+
|
|
25
|
+
Scans rules/**/*.md, parses frontmatter, links to .yml files.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
rules_dir: Path to rules directory (default: ~/.reporails/rules/)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dict mapping rule ID to Rule object
|
|
32
|
+
"""
|
|
33
|
+
if rules_dir is None:
|
|
34
|
+
rules_dir = get_rules_dir()
|
|
35
|
+
|
|
36
|
+
if not rules_dir.exists():
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
rules: dict[str, Rule] = {}
|
|
40
|
+
|
|
41
|
+
for md_path in rules_dir.rglob("*.md"):
|
|
42
|
+
try:
|
|
43
|
+
content = md_path.read_text(encoding="utf-8")
|
|
44
|
+
frontmatter = parse_frontmatter(content)
|
|
45
|
+
|
|
46
|
+
if not frontmatter:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Look for corresponding .yml file
|
|
50
|
+
yml_path_candidate = md_path.with_suffix(".yml")
|
|
51
|
+
yml_path: Path | None = yml_path_candidate if yml_path_candidate.exists() else None
|
|
52
|
+
|
|
53
|
+
rule = build_rule(frontmatter, md_path, yml_path)
|
|
54
|
+
rules[rule.id] = rule
|
|
55
|
+
|
|
56
|
+
except (ValueError, KeyError):
|
|
57
|
+
# Skip files without valid frontmatter
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
return rules
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_rule(frontmatter: dict[str, Any], md_path: Path, yml_path: Path | None) -> Rule:
|
|
64
|
+
"""Build Rule object from parsed frontmatter.
|
|
65
|
+
|
|
66
|
+
Pure function — validates and constructs.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
frontmatter: Parsed YAML dict
|
|
70
|
+
md_path: Path to source .md file
|
|
71
|
+
yml_path: Path to .yml file (optional)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Rule object
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
KeyError: If required fields missing
|
|
78
|
+
ValueError: If field values invalid
|
|
79
|
+
"""
|
|
80
|
+
# Parse checks (formerly antipatterns)
|
|
81
|
+
checks = []
|
|
82
|
+
# Support both "checks" and legacy "antipatterns" field names
|
|
83
|
+
check_data = frontmatter.get("checks", frontmatter.get("antipatterns", []))
|
|
84
|
+
for item in check_data:
|
|
85
|
+
check = Check(
|
|
86
|
+
id=item.get("id", ""),
|
|
87
|
+
name=item.get("name", ""),
|
|
88
|
+
severity=Severity(item.get("severity", "medium")),
|
|
89
|
+
)
|
|
90
|
+
checks.append(check)
|
|
91
|
+
|
|
92
|
+
return Rule(
|
|
93
|
+
id=frontmatter["id"],
|
|
94
|
+
title=frontmatter["title"],
|
|
95
|
+
category=Category(frontmatter["category"]),
|
|
96
|
+
type=RuleType(frontmatter["type"]),
|
|
97
|
+
level=frontmatter.get("level", "L2"), # Default to L2 (Basic) if not specified
|
|
98
|
+
checks=checks,
|
|
99
|
+
detection=frontmatter.get("detection"),
|
|
100
|
+
sources=frontmatter.get("sources", []),
|
|
101
|
+
see_also=frontmatter.get("see_also", []),
|
|
102
|
+
scoring=frontmatter.get("scoring", 0),
|
|
103
|
+
validation=frontmatter.get("validation"),
|
|
104
|
+
question=frontmatter.get("question"),
|
|
105
|
+
criteria=frontmatter.get("criteria"),
|
|
106
|
+
choices=frontmatter.get("choices"),
|
|
107
|
+
pass_value=frontmatter.get("pass_value"),
|
|
108
|
+
examples=frontmatter.get("examples"),
|
|
109
|
+
md_path=md_path,
|
|
110
|
+
yml_path=yml_path,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_rules_by_type(rules: dict[str, Rule], rule_type: RuleType) -> dict[str, Rule]:
|
|
115
|
+
"""Filter rules by type.
|
|
116
|
+
|
|
117
|
+
Pure function.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
rules: Dict of rules
|
|
121
|
+
rule_type: Type to filter by
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Filtered dict of rules
|
|
125
|
+
"""
|
|
126
|
+
return {k: v for k, v in rules.items() if v.type == rule_type}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_rules_by_category(rules: dict[str, Rule], category: Category) -> dict[str, Rule]:
|
|
130
|
+
"""Filter rules by category.
|
|
131
|
+
|
|
132
|
+
Pure function.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
rules: Dict of rules
|
|
136
|
+
category: Category to filter by
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Filtered dict of rules
|
|
140
|
+
"""
|
|
141
|
+
return {k: v for k, v in rules.items() if v.category == category}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_rule_yml_paths(rules: dict[str, Rule]) -> list[Path]:
|
|
145
|
+
"""Get list of .yml paths for rules that have them.
|
|
146
|
+
|
|
147
|
+
Pure function.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
rules: Dict of rules
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of paths to .yml files
|
|
154
|
+
"""
|
|
155
|
+
return [r.yml_path for r in rules.values() if r.yml_path is not None]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""SARIF parsing - converts OpenGrep output to domain objects.
|
|
2
|
+
|
|
3
|
+
All functions are pure (no I/O).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from reporails_cli.core.models import Rule, Severity, Violation
|
|
12
|
+
|
|
13
|
+
# Severity weights for scoring
|
|
14
|
+
SEVERITY_WEIGHTS: dict[Severity, float] = {
|
|
15
|
+
Severity.CRITICAL: 5.5,
|
|
16
|
+
Severity.HIGH: 4.0,
|
|
17
|
+
Severity.MEDIUM: 2.5,
|
|
18
|
+
Severity.LOW: 1.0,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_rule_id(sarif_rule_id: str) -> str:
|
|
23
|
+
"""Extract short rule ID from OpenGrep SARIF ruleId.
|
|
24
|
+
|
|
25
|
+
OpenGrep formats rule IDs as: checks.{category}.{id}-{slug}
|
|
26
|
+
Example: checks.structure.S1-many-h2-headings -> S1
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
sarif_rule_id: Full ruleId from SARIF output
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Short rule ID (e.g., S1, C10, M7)
|
|
33
|
+
"""
|
|
34
|
+
# Pattern: extract the ID part (letter + digits) from the last segment
|
|
35
|
+
match = re.search(r"\.([A-Z]\d+)-", sarif_rule_id)
|
|
36
|
+
if match:
|
|
37
|
+
return match.group(1)
|
|
38
|
+
return sarif_rule_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_check_slug(sarif_rule_id: str) -> str | None:
|
|
42
|
+
"""Extract check slug from OpenGrep SARIF ruleId.
|
|
43
|
+
|
|
44
|
+
OpenGrep formats rule IDs as: checks.{category}.{id}-{slug}
|
|
45
|
+
Example: checks.structure.S1-many-sections -> many-sections
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
sarif_rule_id: Full ruleId from SARIF output
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Check slug (e.g., "many-sections") or None
|
|
52
|
+
"""
|
|
53
|
+
match = re.search(r"\.[A-Z]\d+-(.+)$", sarif_rule_id)
|
|
54
|
+
if match:
|
|
55
|
+
return match.group(1)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_location(result: dict[str, Any]) -> str:
|
|
60
|
+
"""Extract location string from SARIF result.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
result: SARIF result object
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Location string in format "file:line"
|
|
67
|
+
"""
|
|
68
|
+
locations = result.get("locations", [])
|
|
69
|
+
if not locations:
|
|
70
|
+
return "unknown"
|
|
71
|
+
|
|
72
|
+
loc = locations[0].get("physicalLocation", {})
|
|
73
|
+
artifact = loc.get("artifactLocation", {}).get("uri", "unknown")
|
|
74
|
+
region = loc.get("region", {})
|
|
75
|
+
line = region.get("startLine", 0)
|
|
76
|
+
return f"{artifact}:{line}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_severity(rule: Rule | None, check_slug: str | None) -> Severity:
|
|
80
|
+
"""Get severity for a violation.
|
|
81
|
+
|
|
82
|
+
Looks up severity from rule's checks list, falls back to MEDIUM.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
rule: Rule object (may be None)
|
|
86
|
+
check_slug: Check slug from SARIF (may be None)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Severity level
|
|
90
|
+
"""
|
|
91
|
+
if rule is None:
|
|
92
|
+
return Severity.MEDIUM
|
|
93
|
+
|
|
94
|
+
# Try to find matching check
|
|
95
|
+
for check in rule.checks:
|
|
96
|
+
if check_slug and check_slug in check.id:
|
|
97
|
+
return check.severity
|
|
98
|
+
# Return first check's severity as default
|
|
99
|
+
return check.severity
|
|
100
|
+
|
|
101
|
+
return Severity.MEDIUM
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_sarif(sarif: dict[str, Any], rules: dict[str, Rule]) -> list[Violation]:
|
|
105
|
+
"""Parse OpenGrep SARIF output into Violation objects.
|
|
106
|
+
|
|
107
|
+
Pure function — no I/O. Skips INFO/note level findings.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
sarif: Parsed SARIF JSON
|
|
111
|
+
rules: Dict of rules for metadata lookup
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of Violation objects
|
|
115
|
+
"""
|
|
116
|
+
violations = []
|
|
117
|
+
|
|
118
|
+
for run in sarif.get("runs", []):
|
|
119
|
+
# Build map of rule levels from tool definitions
|
|
120
|
+
rule_levels: dict[str, str] = {}
|
|
121
|
+
tool = run.get("tool", {}).get("driver", {})
|
|
122
|
+
for rule_def in tool.get("rules", []):
|
|
123
|
+
rule_id = rule_def.get("id", "")
|
|
124
|
+
level = rule_def.get("defaultConfiguration", {}).get("level", "warning")
|
|
125
|
+
rule_levels[rule_id] = level
|
|
126
|
+
|
|
127
|
+
for result in run.get("results", []):
|
|
128
|
+
sarif_rule_id = result.get("ruleId", "")
|
|
129
|
+
|
|
130
|
+
# Skip INFO/note level findings
|
|
131
|
+
rule_level = rule_levels.get(sarif_rule_id, "warning")
|
|
132
|
+
if rule_level in ("note", "none"):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
short_rule_id = extract_rule_id(sarif_rule_id)
|
|
136
|
+
check_slug = extract_check_slug(sarif_rule_id)
|
|
137
|
+
message = result.get("message", {}).get("text", "")
|
|
138
|
+
location = get_location(result)
|
|
139
|
+
|
|
140
|
+
# Get rule metadata
|
|
141
|
+
rule = rules.get(short_rule_id)
|
|
142
|
+
title = rule.title if rule else sarif_rule_id
|
|
143
|
+
severity = get_severity(rule, check_slug)
|
|
144
|
+
|
|
145
|
+
violations.append(
|
|
146
|
+
Violation(
|
|
147
|
+
rule_id=short_rule_id,
|
|
148
|
+
rule_title=title,
|
|
149
|
+
location=location,
|
|
150
|
+
message=message,
|
|
151
|
+
severity=severity,
|
|
152
|
+
check_id=check_slug,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return violations
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def dedupe_violations(violations: list[Violation]) -> list[Violation]:
|
|
160
|
+
"""Deduplicate violations by (file, rule_id).
|
|
161
|
+
|
|
162
|
+
Keeps first occurrence of each unique (file, rule_id) pair.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
violations: List of violations (may have duplicates)
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Deduplicated list of violations
|
|
169
|
+
"""
|
|
170
|
+
seen: set[tuple[str, str]] = set()
|
|
171
|
+
result: list[Violation] = []
|
|
172
|
+
|
|
173
|
+
for v in violations:
|
|
174
|
+
file_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location
|
|
175
|
+
key = (file_path, v.rule_id)
|
|
176
|
+
|
|
177
|
+
if key not in seen:
|
|
178
|
+
seen.add(key)
|
|
179
|
+
result.append(v)
|
|
180
|
+
|
|
181
|
+
return result
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Scoring functions for reporails. All pure functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from reporails_cli.core.models import FrictionEstimate, Level, Severity, Violation
|
|
6
|
+
|
|
7
|
+
# Severity weights for scoring (higher = more impact)
|
|
8
|
+
SEVERITY_WEIGHTS: dict[Severity, float] = {
|
|
9
|
+
Severity.CRITICAL: 5.5,
|
|
10
|
+
Severity.HIGH: 4.0,
|
|
11
|
+
Severity.MEDIUM: 2.5,
|
|
12
|
+
Severity.LOW: 1.0,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Default weight for rules (used when calculating total possible points)
|
|
16
|
+
DEFAULT_RULE_WEIGHT: float = 2.5
|
|
17
|
+
|
|
18
|
+
# Level labels - must match levels.yml
|
|
19
|
+
LEVEL_LABELS: dict[Level, str] = {
|
|
20
|
+
Level.L1: "Absent",
|
|
21
|
+
Level.L2: "Basic",
|
|
22
|
+
Level.L3: "Structured",
|
|
23
|
+
Level.L4: "Abstracted",
|
|
24
|
+
Level.L5: "Governed",
|
|
25
|
+
Level.L6: "Adaptive",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dedupe_violations(violations: list[Violation]) -> list[Violation]:
|
|
30
|
+
"""Deduplicate violations by (file, rule_id).
|
|
31
|
+
|
|
32
|
+
Keeps first occurrence of each unique (file, rule_id) pair.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
violations: List of violations (may have duplicates)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Deduplicated list of violations
|
|
39
|
+
"""
|
|
40
|
+
seen: set[tuple[str, str]] = set()
|
|
41
|
+
result: list[Violation] = []
|
|
42
|
+
|
|
43
|
+
for v in violations:
|
|
44
|
+
file_path = v.location.rsplit(":", 1)[0] if ":" in v.location else v.location
|
|
45
|
+
key = (file_path, v.rule_id)
|
|
46
|
+
|
|
47
|
+
if key not in seen:
|
|
48
|
+
seen.add(key)
|
|
49
|
+
result.append(v)
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def calculate_score(rules_checked: int, violations: list[Violation]) -> float:
|
|
55
|
+
"""Calculate display score on 0-10 scale.
|
|
56
|
+
|
|
57
|
+
Score = (earned_points / possible_points) × 10
|
|
58
|
+
|
|
59
|
+
Lost points are capped per rule - multiple violations of the same rule
|
|
60
|
+
don't deduct more than the rule's weight (DEFAULT_RULE_WEIGHT).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
rules_checked: Total number of rules checked
|
|
64
|
+
violations: List of violations found
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Score between 0.0 and 10.0
|
|
68
|
+
"""
|
|
69
|
+
if rules_checked == 0:
|
|
70
|
+
return 10.0 # No rules = perfect
|
|
71
|
+
|
|
72
|
+
# Total possible points
|
|
73
|
+
possible = rules_checked * DEFAULT_RULE_WEIGHT
|
|
74
|
+
|
|
75
|
+
# Group violations by rule_id, cap lost points per rule
|
|
76
|
+
unique_violations = dedupe_violations(violations)
|
|
77
|
+
by_rule: dict[str, float] = {}
|
|
78
|
+
for v in unique_violations:
|
|
79
|
+
weight = SEVERITY_WEIGHTS.get(v.severity, DEFAULT_RULE_WEIGHT)
|
|
80
|
+
# Accumulate but cap at rule weight
|
|
81
|
+
current = by_rule.get(v.rule_id, 0.0)
|
|
82
|
+
by_rule[v.rule_id] = min(current + weight, DEFAULT_RULE_WEIGHT)
|
|
83
|
+
|
|
84
|
+
lost = sum(by_rule.values())
|
|
85
|
+
|
|
86
|
+
# Earned = possible - lost (floor at 0)
|
|
87
|
+
earned = max(0.0, possible - lost)
|
|
88
|
+
|
|
89
|
+
# Score on 0-10 scale
|
|
90
|
+
score = (earned / possible) * 10
|
|
91
|
+
return round(score, 1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_severity_weight(severity: Severity) -> float:
|
|
95
|
+
"""Get weight for a severity level.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
severity: Severity level
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Weight value
|
|
102
|
+
"""
|
|
103
|
+
return SEVERITY_WEIGHTS.get(severity, DEFAULT_RULE_WEIGHT)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def estimate_friction(violations: list[Violation]) -> FrictionEstimate:
|
|
107
|
+
"""Estimate friction from violations.
|
|
108
|
+
|
|
109
|
+
Friction levels based on violation severity:
|
|
110
|
+
- extreme: Any critical violation
|
|
111
|
+
- high: 2+ high severity OR 5+ total violations
|
|
112
|
+
- medium: 1 high severity OR 3-4 violations
|
|
113
|
+
- small: 1-2 violations (medium/low severity)
|
|
114
|
+
- none: No violations
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
violations: List of violations
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
FrictionEstimate with level
|
|
121
|
+
"""
|
|
122
|
+
unique = dedupe_violations(violations)
|
|
123
|
+
|
|
124
|
+
if not unique:
|
|
125
|
+
return FrictionEstimate(level="none")
|
|
126
|
+
|
|
127
|
+
# Count by severity
|
|
128
|
+
critical_count = sum(1 for v in unique if v.severity == Severity.CRITICAL)
|
|
129
|
+
high_count = sum(1 for v in unique if v.severity == Severity.HIGH)
|
|
130
|
+
total_count = len(unique)
|
|
131
|
+
|
|
132
|
+
# Determine level
|
|
133
|
+
if critical_count > 0:
|
|
134
|
+
level = "extreme"
|
|
135
|
+
elif high_count >= 2 or total_count >= 5:
|
|
136
|
+
level = "high"
|
|
137
|
+
elif high_count >= 1 or total_count >= 3:
|
|
138
|
+
level = "medium"
|
|
139
|
+
else:
|
|
140
|
+
level = "small"
|
|
141
|
+
|
|
142
|
+
return FrictionEstimate(level=level)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_level_label(level: Level) -> str:
|
|
146
|
+
"""Get human-readable label for level.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
level: Maturity level
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Label string (e.g., "Abstracted")
|
|
153
|
+
"""
|
|
154
|
+
return LEVEL_LABELS.get(level, "Unknown")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def has_critical_violations(violations: list[Violation]) -> bool:
|
|
158
|
+
"""Check if any violation is critical.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
violations: List of violations
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
True if any violation has CRITICAL severity
|
|
165
|
+
"""
|
|
166
|
+
return any(v.severity == Severity.CRITICAL for v in violations)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Legacy compatibility
|
|
170
|
+
def get_severity_points(severity: Severity) -> int:
|
|
171
|
+
"""Legacy: Get point deduction for a severity level."""
|
|
172
|
+
points_map = {
|
|
173
|
+
Severity.CRITICAL: -25,
|
|
174
|
+
Severity.HIGH: -15,
|
|
175
|
+
Severity.MEDIUM: -10,
|
|
176
|
+
Severity.LOW: -5,
|
|
177
|
+
}
|
|
178
|
+
return points_map.get(severity, -5)
|