diff --git a/packages/darnit-baseline/src/darnit_baseline/formatters/__init__.py b/packages/darnit-baseline/src/darnit_baseline/formatters/__init__.py index 76d2ad58..a9bb7283 100644 --- a/packages/darnit-baseline/src/darnit_baseline/formatters/__init__.py +++ b/packages/darnit-baseline/src/darnit_baseline/formatters/__init__.py @@ -1,5 +1,11 @@ """OSPS-specific output formatters.""" +from .badge import ( + BADGE_BASE_URL, + control_id_to_key, + generate_badge_url, + status_to_badge_status, +) from .sarif import ( build_sarif_rules, generate_sarif_audit, @@ -8,6 +14,12 @@ ) __all__ = [ + # Badge formatter + "generate_badge_url", + "control_id_to_key", + "status_to_badge_status", + "BADGE_BASE_URL", + # SARIF formatter "generate_sarif_audit", "build_sarif_rules", "result_to_sarif_result", diff --git a/packages/darnit-baseline/src/darnit_baseline/formatters/badge.py b/packages/darnit-baseline/src/darnit_baseline/formatters/badge.py new file mode 100644 index 00000000..afec2ba1 --- /dev/null +++ b/packages/darnit-baseline/src/darnit_baseline/formatters/badge.py @@ -0,0 +1,217 @@ +"""OpenSSF Best Practices Badge automation-proposal URL generator. + +Converts an OSPS Baseline audit result set into a ready-to-follow URL that +pre-fills the Best Practices Badge entry form for the audited project. + +Format reference: + https://www.bestpractices.dev/projects?as=edit&url=ENCODED_URL& + KEY=VALUE&KEY=VALUE&... + +Example: + https://www.bestpractices.dev/projects?as=edit& + url=https%3A%2F%2Fgithub.com%2Fcurl%2Fcurl& + osps_ac_01_01_status=Met& + osps_ac_01_01_justification=GitHub+org+enforces+2FA + +Key transform: OSPS-AC-01.01 -> osps_ac_01_01 +Status mapping: PASS -> Met | FAIL -> Unmet | WARN -> ? | NA/N/A -> N/A + +Justification policy: + Only Met (PASS) and Unmet (FAIL) results include a justification. + Inconclusive (WARN -> ?) and N/A results never include one, to avoid + surfacing error messages or command-not-found noise in badge submissions. + +URL budget: + Total URL is capped at _URL_BUDGET_BYTES (6 KB). All _status params are + always included. Justifications are appended only while the running total + stays within budget. +""" + +from urllib.parse import quote, urlencode + +BADGE_BASE_URL = "https://www.bestpractices.dev/projects" + +# Maximum per-justification length (characters) before truncation. +# Short justifications keep URLs well within browser and server limits +# and match the badge form's expectation of human-readable notes. +_MAX_JUSTIFICATION_LEN = 200 + +# Total URL budget in bytes. Common server/proxy limits are 8 KB; we leave +# headroom so the URL reliably works everywhere. +_URL_BUDGET_BYTES = 6 * 1024 # 6 KB + +# Only these badge statuses warrant a justification in the URL. +_JUSTIFICATION_STATUSES = {"Met", "Unmet"} + +# Status mapping from darnit audit statuses to badge site values. +_STATUS_MAP: dict[str, str] = { + "PASS": "Met", + "FAIL": "Unmet", + "WARN": "?", # inconclusive -- cannot assert Met or Unmet + "NA": "N/A", + "N/A": "N/A", +} + + +def control_id_to_key(control_id: str) -> str: + """Transform an OSPS control ID into a Best Practices Badge parameter key. + + Args: + control_id: OSPS control ID, e.g. ``"OSPS-AC-01.01"`` + + Returns: + Badge parameter key, e.g. ``"osps_ac_01_01"`` + + Examples: + >>> control_id_to_key("OSPS-AC-01.01") + 'osps_ac_01_01' + >>> control_id_to_key("OSPS-VM-03.02") + 'osps_vm_03_02' + """ + # Replace hyphens and dots with underscores, then lowercase. + key = control_id.replace("-", "_").replace(".", "_").lower() + return key + + +def status_to_badge_status(status: str) -> str: + """Map a darnit audit status to a Best Practices Badge status string. + + Args: + status: One of ``"PASS"``, ``"FAIL"``, ``"WARN"``, ``"NA"``, ``"N/A"`` + + Returns: + Badge status: ``"Met"``, ``"Unmet"``, ``"?"``, or ``"N/A"`` + + Examples: + >>> status_to_badge_status("PASS") + 'Met' + >>> status_to_badge_status("FAIL") + 'Unmet' + >>> status_to_badge_status("WARN") + '?' + >>> status_to_badge_status("NA") + 'N/A' + """ + return _STATUS_MAP.get(status, "?") + + +def generate_badge_url( + results: list[dict], + project_url: str = "", +) -> str: + """Generate an OpenSSF Best Practices Badge automation-proposal URL. + + Builds a URL that pre-fills the badge entry form at + https://www.bestpractices.dev with the status and (where appropriate) the + justification for each OSPS control in the audit results. The maintainer + can follow the link, review the pre-filled fields, and submit to earn + their badge. + + Rules applied to keep the URL safe and clean: + + * Every control gets a ``_status`` parameter (always included). + * Justifications are only added for **Met** (PASS) and **Unmet** (FAIL) + results. Inconclusive (``?``) and N/A results never get a justification, + preventing error messages or command-not-found text from reaching a real + badge submission. + * The total URL is capped at 6 KB. Justifications are added in result + order until the budget is exhausted; the remaining status params are + still included without justification. + + Args: + results: List of audit result dicts, each containing at minimum + ``"id"`` (str), ``"status"`` (str), and optionally + ``"details"`` (str). + project_url: The project's canonical repository URL + (e.g. ``"https://github.com/curl/curl"``). + Required for a valid badge submission but the URL is + still generated without it (with a warning). + + Returns: + A formatted string containing a header and the automation-proposal URL. + """ + # --- Pass 1: build all _status params (always included) ----------------- + status_params: list[tuple[str, str]] = [("as", "edit")] + if project_url: + status_params.append(("url", project_url)) + + valid_results: list[tuple[str, str, str]] = [] # (key_prefix, badge_status, details) + for result in results: + control_id: str = result.get("id", "") + status: str = result.get("status", "") + details: str = result.get("details", "") or "" + + if not control_id or not status: + continue + + key_prefix = control_id_to_key(control_id) + badge_status = status_to_badge_status(status) + status_params.append((f"{key_prefix}_status", badge_status)) + valid_results.append((key_prefix, badge_status, details)) + + # --- Pass 2: add justifications within the URL budget ------------------- + # Calculate the base URL length with all status params but no justifications. + base_query = urlencode(status_params, quote_via=quote) + base_url = f"{BADGE_BASE_URL}?{base_query}" + remaining_budget = _URL_BUDGET_BYTES - len(base_url.encode()) + + # Build final params: status params first, then interleave justifications. + final_params: list[tuple[str, str]] = list(status_params) + justification_pairs: list[tuple[str, str]] = [] + + for key_prefix, badge_status, details in valid_results: + # Only Met/Unmet warrant justification text + if badge_status not in _JUSTIFICATION_STATUSES: + continue + + justification = details.strip() + if not justification: + continue + + if len(justification) > _MAX_JUSTIFICATION_LEN: + justification = justification[:_MAX_JUSTIFICATION_LEN] + "..." + + # Estimate the encoded cost of adding this param + encoded_pair = urlencode( + [(f"{key_prefix}_justification", justification)], quote_via=quote + ) + cost = len(f"&{encoded_pair}".encode()) + + if remaining_budget < cost: + # Budget exhausted -- skip remaining justifications + break + + remaining_budget -= cost + justification_pairs.append((f"{key_prefix}_justification", justification)) + + final_params.extend(justification_pairs) + + query = urlencode(final_params, quote_via=quote) + url = f"{BADGE_BASE_URL}?{query}" + + header_lines = [ + "## OpenSSF Best Practices Badge -- Automation Proposal", + "", + "Follow this link to pre-fill your badge entry with the audit results.", + "Review each field, then submit to claim your badge.", + "", + ] + + if not project_url: + header_lines += [ + "> WARNING: No project URL detected. The link above is missing the `url=` parameter.", + "> Pass `project_url=` to `audit_openssf_baseline` to include it,", + "> which is required for a valid badge submission.", + "", + ] + + header_lines.append(url) + return "\n".join(header_lines) + + +__all__ = [ + "control_id_to_key", + "status_to_badge_status", + "generate_badge_url", + "BADGE_BASE_URL", +] diff --git a/packages/darnit-baseline/src/darnit_baseline/tools.py b/packages/darnit-baseline/src/darnit_baseline/tools.py index 1d2317d8..a46715b9 100644 --- a/packages/darnit-baseline/src/darnit_baseline/tools.py +++ b/packages/darnit-baseline/src/darnit_baseline/tools.py @@ -48,6 +48,7 @@ def audit_openssf_baseline( staging: bool = False, prefer_upstream: bool = True, profile: str | None = None, + project_url: str | None = None, ) -> str: """ Run a comprehensive OpenSSF Baseline audit on a repository. @@ -64,8 +65,10 @@ def audit_openssf_baseline( - Different fields use AND logic: domain=AC AND level=1 - Same field repeated uses OR logic: priority=low OR priority=high - Bare values match against control tags dict keys - output_format: Output format - "markdown", "json", "summary", or "sarif". Default: "markdown". + output_format: Output format - "markdown", "json", "summary", "sarif", or "badge". + Default: "markdown". Use "summary" for compact JSON (id/status/level/details only, no evidence). + Use "badge" to generate an OpenSSF Best Practices Badge automation-proposal URL. auto_init_config: Create .project.yaml if missing. Default: True attest: Generate in-toto attestation after audit. Default: False sign_attestation: Sign attestation with Sigstore. Default: True @@ -74,6 +77,9 @@ def audit_openssf_baseline( Useful for auditing forks against their upstream repository. Default: True profile: Optional audit profile name to filter controls. Short name (e.g., "level1_quick") or qualified name (e.g., "openssf-baseline:level1_quick"). Default: None (all controls) + project_url: Canonical repository URL for the Best Practices Badge submission + (e.g. "https://github.com/curl/curl"). Auto-detected from git when omitted. + Only used when output_format="badge". Returns: Formatted audit report with compliance status and remediation instructions @@ -160,7 +166,16 @@ def audit_openssf_baseline( ) # Format output - if output_format == "summary": + if output_format == "badge": + from .formatters.badge import generate_badge_url + + # Resolve project URL: explicit arg > auto-detected from git remote + resolved_project_url = project_url or "" + if not resolved_project_url and owner and repo: + resolved_project_url = f"https://github.com/{owner}/{repo}" + + return generate_badge_url(results, resolved_project_url) + elif output_format == "summary": # Compact JSON: only id/status/level/details — no evidence or pass_history. # ~5-8K vs ~164K for full JSON with 62 controls. compact_results = [ diff --git a/tests/darnit_baseline/formatters/test_badge_formatter.py b/tests/darnit_baseline/formatters/test_badge_formatter.py new file mode 100644 index 00000000..06cc9e1e --- /dev/null +++ b/tests/darnit_baseline/formatters/test_badge_formatter.py @@ -0,0 +1,270 @@ +"""Unit tests for the Best Practices Badge output formatter. + +Tests cover: +- Control ID key transform +- Status mapping for all known statuses +- URL generation structure and params +- Auto-detection fallback (no project URL) +- No justification for inconclusive / N/A results +- Total URL budget cap (8000 chars for 62-control audit) +""" + +from darnit_baseline.formatters import ( + BADGE_BASE_URL, + control_id_to_key, + generate_badge_url, + status_to_badge_status, +) + +# --------------------------------------------------------------------------- +# control_id_to_key +# --------------------------------------------------------------------------- + + +class TestControlIdToKey: + """Tests for the OSPS control ID -> badge key transform.""" + + def test_basic_transform(self): + assert control_id_to_key("OSPS-AC-01.01") == "osps_ac_01_01" + + def test_vm_domain(self): + assert control_id_to_key("OSPS-VM-03.02") == "osps_vm_03_02" + + def test_br_domain(self): + assert control_id_to_key("OSPS-BR-02.01") == "osps_br_02_01" + + def test_do_domain(self): + assert control_id_to_key("OSPS-DO-01.01") == "osps_do_01_01" + + def test_qa_domain(self): + assert control_id_to_key("OSPS-QA-07.01") == "osps_qa_07_01" + + def test_all_hyphens_and_dots_replaced(self): + key = control_id_to_key("OSPS-GV-02.01") + assert "-" not in key + assert "." not in key + + def test_result_is_lowercase(self): + key = control_id_to_key("OSPS-AC-01.01") + assert key == key.lower() + + +# --------------------------------------------------------------------------- +# status_to_badge_status +# --------------------------------------------------------------------------- + + +class TestStatusToBadgeStatus: + """Tests for the darnit status -> badge status mapping.""" + + def test_pass_maps_to_met(self): + assert status_to_badge_status("PASS") == "Met" + + def test_fail_maps_to_unmet(self): + assert status_to_badge_status("FAIL") == "Unmet" + + def test_warn_maps_to_question_mark(self): + # WARN = inconclusive; cannot assert Met or Unmet + assert status_to_badge_status("WARN") == "?" + + def test_na_maps_to_na(self): + assert status_to_badge_status("NA") == "N/A" + + def test_na_with_slash_maps_to_na(self): + assert status_to_badge_status("N/A") == "N/A" + + def test_unknown_status_maps_to_question_mark(self): + # Any unrecognised status is treated as inconclusive + assert status_to_badge_status("WHATEVER") == "?" + + +# --------------------------------------------------------------------------- +# generate_badge_url +# --------------------------------------------------------------------------- + + +class TestGenerateBadgeUrl: + """Tests for the full URL generation.""" + + _SIMPLE_RESULTS = [ + { + "id": "OSPS-AC-01.01", + "status": "PASS", + "details": "GitHub org enforces 2FA", + }, + { + "id": "OSPS-AC-03.01", + "status": "FAIL", + "details": "Branch protection not enabled on main", + }, + ] + + def test_url_starts_with_badge_base(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert BADGE_BASE_URL in output + + def test_url_contains_as_edit(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert "as=edit" in output + + def test_url_contains_encoded_project_url(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert "github.com" in output + assert "example" in output + + def test_url_contains_status_params(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert "osps_ac_01_01_status=Met" in output + assert "osps_ac_03_01_status=Unmet" in output + + def test_url_contains_justification_for_pass(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert "osps_ac_01_01_justification=" in output + + def test_url_contains_justification_for_fail(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert "osps_ac_03_01_justification=" in output + + # --- Justification policy: only Met/Unmet get justification ------------- + + def test_warn_result_has_no_justification(self): + """WARN maps to %3F and must never include a justification param.""" + results = [{"id": "OSPS-VM-01.01", "status": "WARN", "details": "Could not verify"}] + output = generate_badge_url(results, "https://github.com/example/repo") + # urlencode(quote_via=quote) always percent-encodes '?' -> '%3F' + assert "osps_vm_01_01_status=%3F" in output + assert "osps_vm_01_01_justification" not in output + + def test_na_result_has_no_justification(self): + """NA maps to N%2FA and must never include a justification param.""" + results = [{"id": "OSPS-BR-04.01", "status": "NA", "details": "Not applicable"}] + output = generate_badge_url(results, "https://github.com/example/repo") + # urlencode(quote_via=quote) always percent-encodes '/' -> '%2F' + assert "osps_br_04_01_status=N%2FA" in output + assert "osps_br_04_01_justification" not in output + + def test_na_slash_result_has_no_justification(self): + """N/A status also suppresses justification.""" + results = [{"id": "OSPS-BR-04.01", "status": "N/A", "details": "Not applicable"}] + output = generate_badge_url(results, "https://github.com/example/repo") + assert "osps_br_04_01_status=N%2FA" in output + assert "osps_br_04_01_justification" not in output + + # --- URL length ceiling -------------------------------------------------- + + def test_url_under_8000_chars_for_62_controls(self): + """URL must stay under 8000 chars for a full 62-control OSPS audit.""" + # Simulate 62 controls with typical short justifications + domains = ["AC", "BR", "DO", "GV", "QA", "VM"] + results = [] + for i, domain in enumerate(domains): + for j in range(1, 11): + control_num = f"{j:02d}" + results.append({ + "id": f"OSPS-{domain}-{control_num}.01", + "status": "FAIL", + "details": f"Remediation required for {domain}-{control_num}.01 control", + }) + if len(results) == 62: + break + if len(results) == 62: + break + + output = generate_badge_url(results, "https://github.com/example/repo") + url_line = [line for line in output.splitlines() if line.startswith("https://")][-1] + assert len(url_line) <= 8000, ( + f"URL is {len(url_line)} chars, exceeds 8000-char ceiling" + ) + + def test_all_status_params_present_even_when_budget_exhausted(self): + """Even when the URL budget is exhausted, every _status param must appear.""" + many_results = [ + { + "id": f"OSPS-AC-{i:02d}.01", + "status": "FAIL", + "details": "X" * 300, + } + for i in range(1, 30) + ] + output = generate_badge_url(many_results, "https://github.com/example/repo") + for i in range(1, 30): + assert f"osps_ac_{i:02d}_01_status=Unmet" in output + + # --- Other edge cases ---------------------------------------------------- + + def test_empty_results_still_returns_url(self): + output = generate_badge_url([], "https://github.com/example/repo") + assert BADGE_BASE_URL in output + assert "as=edit" in output + + def test_no_project_url_shows_warning(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "") + assert "WARNING:" in output + + def test_no_project_url_no_url_param(self): + """When project_url is empty, the url= query param should be absent.""" + output = generate_badge_url([], "") + url_line = [line for line in output.splitlines() if line.startswith("https://")][-1] + assert "as=edit" in url_line + assert "url=" not in url_line + + def test_justification_truncated_at_200_chars(self): + """Justification longer than 200 chars is truncated and ends with ...""" + long_details = "A" * 300 + results = [{"id": "OSPS-AC-01.01", "status": "PASS", "details": long_details}] + output = generate_badge_url(results, "https://github.com/example/repo") + # '...' is plain ASCII; check it appears in the output + assert "..." in output + + def test_result_missing_details_no_justification_param(self): + results = [{"id": "OSPS-AC-01.01", "status": "PASS"}] + output = generate_badge_url(results, "https://github.com/example/repo") + assert "osps_ac_01_01_status=Met" in output + assert "osps_ac_01_01_justification" not in output + + def test_result_with_empty_details_no_justification_param(self): + results = [{"id": "OSPS-AC-01.01", "status": "PASS", "details": " "}] + output = generate_badge_url(results, "https://github.com/example/repo") + assert "osps_ac_01_01_justification" not in output + + def test_markdown_header_present(self): + output = generate_badge_url(self._SIMPLE_RESULTS, "https://github.com/example/repo") + assert "Best Practices Badge" in output + + def test_result_with_missing_id_skipped(self): + results = [ + {"status": "PASS", "details": "No ID"}, + {"id": "OSPS-AC-01.01", "status": "PASS", "details": "Has ID"}, + ] + output = generate_badge_url(results, "https://github.com/example/repo") + assert "osps_ac_01_01_status=Met" in output + assert output.count("_status=") == 1 + + def test_result_with_missing_status_skipped(self): + results = [ + {"id": "OSPS-AC-01.01", "details": "No status"}, + {"id": "OSPS-AC-03.01", "status": "FAIL"}, + ] + output = generate_badge_url(results, "https://github.com/example/repo") + assert "osps_ac_03_01_status=Unmet" in output + assert output.count("_status=") == 1 + + +# --------------------------------------------------------------------------- +# Integration: re-export from formatters package __init__ +# --------------------------------------------------------------------------- + + +def test_badge_exports_available_from_formatters_package(): + """Ensure the badge symbols are re-exported from the formatters package. + + This test uses the names already imported at module level from + darnit_baseline.formatters (not .formatters.badge). If the re-export + breaks, every test in this file that uses those names will fail, which + is the correct signal. + """ + assert callable(generate_badge_url) + assert callable(control_id_to_key) + assert callable(status_to_badge_status) + assert isinstance(BADGE_BASE_URL, str) + assert "bestpractices.dev" in BADGE_BASE_URL