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.
Files changed (64) hide show
  1. oss_policy_kit/__init__.py +3 -0
  2. oss_policy_kit/__main__.py +6 -0
  3. oss_policy_kit/adapters/__init__.py +1 -0
  4. oss_policy_kit/adapters/local_paths.py +18 -0
  5. oss_policy_kit/adapters/scorecard_json.py +80 -0
  6. oss_policy_kit/application/__init__.py +1 -0
  7. oss_policy_kit/application/batch_evaluate.py +372 -0
  8. oss_policy_kit/application/cli_output.py +160 -0
  9. oss_policy_kit/application/engine.py +200 -0
  10. oss_policy_kit/application/evaluators.py +2074 -0
  11. oss_policy_kit/application/evidence_scaffold.py +234 -0
  12. oss_policy_kit/application/loader.py +119 -0
  13. oss_policy_kit/application/profile_hints.py +580 -0
  14. oss_policy_kit/application/reporting.py +265 -0
  15. oss_policy_kit/application/waivers.py +95 -0
  16. oss_policy_kit/cli/__init__.py +1 -0
  17. oss_policy_kit/cli/main.py +1000 -0
  18. oss_policy_kit/cli/terminal_ui.py +231 -0
  19. oss_policy_kit/data/controls/catalog.yaml +251 -0
  20. oss_policy_kit/data/profiles/aws-level-1/profile.yaml +18 -0
  21. oss_policy_kit/data/profiles/aws-level-2/profile.yaml +20 -0
  22. oss_policy_kit/data/profiles/aws-level-3/profile.yaml +22 -0
  23. oss_policy_kit/data/profiles/aws-release-hardening-1/profile.yaml +20 -0
  24. oss_policy_kit/data/profiles/aws-release-hardening-2/profile.yaml +22 -0
  25. oss_policy_kit/data/profiles/aws-release-hardening-3/profile.yaml +24 -0
  26. oss_policy_kit/data/profiles/azure-level-1/profile.yaml +19 -0
  27. oss_policy_kit/data/profiles/azure-level-2/profile.yaml +21 -0
  28. oss_policy_kit/data/profiles/azure-level-3/profile.yaml +22 -0
  29. oss_policy_kit/data/profiles/azure-release-hardening-1/profile.yaml +21 -0
  30. oss_policy_kit/data/profiles/azure-release-hardening-2/profile.yaml +23 -0
  31. oss_policy_kit/data/profiles/azure-release-hardening-3/profile.yaml +24 -0
  32. oss_policy_kit/data/profiles/github-level-1/profile.yaml +23 -0
  33. oss_policy_kit/data/profiles/github-level-2/profile.yaml +30 -0
  34. oss_policy_kit/data/profiles/github-level-3/profile.yaml +32 -0
  35. oss_policy_kit/data/profiles/github-release-hardening/profile.yaml +24 -0
  36. oss_policy_kit/data/profiles/github-release-hardening-1/profile.yaml +24 -0
  37. oss_policy_kit/data/profiles/github-release-hardening-2/profile.yaml +31 -0
  38. oss_policy_kit/data/profiles/github-release-hardening-3/profile.yaml +33 -0
  39. oss_policy_kit/data/schema/__init__.py +1 -0
  40. oss_policy_kit/data/schema/evidence-aws-codebuild-project.schema.json +24 -0
  41. oss_policy_kit/data/schema/evidence-aws-codecommit-review-posture.schema.json +24 -0
  42. oss_policy_kit/data/schema/evidence-aws-codepipeline.schema.json +29 -0
  43. oss_policy_kit/data/schema/evidence-azure-branch-policies.schema.json +37 -0
  44. oss_policy_kit/data/schema/evidence-azure-pipeline-governance.schema.json +46 -0
  45. oss_policy_kit/data/schema/evidence-branch-protection.schema.json +69 -0
  46. oss_policy_kit/data/schema/evidence-github-environment-protection.schema.json +29 -0
  47. oss_policy_kit/data/schema/evidence-github-rulesets.schema.json +31 -0
  48. oss_policy_kit/data/schema/evidence-github-secret-scanning.schema.json +25 -0
  49. oss_policy_kit/data/schema/profile-recommendation-v2.schema.json +31 -0
  50. oss_policy_kit/domain/__init__.py +15 -0
  51. oss_policy_kit/domain/errors.py +17 -0
  52. oss_policy_kit/domain/models.py +78 -0
  53. oss_policy_kit/infrastructure/__init__.py +1 -0
  54. oss_policy_kit/infrastructure/aws_ci_parser.py +150 -0
  55. oss_policy_kit/infrastructure/azure_pipeline_parser.py +185 -0
  56. oss_policy_kit/infrastructure/workflow_parser.py +282 -0
  57. oss_policy_kit/infrastructure/yaml_io.py +13 -0
  58. oss_policy_kit-2.0.1.dist-info/METADATA +735 -0
  59. oss_policy_kit-2.0.1.dist-info/RECORD +64 -0
  60. oss_policy_kit-2.0.1.dist-info/WHEEL +5 -0
  61. oss_policy_kit-2.0.1.dist-info/entry_points.txt +2 -0
  62. oss_policy_kit-2.0.1.dist-info/licenses/LICENSE +202 -0
  63. oss_policy_kit-2.0.1.dist-info/licenses/NOTICE +4 -0
  64. oss_policy_kit-2.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """OSS Policy Kit - local evaluation of OSS security baseline controls."""
2
+
3
+ __version__ = "2.0.1"
@@ -0,0 +1,6 @@
1
+ """python -m oss_policy_kit"""
2
+
3
+ from oss_policy_kit.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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")