oss-policy-kit 2.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.
- oss_policy_kit/__init__.py +3 -0
- oss_policy_kit/__main__.py +6 -0
- oss_policy_kit/adapters/__init__.py +1 -0
- oss_policy_kit/adapters/local_paths.py +18 -0
- oss_policy_kit/adapters/scorecard_json.py +80 -0
- oss_policy_kit/application/__init__.py +1 -0
- oss_policy_kit/application/batch_evaluate.py +372 -0
- oss_policy_kit/application/cli_output.py +160 -0
- oss_policy_kit/application/engine.py +200 -0
- oss_policy_kit/application/evaluators.py +2074 -0
- oss_policy_kit/application/evidence_scaffold.py +234 -0
- oss_policy_kit/application/loader.py +119 -0
- oss_policy_kit/application/profile_hints.py +580 -0
- oss_policy_kit/application/reporting.py +265 -0
- oss_policy_kit/application/waivers.py +95 -0
- oss_policy_kit/cli/__init__.py +1 -0
- oss_policy_kit/cli/main.py +1000 -0
- oss_policy_kit/cli/terminal_ui.py +231 -0
- oss_policy_kit/data/controls/catalog.yaml +251 -0
- oss_policy_kit/data/profiles/aws-level-1/profile.yaml +18 -0
- oss_policy_kit/data/profiles/aws-level-2/profile.yaml +20 -0
- oss_policy_kit/data/profiles/aws-level-3/profile.yaml +22 -0
- oss_policy_kit/data/profiles/aws-release-hardening-1/profile.yaml +20 -0
- oss_policy_kit/data/profiles/aws-release-hardening-2/profile.yaml +22 -0
- oss_policy_kit/data/profiles/aws-release-hardening-3/profile.yaml +24 -0
- oss_policy_kit/data/profiles/azure-level-1/profile.yaml +19 -0
- oss_policy_kit/data/profiles/azure-level-2/profile.yaml +21 -0
- oss_policy_kit/data/profiles/azure-level-3/profile.yaml +22 -0
- oss_policy_kit/data/profiles/azure-release-hardening-1/profile.yaml +21 -0
- oss_policy_kit/data/profiles/azure-release-hardening-2/profile.yaml +23 -0
- oss_policy_kit/data/profiles/azure-release-hardening-3/profile.yaml +24 -0
- oss_policy_kit/data/profiles/github-level-1/profile.yaml +23 -0
- oss_policy_kit/data/profiles/github-level-2/profile.yaml +30 -0
- oss_policy_kit/data/profiles/github-level-3/profile.yaml +32 -0
- oss_policy_kit/data/profiles/github-release-hardening/profile.yaml +24 -0
- oss_policy_kit/data/profiles/github-release-hardening-1/profile.yaml +24 -0
- oss_policy_kit/data/profiles/github-release-hardening-2/profile.yaml +31 -0
- oss_policy_kit/data/profiles/github-release-hardening-3/profile.yaml +33 -0
- oss_policy_kit/data/schema/__init__.py +1 -0
- oss_policy_kit/data/schema/evidence-aws-codebuild-project.schema.json +24 -0
- oss_policy_kit/data/schema/evidence-aws-codecommit-review-posture.schema.json +24 -0
- oss_policy_kit/data/schema/evidence-aws-codepipeline.schema.json +29 -0
- oss_policy_kit/data/schema/evidence-azure-branch-policies.schema.json +37 -0
- oss_policy_kit/data/schema/evidence-azure-pipeline-governance.schema.json +46 -0
- oss_policy_kit/data/schema/evidence-branch-protection.schema.json +69 -0
- oss_policy_kit/data/schema/evidence-github-environment-protection.schema.json +29 -0
- oss_policy_kit/data/schema/evidence-github-rulesets.schema.json +31 -0
- oss_policy_kit/data/schema/evidence-github-secret-scanning.schema.json +25 -0
- oss_policy_kit/data/schema/profile-recommendation-v2.schema.json +31 -0
- oss_policy_kit/domain/__init__.py +15 -0
- oss_policy_kit/domain/errors.py +17 -0
- oss_policy_kit/domain/models.py +78 -0
- oss_policy_kit/infrastructure/__init__.py +1 -0
- oss_policy_kit/infrastructure/aws_ci_parser.py +150 -0
- oss_policy_kit/infrastructure/azure_pipeline_parser.py +185 -0
- oss_policy_kit/infrastructure/workflow_parser.py +282 -0
- oss_policy_kit/infrastructure/yaml_io.py +13 -0
- oss_policy_kit-2.0.1.dist-info/METADATA +735 -0
- oss_policy_kit-2.0.1.dist-info/RECORD +64 -0
- oss_policy_kit-2.0.1.dist-info/WHEEL +5 -0
- oss_policy_kit-2.0.1.dist-info/entry_points.txt +2 -0
- oss_policy_kit-2.0.1.dist-info/licenses/LICENSE +202 -0
- oss_policy_kit-2.0.1.dist-info/licenses/NOTICE +4 -0
- oss_policy_kit-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters for external evidence (e.g. OpenSSF Scorecard JSON)."""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Safe path resolution for repository targets."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from oss_policy_kit.domain.errors import InvalidInputError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_existing_dir(path_str: str) -> Path:
|
|
9
|
+
"""Resolve user-supplied path to an absolute directory."""
|
|
10
|
+
|
|
11
|
+
candidate = Path(path_str).expanduser()
|
|
12
|
+
try:
|
|
13
|
+
resolved = candidate.resolve()
|
|
14
|
+
except OSError as exc:
|
|
15
|
+
raise InvalidInputError(f"Invalid path: {path_str} ({exc})") from exc
|
|
16
|
+
if not resolved.is_dir():
|
|
17
|
+
raise InvalidInputError(f"Not a directory or does not exist: {resolved}")
|
|
18
|
+
return resolved
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Optional ingestion of OpenSSF Scorecard JSON exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from oss_policy_kit.infrastructure.yaml_io import load_yaml_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ScorecardCheck:
|
|
15
|
+
"""Normalized check entry."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
score: int | None
|
|
19
|
+
reason: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ScorecardBundle:
|
|
24
|
+
"""Parsed Scorecard payload."""
|
|
25
|
+
|
|
26
|
+
checks: list[ScorecardCheck] = field(default_factory=list)
|
|
27
|
+
raw_path: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _coerce_checks(blob: Any) -> list[ScorecardCheck]:
|
|
31
|
+
if blob is None:
|
|
32
|
+
return []
|
|
33
|
+
if isinstance(blob, dict) and "checks" in blob:
|
|
34
|
+
blob = blob["checks"]
|
|
35
|
+
if not isinstance(blob, list):
|
|
36
|
+
return []
|
|
37
|
+
out: list[ScorecardCheck] = []
|
|
38
|
+
for item in blob:
|
|
39
|
+
if not isinstance(item, dict):
|
|
40
|
+
continue
|
|
41
|
+
name = str(item.get("name", "")).strip()
|
|
42
|
+
if not name:
|
|
43
|
+
continue
|
|
44
|
+
score = item.get("score")
|
|
45
|
+
score_i = int(score) if isinstance(score, int) else None
|
|
46
|
+
reason = item.get("reason") or item.get("details")
|
|
47
|
+
reason_s = str(reason) if reason is not None else None
|
|
48
|
+
out.append(ScorecardCheck(name=name, score=score_i, reason=reason_s))
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_scorecard_json(path: Path) -> ScorecardBundle:
|
|
53
|
+
"""Load Scorecard JSON (common CLI export shapes)."""
|
|
54
|
+
|
|
55
|
+
text = path.read_text(encoding="utf-8")
|
|
56
|
+
data = json.loads(text)
|
|
57
|
+
checks: list[ScorecardCheck] = []
|
|
58
|
+
if isinstance(data, dict):
|
|
59
|
+
if "scorecard" in data and isinstance(data["scorecard"], dict):
|
|
60
|
+
checks = _coerce_checks(data["scorecard"].get("checks"))
|
|
61
|
+
if not checks:
|
|
62
|
+
checks = _coerce_checks(data.get("checks"))
|
|
63
|
+
return ScorecardBundle(checks=checks, raw_path=str(path.resolve()))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_scorecard_auto(path: Path) -> ScorecardBundle:
|
|
67
|
+
"""Load JSON or YAML scorecard-like file."""
|
|
68
|
+
|
|
69
|
+
suf = path.suffix.lower()
|
|
70
|
+
if suf in {".yaml", ".yml"}:
|
|
71
|
+
data = load_yaml_file(path)
|
|
72
|
+
checks = _coerce_checks(data if isinstance(data, dict) else None)
|
|
73
|
+
return ScorecardBundle(checks=checks, raw_path=str(path.resolve()))
|
|
74
|
+
return load_scorecard_json(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def checks_as_map(bundle: ScorecardBundle | None) -> dict[str, ScorecardCheck]:
|
|
78
|
+
if bundle is None:
|
|
79
|
+
return {}
|
|
80
|
+
return {c.name.lower(): c for c in bundle.checks}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application services: load, evaluate, report."""
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Evaluate multiple repository roots against one or more profiles (monorepo / batch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import oss_policy_kit
|
|
15
|
+
from oss_policy_kit.adapters.local_paths import resolve_existing_dir
|
|
16
|
+
from oss_policy_kit.application.cli_output import FailOnPolicy, fail_on_violated
|
|
17
|
+
from oss_policy_kit.application.engine import evaluate_repository
|
|
18
|
+
from oss_policy_kit.application.loader import load_catalog, load_profile_by_id, merge_kit_root
|
|
19
|
+
from oss_policy_kit.application.reporting import compute_priority_insights, report_to_dict, write_markdown_report
|
|
20
|
+
from oss_policy_kit.domain.errors import InvalidInputError
|
|
21
|
+
|
|
22
|
+
REPO_SIGNALS: tuple[str, ...] = (
|
|
23
|
+
".git",
|
|
24
|
+
"README.md",
|
|
25
|
+
"README.rst",
|
|
26
|
+
"package.json",
|
|
27
|
+
"pyproject.toml",
|
|
28
|
+
"requirements.txt",
|
|
29
|
+
"setup.py",
|
|
30
|
+
"Dockerfile",
|
|
31
|
+
"go.mod",
|
|
32
|
+
"pom.xml",
|
|
33
|
+
"Cargo.toml",
|
|
34
|
+
"build.gradle",
|
|
35
|
+
"buildspec.yml",
|
|
36
|
+
"azure-pipelines.yml",
|
|
37
|
+
)
|
|
38
|
+
REPO_GLOB_SIGNALS: tuple[str, ...] = ("*.csproj", "*.sln")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_likely_repository(path: Path) -> tuple[bool, str]:
|
|
42
|
+
"""Return ``(True, signal_found)`` when *path* looks like a repository root."""
|
|
43
|
+
|
|
44
|
+
for signal in REPO_SIGNALS:
|
|
45
|
+
if (path / signal).exists():
|
|
46
|
+
return True, signal
|
|
47
|
+
for pattern in REPO_GLOB_SIGNALS:
|
|
48
|
+
if list(path.glob(pattern)):
|
|
49
|
+
return True, pattern
|
|
50
|
+
return False, ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _batch_failure_counts(fails_by_target: dict[str, int]) -> list[int]:
|
|
54
|
+
return list(fails_by_target.values())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _failure_distribution_bucketing(fail_counts: list[int]) -> dict[str, int]:
|
|
58
|
+
buckets = {"0": 0, "1-5": 0, "6-10": 0, "11+": 0}
|
|
59
|
+
for n in fail_counts:
|
|
60
|
+
if n == 0:
|
|
61
|
+
buckets["0"] += 1
|
|
62
|
+
elif n <= 5:
|
|
63
|
+
buckets["1-5"] += 1
|
|
64
|
+
elif n <= 10:
|
|
65
|
+
buckets["6-10"] += 1
|
|
66
|
+
else:
|
|
67
|
+
buckets["11+"] += 1
|
|
68
|
+
return buckets
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _gate_violated_for_batch(policy: FailOnPolicy, summaries: list[dict[str, int]]) -> bool:
|
|
72
|
+
if policy == "none":
|
|
73
|
+
return False
|
|
74
|
+
return any(fail_on_violated(policy, s) for s in summaries)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class BatchRunRow:
|
|
79
|
+
"""One target × profile evaluation summary."""
|
|
80
|
+
|
|
81
|
+
target_name: str
|
|
82
|
+
target_path: str
|
|
83
|
+
profile_id: str
|
|
84
|
+
summary_by_status: dict[str, int]
|
|
85
|
+
report_path_json: str
|
|
86
|
+
report_path_md: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class BatchResult:
|
|
91
|
+
"""Paths to consolidated batch artifacts plus CI gate outcome."""
|
|
92
|
+
|
|
93
|
+
batch_json: Path
|
|
94
|
+
batch_md: Path
|
|
95
|
+
gate_violated: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def discover_batch_targets(
|
|
99
|
+
target_root: Path,
|
|
100
|
+
*,
|
|
101
|
+
include: str | None,
|
|
102
|
+
exclude: str | None,
|
|
103
|
+
) -> list[Path]:
|
|
104
|
+
"""Return immediate child directories of *target_root* suitable as repo roots."""
|
|
105
|
+
|
|
106
|
+
out: list[Path] = []
|
|
107
|
+
for child in sorted(target_root.iterdir(), key=lambda p: p.name.lower()):
|
|
108
|
+
if not child.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
if child.name.startswith("."):
|
|
111
|
+
continue
|
|
112
|
+
if include and not fnmatch.fnmatch(child.name, include):
|
|
113
|
+
continue
|
|
114
|
+
if exclude and fnmatch.fnmatch(child.name, exclude):
|
|
115
|
+
continue
|
|
116
|
+
out.append(child)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_batch_evaluation(
|
|
121
|
+
*,
|
|
122
|
+
target_root: Path,
|
|
123
|
+
profile_ids: list[str],
|
|
124
|
+
output_dir: Path,
|
|
125
|
+
kit_root: Path | None,
|
|
126
|
+
include: str | None,
|
|
127
|
+
exclude: str | None,
|
|
128
|
+
fail_on: str = "none",
|
|
129
|
+
skip_non_repos: bool = False,
|
|
130
|
+
progress_callback: Callable[[str, int, int], None] | None = None,
|
|
131
|
+
) -> BatchResult:
|
|
132
|
+
"""Evaluate each discovered child of *target_root* against every *profile_id*.
|
|
133
|
+
|
|
134
|
+
Writes per-run ``evaluation-report.{json,md}`` under
|
|
135
|
+
``output_dir / <sanitized_target_name> / <profile_id> /`` plus consolidated
|
|
136
|
+
``evaluation-batch.json`` and ``evaluation-batch.md`` at *output_dir*.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
policy = cast(FailOnPolicy, fail_on.lower())
|
|
140
|
+
root = merge_kit_root(kit_root)
|
|
141
|
+
catalog = load_catalog(root / "controls" / "catalog.yaml")
|
|
142
|
+
targets = discover_batch_targets(target_root, include=include, exclude=exclude)
|
|
143
|
+
if not targets:
|
|
144
|
+
raise InvalidInputError(f"No subdirectories to evaluate under {target_root}")
|
|
145
|
+
|
|
146
|
+
skipped_dirs: list[dict[str, str]] = []
|
|
147
|
+
eval_queue: list[tuple[Path, bool]] = []
|
|
148
|
+
for child in targets:
|
|
149
|
+
likely, _sig = is_likely_repository(child)
|
|
150
|
+
if skip_non_repos and not likely:
|
|
151
|
+
skipped_dirs.append(
|
|
152
|
+
{
|
|
153
|
+
"name": child.name,
|
|
154
|
+
"path": str(child.resolve()),
|
|
155
|
+
"reason": "No repository root signals (README, manifest, Dockerfile, lockfile path, etc.).",
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
eval_queue.append((child, likely))
|
|
160
|
+
|
|
161
|
+
if not eval_queue:
|
|
162
|
+
raise InvalidInputError(
|
|
163
|
+
"No repositories to evaluate after filtering. Remove --skip-non-repos or add include/exclude patterns."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
rows: list[BatchRunRow] = []
|
|
168
|
+
consolidated_reports: list[dict[str, Any]] = []
|
|
169
|
+
summaries_for_gate: list[dict[str, int]] = []
|
|
170
|
+
total_runs = len(eval_queue) * len(profile_ids)
|
|
171
|
+
run_index = 0
|
|
172
|
+
|
|
173
|
+
for target, likely_repo in eval_queue:
|
|
174
|
+
repo = resolve_existing_dir(str(target))
|
|
175
|
+
safe_name = target.name.replace("/", "_").replace("\\", "_")
|
|
176
|
+
for pid in profile_ids:
|
|
177
|
+
run_index += 1
|
|
178
|
+
if progress_callback is not None:
|
|
179
|
+
progress_callback(target.name, run_index, total_runs)
|
|
180
|
+
profile = load_profile_by_id(root, pid)
|
|
181
|
+
report = evaluate_repository(
|
|
182
|
+
repo_root=repo,
|
|
183
|
+
profile=profile,
|
|
184
|
+
catalog=catalog,
|
|
185
|
+
waiver_outcome=None,
|
|
186
|
+
scorecard=None,
|
|
187
|
+
)
|
|
188
|
+
dest = output_dir / safe_name / pid
|
|
189
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
json_path = dest / "evaluation-report.json"
|
|
191
|
+
md_path = dest / "evaluation-report.md"
|
|
192
|
+
json_path.write_text(
|
|
193
|
+
json.dumps(report_to_dict(report), indent=2, ensure_ascii=False) + "\n",
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
write_markdown_report(report, md_path)
|
|
197
|
+
rows.append(
|
|
198
|
+
BatchRunRow(
|
|
199
|
+
target_name=target.name,
|
|
200
|
+
target_path=str(repo.resolve()),
|
|
201
|
+
profile_id=pid,
|
|
202
|
+
summary_by_status=dict(report.summary_by_status),
|
|
203
|
+
report_path_json=str(json_path.resolve()),
|
|
204
|
+
report_path_md=str(md_path.resolve()),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
summaries_for_gate.append(dict(report.summary_by_status))
|
|
208
|
+
run_payload: dict[str, Any] = {
|
|
209
|
+
"target_name": target.name,
|
|
210
|
+
"target_path": str(repo.resolve()),
|
|
211
|
+
"profile_id": pid,
|
|
212
|
+
"summary_by_status": report.summary_by_status,
|
|
213
|
+
"action_insights": compute_priority_insights(report),
|
|
214
|
+
"reports": {"json": str(json_path.resolve()), "markdown": str(md_path.resolve())},
|
|
215
|
+
}
|
|
216
|
+
if not skip_non_repos and not likely_repo:
|
|
217
|
+
run_payload["likely_not_a_repository"] = True
|
|
218
|
+
consolidated_reports.append(run_payload)
|
|
219
|
+
|
|
220
|
+
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
221
|
+
gate_violated = _gate_violated_for_batch(policy, summaries_for_gate)
|
|
222
|
+
|
|
223
|
+
totals: dict[str, int] = defaultdict(int)
|
|
224
|
+
for row in rows:
|
|
225
|
+
for k, v in row.summary_by_status.items():
|
|
226
|
+
totals[k] += v
|
|
227
|
+
|
|
228
|
+
fails_by_target: dict[str, int] = defaultdict(int)
|
|
229
|
+
for row in rows:
|
|
230
|
+
fails_by_target[row.target_name] += row.summary_by_status.get("fail", 0)
|
|
231
|
+
|
|
232
|
+
fc_values = _batch_failure_counts(fails_by_target)
|
|
233
|
+
all_tied = len(fails_by_target) >= 2 and len(set(fc_values)) == 1
|
|
234
|
+
common_fail_count: int | None = fc_values[0] if all_tied else None
|
|
235
|
+
|
|
236
|
+
comparison_lines: list[str] = []
|
|
237
|
+
if fails_by_target:
|
|
238
|
+
if all_tied:
|
|
239
|
+
comparison_lines.append(
|
|
240
|
+
f"All **{len(fails_by_target)}** repositories share the same failure count "
|
|
241
|
+
f"({common_fail_count}) — no clear best or worst performer."
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
max_f = max(fails_by_target.values())
|
|
245
|
+
min_f = min(fails_by_target.values())
|
|
246
|
+
worst_targets = sorted(n for n, c in fails_by_target.items() if c == max_f)
|
|
247
|
+
best_targets = sorted(n for n, c in fails_by_target.items() if c == min_f)
|
|
248
|
+
comparison_lines.append(
|
|
249
|
+
f"- **Most failures (tie-break: lexicographic name)**: {', '.join(f'`{n}`' for n in worst_targets)}"
|
|
250
|
+
)
|
|
251
|
+
comparison_lines.append(f"- **Fewest failures**: {', '.join(f'`{n}`' for n in best_targets)}")
|
|
252
|
+
|
|
253
|
+
dist = _failure_distribution_bucketing(fc_values)
|
|
254
|
+
|
|
255
|
+
gap_hits: Counter[str] = Counter()
|
|
256
|
+
for cr in consolidated_reports:
|
|
257
|
+
ac = cr.get("action_insights") or {}
|
|
258
|
+
for ids in (ac.get("failing_controls_by_category") or {}).values():
|
|
259
|
+
for cid in ids:
|
|
260
|
+
gap_hits[str(cid)] += 1
|
|
261
|
+
|
|
262
|
+
batch_payload: dict[str, Any] = {
|
|
263
|
+
"schema_version": "https://github.com/lucashgrifoni/OSS-Security-Policy-as-Code-Starter-Kit/reports/batch/0.1",
|
|
264
|
+
"generated_at": generated_at,
|
|
265
|
+
"kit_version": oss_policy_kit.__version__,
|
|
266
|
+
"target_root": str(target_root.resolve()),
|
|
267
|
+
"profile_ids": profile_ids,
|
|
268
|
+
"gate_violated": gate_violated,
|
|
269
|
+
"fail_on": policy,
|
|
270
|
+
"all_tied": all_tied,
|
|
271
|
+
"common_fail_count": common_fail_count,
|
|
272
|
+
"failure_distribution": dist,
|
|
273
|
+
"runs": consolidated_reports,
|
|
274
|
+
}
|
|
275
|
+
if skipped_dirs:
|
|
276
|
+
batch_payload["skipped_directories"] = skipped_dirs
|
|
277
|
+
|
|
278
|
+
batch_json = output_dir / "evaluation-batch.json"
|
|
279
|
+
batch_json.write_text(json.dumps(batch_payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
280
|
+
|
|
281
|
+
md_lines = [
|
|
282
|
+
"# OSS Policy Kit - batch evaluation",
|
|
283
|
+
"",
|
|
284
|
+
f"- **Generated (UTC)**: `{generated_at}`",
|
|
285
|
+
f"- **Kit version**: `{oss_policy_kit.__version__}`",
|
|
286
|
+
f"- **Target root**: `{target_root.resolve()}`",
|
|
287
|
+
f"- **Profiles**: {', '.join(f'`{p}`' for p in profile_ids)}",
|
|
288
|
+
(
|
|
289
|
+
f"- **Targets evaluated**: {len(eval_queue)} child folder(s) x {len(profile_ids)} "
|
|
290
|
+
f"profile(s) = {len(rows)} run(s)"
|
|
291
|
+
),
|
|
292
|
+
"",
|
|
293
|
+
"## Consolidated status totals (all runs)",
|
|
294
|
+
"",
|
|
295
|
+
]
|
|
296
|
+
for status in sorted(totals.keys()):
|
|
297
|
+
md_lines.append(f"- `{status}`: **{totals[status]}**")
|
|
298
|
+
md_lines.append("")
|
|
299
|
+
|
|
300
|
+
label = policy
|
|
301
|
+
gate_state = "**VIOLATED**" if gate_violated else "**PASSED**"
|
|
302
|
+
md_lines.append(f"- **CI gate (`--fail-on: {label}`)**: {gate_state}")
|
|
303
|
+
md_lines.append("")
|
|
304
|
+
|
|
305
|
+
md_lines.append("## Failure distribution (per target, total `fail` across profiles)")
|
|
306
|
+
md_lines.append("")
|
|
307
|
+
md_lines.append("| Fail count range | Repositories |")
|
|
308
|
+
md_lines.append("| --- | ---: |")
|
|
309
|
+
md_lines.append(f"| 0 | {dist['0']} |")
|
|
310
|
+
md_lines.append(f"| 1-5 | {dist['1-5']} |")
|
|
311
|
+
md_lines.append(f"| 6-10 | {dist['6-10']} |")
|
|
312
|
+
md_lines.append(f"| 11+ | {dist['11+']} |")
|
|
313
|
+
md_lines.append("")
|
|
314
|
+
|
|
315
|
+
if comparison_lines:
|
|
316
|
+
md_lines.append("## Quick comparison (by total `fail` counts across profiles)")
|
|
317
|
+
md_lines.append("")
|
|
318
|
+
for ln in comparison_lines:
|
|
319
|
+
if ln.startswith("All **"):
|
|
320
|
+
md_lines.append(f"- {ln}")
|
|
321
|
+
else:
|
|
322
|
+
md_lines.append(ln)
|
|
323
|
+
md_lines.append("")
|
|
324
|
+
|
|
325
|
+
if skipped_dirs:
|
|
326
|
+
md_lines.append("## Skipped directories")
|
|
327
|
+
md_lines.append("")
|
|
328
|
+
for s in skipped_dirs:
|
|
329
|
+
md_lines.append(f"- `{s['name']}` — {s['reason']}")
|
|
330
|
+
md_lines.append("")
|
|
331
|
+
|
|
332
|
+
md_lines.extend(
|
|
333
|
+
[
|
|
334
|
+
"## Matrix (per target folder)",
|
|
335
|
+
"",
|
|
336
|
+
"| Target | Profile | fail | manual-review | pass | other |",
|
|
337
|
+
"| --- | --- | ---: | ---: | ---: | --- |",
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
for row in rows:
|
|
341
|
+
summary = row.summary_by_status
|
|
342
|
+
fails = summary.get("fail", 0)
|
|
343
|
+
mrr = summary.get("manual-review-required", 0)
|
|
344
|
+
passes = summary.get("pass", 0)
|
|
345
|
+
other = sum(v for k, v in summary.items() if k not in {"fail", "manual-review-required", "pass"})
|
|
346
|
+
md_lines.append(f"| `{row.target_name}` | `{row.profile_id}` | {fails} | {mrr} | {passes} | {other} |")
|
|
347
|
+
md_lines.append("")
|
|
348
|
+
if gap_hits:
|
|
349
|
+
md_lines.append("## Repeated failing controls (across runs)")
|
|
350
|
+
md_lines.append("")
|
|
351
|
+
md_lines.append("| Control id | Runs (count) |")
|
|
352
|
+
md_lines.append("| --- | ---: |")
|
|
353
|
+
for cid, n in gap_hits.most_common(15):
|
|
354
|
+
md_lines.append(f"| `{cid}` | {n} |")
|
|
355
|
+
md_lines.append("")
|
|
356
|
+
md_lines.append("## Report artifacts (paths relative to batch output directory)")
|
|
357
|
+
md_lines.append("")
|
|
358
|
+
out_abs = output_dir.resolve()
|
|
359
|
+
for row in rows:
|
|
360
|
+
jp = Path(row.report_path_json).resolve()
|
|
361
|
+
mp = Path(row.report_path_md).resolve()
|
|
362
|
+
try:
|
|
363
|
+
rel_json = jp.relative_to(out_abs)
|
|
364
|
+
rel_md = mp.relative_to(out_abs)
|
|
365
|
+
j_show, m_show = rel_json.as_posix(), rel_md.as_posix()
|
|
366
|
+
except ValueError:
|
|
367
|
+
j_show, m_show = str(jp), str(mp)
|
|
368
|
+
md_lines.append(f"- `{row.target_name}` x `{row.profile_id}` -> `{j_show}` , `{m_show}`")
|
|
369
|
+
batch_md = output_dir / "evaluation-batch.md"
|
|
370
|
+
batch_md.write_text("\n".join(md_lines), encoding="utf-8")
|
|
371
|
+
|
|
372
|
+
return BatchResult(batch_json=batch_json, batch_md=batch_md, gate_violated=gate_violated)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""CLI stdout helpers for automation-friendly summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from oss_policy_kit.application.reporting import compute_priority_insights
|
|
11
|
+
from oss_policy_kit.cli.terminal_ui import max_gap_line_chars, terminal_width
|
|
12
|
+
from oss_policy_kit.domain.models import ControlResult, ControlStatus, ExecutionReport
|
|
13
|
+
|
|
14
|
+
OutputFormat = Literal["human", "json"]
|
|
15
|
+
FailOnPolicy = Literal["none", "fail", "degraded"]
|
|
16
|
+
STATUS_ORDER: tuple[str, ...] = (
|
|
17
|
+
"pass",
|
|
18
|
+
"fail",
|
|
19
|
+
"manual-review-required",
|
|
20
|
+
"self-attested",
|
|
21
|
+
"not-evaluated",
|
|
22
|
+
"waived",
|
|
23
|
+
"not-observable",
|
|
24
|
+
"not-applicable",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ordered_summary(summary_by_status: dict[str, int]) -> dict[str, int]:
|
|
29
|
+
"""Return status counts in a stable, policy-oriented order."""
|
|
30
|
+
|
|
31
|
+
ordered: dict[str, int] = {}
|
|
32
|
+
for status in STATUS_ORDER:
|
|
33
|
+
if status in summary_by_status:
|
|
34
|
+
ordered[status] = summary_by_status[status]
|
|
35
|
+
# Preserve forward compatibility for any future status keys.
|
|
36
|
+
for status, count in summary_by_status.items():
|
|
37
|
+
if status not in ordered:
|
|
38
|
+
ordered[status] = count
|
|
39
|
+
return ordered
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _human_primary_gap_line(result: ControlResult, *, max_len: int) -> str:
|
|
43
|
+
"""Build a short, failure-oriented gap line (avoid catalog titles that read like successes)."""
|
|
44
|
+
|
|
45
|
+
reason = (result.reason or "").strip().replace("\n", " ")
|
|
46
|
+
if reason:
|
|
47
|
+
if len(reason) > max_len:
|
|
48
|
+
return reason[: max_len - 1].rstrip() + "..."
|
|
49
|
+
return reason
|
|
50
|
+
return f"{result.control_id}: action required (see report for details)."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fail_on_violated(policy: FailOnPolicy, summary_by_status: dict[str, int]) -> bool:
|
|
54
|
+
"""Return True when the run should exit with code 1 for policy reasons."""
|
|
55
|
+
|
|
56
|
+
if policy == "none":
|
|
57
|
+
return False
|
|
58
|
+
fails = summary_by_status.get("fail", 0)
|
|
59
|
+
if policy == "fail":
|
|
60
|
+
return fails > 0
|
|
61
|
+
if policy == "degraded":
|
|
62
|
+
manual = summary_by_status.get("manual-review-required", 0)
|
|
63
|
+
return fails > 0 or manual > 0
|
|
64
|
+
raise ValueError(f"Unknown fail-on policy: {policy!r}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def print_stdout_summary(report: ExecutionReport, *, output_format: OutputFormat) -> None:
|
|
68
|
+
"""Emit a compact machine- or human-readable summary line to stdout (for piping)."""
|
|
69
|
+
|
|
70
|
+
if output_format == "json":
|
|
71
|
+
ordered_summary = _ordered_summary(report.summary_by_status)
|
|
72
|
+
payload = {
|
|
73
|
+
"schema_version": report.schema_version,
|
|
74
|
+
"kit_version": report.kit_version,
|
|
75
|
+
"profile_id": report.profile_id,
|
|
76
|
+
"target_path": report.target_path,
|
|
77
|
+
"summary_by_status": ordered_summary,
|
|
78
|
+
"controls_total": sum(ordered_summary.values()),
|
|
79
|
+
"operational_warnings_count": len(report.operational_warnings),
|
|
80
|
+
}
|
|
81
|
+
if report.scorecard_supplemental is not None:
|
|
82
|
+
payload["scorecard_supplemental"] = report.scorecard_supplemental
|
|
83
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
84
|
+
return
|
|
85
|
+
ordered_summary = _ordered_summary(report.summary_by_status)
|
|
86
|
+
tw = max(20, terminal_width(sys.stdout))
|
|
87
|
+
gap_max = max_gap_line_chars(stream=sys.stdout)
|
|
88
|
+
lines: list[str] = []
|
|
89
|
+
lines.append(f"Profile: {report.profile_id}")
|
|
90
|
+
lines.append(f"Target: {report.target_path}")
|
|
91
|
+
status_bits = [f"{k}={v}" for k, v in ordered_summary.items() if v]
|
|
92
|
+
lines.append("Outcome: " + (", ".join(status_bits) if status_bits else "(no counts)"))
|
|
93
|
+
total_c = sum(ordered_summary.values())
|
|
94
|
+
warn_c = len(report.operational_warnings)
|
|
95
|
+
lines.append(f"Controls: {total_c} | Operational warnings: {warn_c}")
|
|
96
|
+
|
|
97
|
+
fails = [r for r in report.results if r.status == ControlStatus.FAIL]
|
|
98
|
+
mrr = [r for r in report.results if r.status == ControlStatus.MANUAL_REVIEW_REQUIRED]
|
|
99
|
+
gap_lines: list[str] = []
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
for r in fails + mrr:
|
|
102
|
+
line = _human_primary_gap_line(r, max_len=gap_max)
|
|
103
|
+
if line in seen:
|
|
104
|
+
continue
|
|
105
|
+
gap_lines.append(line)
|
|
106
|
+
seen.add(line)
|
|
107
|
+
if len(gap_lines) >= 3:
|
|
108
|
+
break
|
|
109
|
+
if not gap_lines:
|
|
110
|
+
insights = compute_priority_insights(report)
|
|
111
|
+
for row in insights["top_structural_causes"][:3]:
|
|
112
|
+
bucket = str(row["bucket"])
|
|
113
|
+
if bucket not in seen:
|
|
114
|
+
gap_lines.append(f"{bucket} ({row['count']} control(s))")
|
|
115
|
+
seen.add(bucket)
|
|
116
|
+
if len(gap_lines) >= 3:
|
|
117
|
+
break
|
|
118
|
+
lines.append("")
|
|
119
|
+
lines.append("Top gaps:")
|
|
120
|
+
bullet_w = max(12, tw - 4)
|
|
121
|
+
for g in gap_lines[:3]:
|
|
122
|
+
wrapped = textwrap.wrap(
|
|
123
|
+
g,
|
|
124
|
+
width=bullet_w,
|
|
125
|
+
break_long_words=True,
|
|
126
|
+
break_on_hyphens=False,
|
|
127
|
+
)
|
|
128
|
+
if not wrapped:
|
|
129
|
+
continue
|
|
130
|
+
lines.append(f" - {wrapped[0]}")
|
|
131
|
+
lines.extend(f" {ln}" for ln in wrapped[1:])
|
|
132
|
+
|
|
133
|
+
insights = compute_priority_insights(report)
|
|
134
|
+
next_step = (
|
|
135
|
+
insights["recommended_actions"][0]
|
|
136
|
+
if insights["recommended_actions"]
|
|
137
|
+
else "Revise o relatório Markdown/JSON para priorizar correções."
|
|
138
|
+
)
|
|
139
|
+
lines.append("")
|
|
140
|
+
lines.append("Suggested next step:")
|
|
141
|
+
step_w = max(12, tw - 2)
|
|
142
|
+
lines.extend(textwrap.wrap(next_step, width=step_w, break_long_words=True, break_on_hyphens=False))
|
|
143
|
+
|
|
144
|
+
if report.external_waiver_path:
|
|
145
|
+
lines.append("")
|
|
146
|
+
note = (
|
|
147
|
+
"Waiver note: an external file was loaded via --waivers; "
|
|
148
|
+
"this does not change GOV-WAIV-014 (versioned waivers in the repository)."
|
|
149
|
+
)
|
|
150
|
+
lines.extend(textwrap.wrap(note, width=tw, break_long_words=True, break_on_hyphens=False))
|
|
151
|
+
path_prefix = " Path: "
|
|
152
|
+
path_rest = report.external_waiver_path
|
|
153
|
+
if len(path_prefix) + len(path_rest) <= tw:
|
|
154
|
+
lines.append(path_prefix + path_rest)
|
|
155
|
+
else:
|
|
156
|
+
lines.append(path_prefix.rstrip())
|
|
157
|
+
path_w = max(12, tw - 4)
|
|
158
|
+
lines.extend(f" {pl}" for pl in textwrap.wrap(path_rest, width=path_w, break_long_words=True))
|
|
159
|
+
|
|
160
|
+
sys.stdout.write("\n".join(lines) + "\n")
|