From b1d98b22a425f01b2886c9dd735ed38c231914f8 Mon Sep 17 00:00:00 2001 From: Sudhir Verma <9924513+sudhirverma@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:28:46 +0530 Subject: [PATCH 1/3] feat(supply-chain-audit): add OpenSSF Scorecard collect, analyze, and workflow Wire Scorecard into the supply-chain audit (API + CLI fallback excluding Vulnerabilities), surface scores in the HTML report, and dogfood scorecard.yml on team-devtools. --- .agents/skills/td-supply-chain-audit/SKILL.md | 19 +- .../references/detection-patterns.md | 64 ++ .../td-supply-chain-audit/scripts/analyze.py | 342 ++++++++- .../scripts/audit_models.py | 18 + .../scripts/cache_utils.py | 36 +- .../td-supply-chain-audit/scripts/collect.py | 725 +++++++++++++++++- .../scripts/html_templates/dashboard.html | 5 + .../td-supply-chain-audit/scripts/report.py | 156 ++++ .github/workflows/scorecard.yml | 78 ++ 9 files changed, 1417 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/scorecard.yml diff --git a/.agents/skills/td-supply-chain-audit/SKILL.md b/.agents/skills/td-supply-chain-audit/SKILL.md index 6bfda5db..93b06dbd 100644 --- a/.agents/skills/td-supply-chain-audit/SKILL.md +++ b/.agents/skills/td-supply-chain-audit/SKILL.md @@ -76,9 +76,12 @@ The compromise-date is the date the package is suspected to have been compromise - `gh` CLI installed and authenticated (`gh auth status` must succeed) - `python3` available (3.10+) -- Network access to GitHub API and PyPI/npm registries +- Network access to GitHub API, PyPI/npm registries, and GitHub releases (for Scorecard CLI auto-bootstrap) +- GitHub token available to `gh` / `GH_TOKEN` (Scorecard CLI uses it for API rate limits) - `playwright` Python package with Chromium (for PDF export): `pip install playwright && playwright install chromium` +Do **not** ask the user to install Scorecard manually. `collect.py` auto-downloads the pinned `scorecard` binary into `.supply-chain-audit/bin/` when missing (**macOS, Linux, and Windows** — amd64/arm64), then scores every target repo that has no published OpenSSF API result. The CLI fallback runs the full Scorecard suite **except** `Vulnerabilities` (OSV dependency scan) — that check can hang for 15+ minutes on larger repos, and dependency CVEs are already covered by the audit's separate OSV.dev inventory pass. + ## Instructions ### Step 1: Validate inputs @@ -113,10 +116,13 @@ This will: - Fetch commits, PRs, check suites, and dependency diffs for all target repos - Fetch all individual commits and review timelines within each merged PR - Fetch branch protection rules and rulesets for each repo +- Detect OpenSSF Scorecard workflow presence and fetch published scores from the Scorecard API +- Auto-bootstrap the Scorecard CLI if needed, then score every repo with no API result (all target environments/repos; skip only with `--skip-scorecard-cli`) +- When commit/PR data is cached but Scorecard scores are still missing, automatically re-run Scorecard collection for those repos - Store results as JSON in the cache directory - Write a `manifest.json` for reproducibility -The script is idempotent: if cache files already exist for the same time frame, they are reused without re-fetching. +The script is idempotent: if cache files already exist for the same time frame, they are reused without re-fetching (Scorecard is refreshed automatically when scores are still unavailable). Monitor progress output. The script prints per-repo status. If rate-limited, it will back off automatically. @@ -127,7 +133,7 @@ python3 .agents/skills/td-supply-chain-audit/scripts/analyze.py \ --cache-dir ".supply-chain-audit/cache" ``` -This detects (13 passes): +This detects (14 passes): - Unsigned commits - GitHub-web-signed commits (signer is GitHub, not a personal key) - Orphan commits (no associated PR) @@ -141,6 +147,7 @@ This detects (13 passes): - Bot-only approvals (PRs merged without any human review) - Self-approved PRs (author approved their own code with no independent review) - Known vulnerabilities (all current packages scanned against OSV.dev) +- OpenSSF Scorecard gaps (unpublished results, low published API aggregate score, weak critical checks; missing workflows are table-only, not findings) Output: `findings.json` in the cache directory. @@ -165,12 +172,13 @@ After analysis completes, **you** (the agent) must read the findings and write a 2. If you need more detail on specific findings, read the full: `.supply-chain-audit/cache//findings.json` 3. Read the protection rules: `.supply-chain-audit/cache//protection/*.json` 4. Read the renovate configs: `.supply-chain-audit/cache//renovate/*.json` -5. Reason about the most impactful actions the team should take based on: +5. Read the Scorecard data: `.supply-chain-audit/cache//scorecard/*.json` +6. Reason about the most impactful actions the team should take based on: - Severity and count of findings by category - Patterns across repos (e.g., many repos missing the same protection) - Quick wins vs. systemic improvements - What would prevent the *worst* findings from recurring -5. Write `.supply-chain-audit/cache//recommendations.json` as a JSON array of objects: +7. Write `.supply-chain-audit/cache//recommendations.json` as a JSON array of objects: ```json [ @@ -206,6 +214,7 @@ This produces a standalone HTML file (no CDN dependencies) with: - Timeline visualization (SVG) - Commit integrity table (sortable, filterable) - Dependency changes table with release dates +- OpenSSF Scorecard workflow and score table - Suspicious patterns grouped by category - Security recommendations (from step 5) - Package focus section (if Phase 2 data exists) diff --git a/.agents/skills/td-supply-chain-audit/references/detection-patterns.md b/.agents/skills/td-supply-chain-audit/references/detection-patterns.md index f5376490..b11dcbcc 100644 --- a/.agents/skills/td-supply-chain-audit/references/detection-patterns.md +++ b/.agents/skills/td-supply-chain-audit/references/detection-patterns.md @@ -585,3 +585,67 @@ package inventory: 4. Assess whether the vulnerable code path is actually exercised 5. For critical/high: open an issue or PR to update immediately 6. For medium/low: schedule update via normal renovate cycle + +--- + +## 14. OpenSSF Scorecard + +**Category:** `scorecard` +**Default Risk:** High (published API score < 5), Medium (API score < 7 or weak +checks), Low/Info (workflow hygiene / CLI-only snapshot). Missing workflows are +shown in the Scorecard table only (not anomaly findings). + +### What it detects + +Gaps in OpenSSF Scorecard adoption and published posture for each audited repo: + +1. **Incomplete workflow** — present but missing `publish_results`, schedule + trigger, or SARIF/code-scanning upload (or wrong action) +2. **Unpublished results** — workflow exists but OpenSSF API has no score yet +3. **Low aggregate score** — published API score below 7 (medium) or 5 (high); + not applied to CLI snapshots (CLI omits `Vulnerabilities`) +4. **Weak critical checks** — Token-Permissions, Dangerous-Workflow, + Branch-Protection, Code-Review, Maintained, Pinned-Dependencies, or + Security-Policy scoring below 5 +5. **Missing workflow** — tracked in the report table only (no finding spam) + +### Why it matters + +Scorecard is the ecosystem-standard continuous signal for supply-chain +hygiene. Without the workflow (as introduced for abbenay in +[PR #57](https://github.com/redhat-developer/abbenay/pull/57)), scores drift, +badges stay stale, and code-scanning never receives SARIF findings. Weak +checks such as token permissions or unpinned actions are common entry points +for workflow compromise. + +### Data sources + +- GitHub Contents API: `.github/workflows/*scorecard*` +- OpenSSF Scorecard API: + `https://api.securityscorecards.dev/projects/github.com/{org}/{repo}` +- Local Scorecard CLI fallback (`scorecard --repo=github.com/{org}/{repo} + --format=json --checks=...`) when the API returns no published score. + `collect.py` auto-downloads a pinned CLI into `.supply-chain-audit/bin/` on + macOS, Linux, and Windows (amd64/arm64) and uses `gh`/env GitHub tokens for + rate limits. CLI runs omit the `Vulnerabilities` check (slow OSV walk; + covered by the audit's separate OSV.dev pass) and are labeled `source=cli` + with `cli_checks_excluded: ["Vulnerabilities"]`. CLI scores do not publish + to OpenSSF. + +### False positive scenarios + +- Brand-new repos where Scorecard has not completed a default-branch run yet + (INFO: unpublished results) +- Checks scored `-1` (not applicable) — ignored by the analyzer +- Private repos that cannot publish results without a PAT +- CLI scores may differ slightly from a later published API score (timing / + Scorecard version); treat CLI as an audit-time snapshot until workflows publish + +### Investigation steps + +1. Confirm whether `.github/workflows/scorecard.yml` exists on the default branch +2. Verify triggers include `push` to default branch, weekly `schedule`, and + optionally `branch_protection_rule` +3. Ensure `publish_results: true` and SARIF upload to code scanning +4. Open the OpenSSF API URL / badge and remediate failing critical checks +5. Re-run the workflow on `main` after fixes and re-audit diff --git a/.agents/skills/td-supply-chain-audit/scripts/analyze.py b/.agents/skills/td-supply-chain-audit/scripts/analyze.py index 63aa4310..00bfcb0e 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/analyze.py +++ b/.agents/skills/td-supply-chain-audit/scripts/analyze.py @@ -1,8 +1,8 @@ """Anomaly detection engine for supply chain audit. -Processes cached data to detect 13 categories of supply chain anomalies +Processes cached data to detect 14 categories of supply chain anomalies including commit integrity, CI integrity, dependency provenance, review -integrity, and known vulnerabilities. +integrity, known vulnerabilities, and OpenSSF Scorecard posture. """ # pylint: disable=too-many-lines @@ -20,6 +20,9 @@ try: from audit_models import ( # pylint: disable=import-error + SCORECARD_CRITICAL_CHECKS, + SCORECARD_HIGH_THRESHOLD, + SCORECARD_MEDIUM_THRESHOLD, Finding, FindingCategory, RiskLevel, @@ -32,6 +35,7 @@ get_all_cached_protection, get_all_cached_prs, get_all_cached_renovate, + get_all_cached_scorecard, get_all_cached_vulns, read_manifest, write_findings, @@ -39,6 +43,9 @@ except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parent)) from audit_models import ( + SCORECARD_CRITICAL_CHECKS, + SCORECARD_HIGH_THRESHOLD, + SCORECARD_MEDIUM_THRESHOLD, Finding, FindingCategory, RiskLevel, @@ -51,6 +58,7 @@ get_all_cached_protection, get_all_cached_prs, get_all_cached_renovate, + get_all_cached_scorecard, get_all_cached_vulns, read_manifest, write_findings, @@ -999,6 +1007,278 @@ def detect_bot_only_approval(pr_audits: list[dict], prs: list[dict]) -> list[Fin return findings +def _scorecard_overall_risk(score: float) -> RiskLevel | None: + """Map an overall OpenSSF Scorecard score to a risk level. + + Args: + score: Aggregate Scorecard score (0-10). + + Returns: + Risk level, or ``None`` when the score is acceptable. + + """ + if score < SCORECARD_HIGH_THRESHOLD: + return RiskLevel.HIGH + if score < SCORECARD_MEDIUM_THRESHOLD: + return RiskLevel.MEDIUM + return None + + +def _scorecard_check_risk(name: str, score: float | None) -> RiskLevel | None: + """Map an individual Scorecard check score to a risk level. + + Args: + name: Scorecard check name. + score: Check score (``-1`` means not applicable). + + Returns: + Risk level for weak critical checks, else ``None``. + + """ + if name not in SCORECARD_CRITICAL_CHECKS: + return None + if not isinstance(score, (int, float)) or score < 0: + return None + if score == 0: + return RiskLevel.HIGH + if score < SCORECARD_HIGH_THRESHOLD: + return RiskLevel.MEDIUM + return None + + +def _scorecard_workflow_findings(repo: str, workflow: dict) -> list[Finding]: + """Build findings for Scorecard workflow hygiene gaps. + + Missing workflows are intentionally omitted (table-only). When a workflow + exists, flag incorrect action usage or incomplete publish/schedule/SARIF setup. + + Args: + repo: Repository name (``org/repo``). + workflow: Cached workflow metadata. + + Returns: + Zero or more Scorecard findings. + + """ + if not workflow.get("present"): + return [] + + if workflow.get("uses_scorecard_action") is False: + return [ + Finding( + category=FindingCategory.SCORECARD, + risk_level=RiskLevel.MEDIUM, + repo=repo, + summary="Scorecard workflow does not use ossf/scorecard-action", + details=( + f"{repo} has a Scorecard-named workflow at " + f"`{workflow.get('path')}` but it does not reference " + f"`ossf/scorecard-action`. Verify the workflow matches the " + f"OpenSSF Scorecard supply-chain security pattern." + ), + evidence={"workflow": workflow}, + ), + ] + + findings: list[Finding] = [] + if workflow.get("publish_results") is False: + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=RiskLevel.MEDIUM, + repo=repo, + summary="Scorecard publish_results disabled", + details=( + f"{repo} runs Scorecard but `publish_results` is false, so " + f"results are not published to the OpenSSF API / badge. " + f"Enable `publish_results: true` on the default branch." + ), + evidence={"workflow": workflow}, + ), + ) + if not workflow.get("has_schedule"): + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=RiskLevel.LOW, + repo=repo, + summary="Scorecard workflow lacks a schedule trigger", + details=( + f"{repo} Scorecard workflow at `{workflow.get('path')}` has " + f"no `schedule` trigger. Add a weekly cron so the Maintained " + f"check and score stay current." + ), + evidence={"workflow": workflow}, + ), + ) + if not workflow.get("uploads_sarif"): + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=RiskLevel.LOW, + repo=repo, + summary="Scorecard results not uploaded to code scanning", + details=( + f"{repo} Scorecard workflow does not upload SARIF via " + f"`github/codeql-action/upload-sarif` (or artifact upload). " + f"Upload results so findings appear in the code scanning dashboard." + ), + evidence={"workflow": workflow}, + ), + ) + return findings + + +def _scorecard_score_findings( + repo: str, + workflow: dict, + score_data: dict, +) -> list[Finding]: + """Build findings from Scorecard aggregate and per-check scores. + + Args: + repo: Repository name (``org/repo``). + workflow: Cached workflow metadata. + score_data: Cached score payload. + + Returns: + Zero or more Scorecard findings. + + """ + findings: list[Finding] = [] + + if not score_data.get("available"): + if workflow.get("present"): + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=RiskLevel.INFO, + repo=repo, + summary="Scorecard results not yet published to OpenSSF API", + details=( + f"{repo} has a Scorecard workflow but no published results at " + f"{score_data.get('api_url')}. Results appear after a successful " + f"run on the default branch with `publish_results: true`. " + f"The audit auto-bootstraps the Scorecard CLI to evaluate the " + f"repo locally when the API has no score." + ), + evidence={ + "workflow": workflow, + "error": score_data.get("error"), + "cli_error": score_data.get("cli_error"), + "api_url": score_data.get("api_url"), + }, + ), + ) + return findings + + if score_data.get("source") == "cli": + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=RiskLevel.INFO, + repo=repo, + summary="Scorecard score from local CLI (not published to OpenSSF API)", + details=( + f"{repo} was scored locally via the Scorecard CLI " + f"(score={score_data.get('score')}/10). Add/enable " + f"`scorecard.yml` with `publish_results: true` so consumers " + f"and badges can use the public OpenSSF API." + ), + date=score_data.get("date"), + evidence={ + "source": "cli", + "score": score_data.get("score"), + "api_error": score_data.get("api_error"), + "api_url": score_data.get("api_url"), + }, + ), + ) + + # Aggregate thresholds apply to published API scores only. CLI runs omit + # Vulnerabilities, so the aggregate is not comparable to the full suite. + overall = score_data.get("score") + source = score_data.get("source") or "api" + if source != "cli" and isinstance(overall, (int, float)): + overall_risk = _scorecard_overall_risk(float(overall)) + if overall_risk is not None: + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=overall_risk, + repo=repo, + summary=f"OpenSSF Scorecard score is {overall}/10", + details=( + f"{repo} Scorecard score is {overall}/10 via published OpenSSF API " + f"(date={score_data.get('date')}). Improve failing checks " + f"(token permissions, pinned actions, branch protection, " + f"code review) and keep publishing results with a Scorecard workflow." + ), + date=score_data.get("date"), + evidence={ + "score": overall, + "source": source, + "date": score_data.get("date"), + "api_url": score_data.get("api_url"), + "badge_url": score_data.get("badge_url"), + }, + ), + ) + + for check in score_data.get("checks") or []: + name = check.get("name", "") + check_score = check.get("score") + check_risk = _scorecard_check_risk(name, check_score) + if check_risk is None: + continue + findings.append( + Finding( + category=FindingCategory.SCORECARD, + risk_level=check_risk, + repo=repo, + summary=f"Scorecard check {name} scored {check_score}/10", + details=( + f"{repo}: OpenSSF Scorecard check `{name}` scored " + f"{check_score}/10. Reason: {check.get('reason') or 'n/a'}. " + f"See {check.get('documentation_url') or score_data.get('api_url')}." + ), + date=score_data.get("date"), + evidence={ + "check": name, + "score": check_score, + "reason": check.get("reason"), + "api_url": score_data.get("api_url"), + }, + ), + ) + return findings + + +def detect_scorecard_issues(scorecards: dict[str, dict]) -> list[Finding]: + """Detect weak OpenSSF Scorecard scores and workflow hygiene issues. + + Missing Scorecard workflows are reported in the Scorecard table only, + not as anomaly findings. + + Args: + scorecards: Scorecard payloads keyed by repo. + + Returns: + Findings for low aggregate scores, weak checks, and workflow gaps + when a Scorecard workflow is already present. + + """ + findings: list[Finding] = [] + + for repo, data in sorted(scorecards.items()): + workflow = data.get("workflow") or {} + score_data = data.get("scorecard") or {} + findings.extend(_scorecard_workflow_findings(repo, workflow)) + findings.extend(_scorecard_score_findings(repo, workflow, score_data)) + + return findings + + def detect_self_approval(pr_audits: list[dict]) -> list[Finding]: """Detect PRs where the author approved their own PR with no independent review. @@ -1081,7 +1361,7 @@ def detect_self_approval(pr_audits: list[dict]) -> list[Finding]: return findings -def _print_cache_stats( # pylint: disable=too-many-positional-arguments +def _print_cache_stats( # pylint: disable=too-many-positional-arguments,too-many-arguments commits: list[dict], prs: list[dict], checks: dict[str, list[dict]], @@ -1090,6 +1370,7 @@ def _print_cache_stats( # pylint: disable=too-many-positional-arguments pr_audits: list[dict], renovate_configs: dict[str, dict], vulns: dict[str, list[dict]], + scorecards: dict[str, dict], ) -> None: """Print summary statistics for loaded cache data. @@ -1102,6 +1383,7 @@ def _print_cache_stats( # pylint: disable=too-many-positional-arguments pr_audits: PR audit data. renovate_configs: Renovate configs keyed by repo. vulns: Vulnerability results keyed by repo. + scorecards: Scorecard payloads keyed by repo. """ print(f" Commits on main: {len(commits)}") @@ -1116,6 +1398,21 @@ def _print_cache_stats( # pylint: disable=too-many-positional-arguments print( f" Repos with vulnerability data: {len(vulns)} ({sum(len(v) for v in vulns.values())} affected packages)", ) + workflows_present = sum(1 for s in scorecards.values() if (s.get("workflow") or {}).get("present")) + scores_api = sum( + 1 + for s in scorecards.values() + if (s.get("scorecard") or {}).get("available") and (s.get("scorecard") or {}).get("source") == "api" + ) + scores_cli = sum( + 1 + for s in scorecards.values() + if (s.get("scorecard") or {}).get("available") and (s.get("scorecard") or {}).get("source") == "cli" + ) + print( + f" Repos with Scorecard data: {len(scorecards)} " + f"({workflows_present} workflows, {scores_api} API scores, {scores_cli} CLI scores)", + ) def _run_detection_pass( @@ -1142,7 +1439,7 @@ def _run_detection_pass( return findings -def _run_detection_passes( # pylint: disable=too-many-positional-arguments +def _run_detection_passes( # pylint: disable=too-many-positional-arguments,too-many-arguments commits: list[dict], prs: list[dict], checks: dict[str, list[dict]], @@ -1151,6 +1448,7 @@ def _run_detection_passes( # pylint: disable=too-many-positional-arguments pr_audits: list[dict], renovate_configs: dict[str, dict], vulns: dict[str, list[dict]], + scorecards: dict[str, dict], ) -> list[Finding]: """Execute all detection passes and return combined findings. @@ -1163,6 +1461,7 @@ def _run_detection_passes( # pylint: disable=too-many-positional-arguments pr_audits: PR audit data. renovate_configs: Renovate configs keyed by repo. vulns: Vulnerability results keyed by repo. + scorecards: Scorecard payloads keyed by repo. Returns: Combined findings from all passes. @@ -1172,43 +1471,43 @@ def _run_detection_passes( # pylint: disable=too-many-positional-arguments pass_specs: list[tuple[str, str, Callable[[], list[Finding]], Callable[[list[Finding]], str]]] = [ ( - "[1/12]", + "[1/14]", "Unsigned commits", lambda: detect_unsigned_commits(commits), lambda findings: f"Found {len(findings)} unsigned commits", ), ( - "[2/12]", + "[2/14]", "GitHub-web-signed commits (excluding PR merges)", lambda: detect_github_web_signed(commits, prs), lambda findings: f"Found {len(findings)} GitHub-web-signed commits (non-merge)", ), ( - "[3/12]", + "[3/14]", "Orphan commits (no PR)", lambda: detect_orphan_commits(commits, prs), lambda findings: f"Found {len(findings)} orphan commits", ), ( - "[4/12]", + "[4/14]", "Bypassed CI (required checks only)", lambda: detect_bypassed_ci(commits, prs, checks, protection), lambda findings: f"Found {len(findings)} bypassed CI instances", ), ( - "[5/12]", + "[5/14]", "Post-merge pushes", lambda: detect_post_merge_pushes(commits, prs), lambda findings: f"Found {len(findings)} post-merge pushes", ), ( - "[6/12]", + "[6/14]", "Replicated commit messages", lambda: detect_replicated_messages(commits), lambda findings: f"Found {len(findings)} replicated messages", ), ( - "[7/12]", + "[7/14]", "Dependency cooldown policy check", lambda: detect_suspicious_dep_timing(deps, renovate_configs), lambda findings: ( @@ -1219,41 +1518,47 @@ def _run_detection_passes( # pylint: disable=too-many-positional-arguments ), ), ( - "[8/12]", + "[8/14]", "Yanked/deleted versions", lambda: detect_yanked_versions(deps), lambda findings: f"Found {len(findings)} yanked versions", ), ( - "[9/12]", + "[9/14]", "Branch protection changes", lambda: detect_protection_changes(protection), lambda findings: f"Found {len(findings)} protection findings", ), ( - "[10/12]", + "[10/14]", "Post-approval commits in PRs", lambda: detect_post_approval_commits(pr_audits), lambda findings: f"Found {len(findings)} PRs with post-approval commits", ), ( - "[11/13]", + "[11/14]", "Bot-only approvals (no human review)", lambda: detect_bot_only_approval(pr_audits, prs), lambda findings: f"Found {len(findings)} PRs with bot-only approval", ), ( - "[12/13]", + "[12/14]", "Self-approved PRs", lambda: detect_self_approval(pr_audits), lambda findings: f"Found {len(findings)} self-approved PRs", ), ( - "[13/13]", + "[13/14]", "Known vulnerabilities (OSV.dev)", lambda: detect_known_vulnerabilities(vulns), lambda findings: f"Found {len(findings)} known vulnerabilities", ), + ( + "[14/14]", + "OpenSSF Scorecard workflow and scores", + lambda: detect_scorecard_issues(scorecards), + lambda findings: f"Found {len(findings)} Scorecard findings", + ), ] all_findings: list[Finding] = [] @@ -1301,6 +1606,7 @@ def run_analysis(cache_dir: Path) -> list[Finding]: pr_audits = get_all_cached_pr_audits(cache_dir) renovate_configs = get_all_cached_renovate(cache_dir) vulns = get_all_cached_vulns(cache_dir) + scorecards = get_all_cached_scorecard(cache_dir) _print_cache_stats( commits, @@ -1311,6 +1617,7 @@ def run_analysis(cache_dir: Path) -> list[Finding]: pr_audits, renovate_configs, vulns, + scorecards, ) all_findings = _run_detection_passes( @@ -1322,6 +1629,7 @@ def run_analysis(cache_dir: Path) -> list[Finding]: pr_audits, renovate_configs, vulns, + scorecards, ) _print_risk_summary(all_findings) diff --git a/.agents/skills/td-supply-chain-audit/scripts/audit_models.py b/.agents/skills/td-supply-chain-audit/scripts/audit_models.py index f8a48457..5e36d2c1 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/audit_models.py +++ b/.agents/skills/td-supply-chain-audit/scripts/audit_models.py @@ -44,6 +44,7 @@ class FindingCategory(enum.Enum): COOLDOWN_VIOLATED: Merged before cooldown elapsed. KNOWN_VULNERABILITY: Known CVE in dependency. SELF_APPROVED: Author approved their own PR. + SCORECARD: OpenSSF Scorecard workflow or score issues. """ @@ -61,6 +62,23 @@ class FindingCategory(enum.Enum): COOLDOWN_VIOLATED = "cooldown_violated" KNOWN_VULNERABILITY = "known_vulnerability" SELF_APPROVED = "self_approved" + SCORECARD = "scorecard" + + +# Shared OpenSSF Scorecard risk thresholds / critical checks (analyze + report). +SCORECARD_HIGH_THRESHOLD = 5.0 +SCORECARD_MEDIUM_THRESHOLD = 7.0 +SCORECARD_CRITICAL_CHECKS = frozenset( + { + "Token-Permissions", + "Dangerous-Workflow", + "Branch-Protection", + "Code-Review", + "Maintained", + "Pinned-Dependencies", + "Security-Policy", + }, +) @dataclass diff --git a/.agents/skills/td-supply-chain-audit/scripts/cache_utils.py b/.agents/skills/td-supply-chain-audit/scripts/cache_utils.py index 820b4b6d..aee73db3 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/cache_utils.py +++ b/.agents/skills/td-supply-chain-audit/scripts/cache_utils.py @@ -110,7 +110,17 @@ def ensure_cache_structure(cache_dir: Path) -> None: cache_dir: Root cache directory. """ - subdirs = ["commits", "prs", "checks", "deps"] + subdirs = [ + "commits", + "prs", + "checks", + "deps", + "scorecard", + "protection", + "renovate", + "vulns", + "pr_audits", + ] for sub in subdirs: (cache_dir / sub).mkdir(parents=True, exist_ok=True) @@ -494,3 +504,27 @@ def get_all_cached_pr_audits(cache_dir: Path) -> list[dict[str, object]]: if isinstance(data, list): all_audits.extend(data) return all_audits + + +def get_all_cached_scorecard(cache_dir: Path) -> dict[str, dict[str, object]]: + """Load all cached OpenSSF Scorecard data, keyed by repo name. + + Args: + cache_dir: Root cache directory. + + Returns: + Scorecard data grouped by repository. + + """ + scorecards: dict[str, dict[str, object]] = {} + scorecard_dir = cache_dir / "scorecard" + if not scorecard_dir.exists(): + return {} + for f in sorted(scorecard_dir.iterdir()): + if f.suffix == ".json": + repo_name = repo_from_cache_name(f.stem) + with f.open(encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + scorecards[repo_name] = data + return scorecards diff --git a/.agents/skills/td-supply-chain-audit/scripts/collect.py b/.agents/skills/td-supply-chain-audit/scripts/collect.py index 95f674b2..f7842c37 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/collect.py +++ b/.agents/skills/td-supply-chain-audit/scripts/collect.py @@ -10,10 +10,16 @@ import argparse import base64 +import contextlib import json +import os +import platform import re +import shutil import subprocess import sys +import tarfile +import tempfile import time import urllib.error import urllib.request @@ -68,6 +74,35 @@ OSV_REQUEST_TIMEOUT_SECONDS = 30 OSV_BATCH_SIZE = 1000 OSV_BATCH_SLEEP_SECONDS = 1 +SCORECARD_REQUEST_TIMEOUT_SECONDS = 20 +SCORECARD_CLI_TIMEOUT_SECONDS = 300 +SCORECARD_CLI_DOWNLOAD_TIMEOUT_SECONDS = 120 +SCORECARD_CLI_BIN = "scorecard" +# Pinned release used when auto-bootstrapping the CLI (linux/mac). +SCORECARD_CLI_VERSION = "v5.5.0" +# Full Scorecard suite minus Vulnerabilities. That check walks OSV for the +# dependency graph and can hang for 15+ minutes on larger repos (e.g. +# ansible-builder). Dependency CVEs are already covered by the audit's +# separate OSV.dev inventory pass. +SCORECARD_CLI_CHECKS = ( + "Binary-Artifacts," + "Branch-Protection," + "CI-Tests," + "CII-Best-Practices," + "Code-Review," + "Contributors," + "Dangerous-Workflow," + "Dependency-Update-Tool," + "Fuzzing," + "License," + "Maintained," + "Packaging," + "Pinned-Dependencies," + "SAST," + "Security-Policy," + "Signed-Releases," + "Token-Permissions" +) MAX_COMMIT_MSG_LEN = 120 GITHUB_EXT_PREFIX_LEN = 7 GITHUB_EXT_EXPECTED_PARTS = 2 @@ -75,6 +110,31 @@ CVSS_HIGH_THRESHOLD = 7.0 CVSS_MEDIUM_THRESHOLD = 4.0 DATE_FORMAT = "%Y-%m-%d" +SCORECARD_WORKFLOW_NAMES = { + "scorecard.yml", + "scorecard.yaml", + "ossf-scorecard.yml", + "ossf-scorecard.yaml", + "openssf-scorecard.yml", + "openssf-scorecard.yaml", +} +SCORECARD_ACTION_RE = re.compile( + r"uses:\s*['\"]?ossf/scorecard-action@", + re.IGNORECASE, +) +SCORECARD_PUBLISH_RE = re.compile( + r"publish_results\s*:\s*(true|false)", + re.IGNORECASE, +) +SCORECARD_SCHEDULE_RE = re.compile(r"^\s*schedule\s*:", re.MULTILINE) +SCORECARD_BRANCH_PROTECTION_RE = re.compile( + r"^\s*branch_protection_rule\s*:", + re.MULTILINE, +) +SCORECARD_SARIF_UPLOAD_RE = re.compile( + r"codeql-action/upload-sarif", + re.IGNORECASE, +) DEP_FILES_PYTHON = { "pyproject.toml", @@ -1336,6 +1396,557 @@ def collect_protection_changes(repo: str, start_date: str, end_date: str) -> lis return changes +def _empty_scorecard_workflow() -> dict: + """Return the default workflow metadata when no Scorecard workflow exists.""" + return { + "present": False, + "path": None, + "publish_results": None, + "has_schedule": False, + "has_branch_protection_trigger": False, + "uploads_sarif": False, + "uses_scorecard_action": False, + } + + +def _analyze_scorecard_workflow(content: str, path: str) -> dict: + """Parse Scorecard workflow YAML text into normalized metadata. + + Args: + content: Raw workflow file contents. + path: Repository-relative workflow path. + + Returns: + Workflow metadata dict. + + """ + publish_match = SCORECARD_PUBLISH_RE.search(content) + publish_results = None + if publish_match: + publish_results = publish_match.group(1).lower() == "true" + + return { + "present": True, + "path": path, + "publish_results": publish_results, + "has_schedule": bool(SCORECARD_SCHEDULE_RE.search(content)), + "has_branch_protection_trigger": bool( + SCORECARD_BRANCH_PROTECTION_RE.search(content), + ), + "uploads_sarif": bool(SCORECARD_SARIF_UPLOAD_RE.search(content)), + "uses_scorecard_action": bool(SCORECARD_ACTION_RE.search(content)), + } + + +def _find_scorecard_workflow(repo: str) -> dict: + """Locate and analyze a Scorecard GitHub Actions workflow in a repo. + + Args: + repo: Repository name (``org/repo``). + + Returns: + Workflow metadata dict. + + """ + workflows = gh_api(f"repos/{repo}/contents/.github/workflows") + if not workflows or not isinstance(workflows, list): + return _empty_scorecard_workflow() + + candidates = [] + for entry in workflows: + name = (entry.get("name") or "").lower() + path = entry.get("path") or "" + if name in SCORECARD_WORKFLOW_NAMES or "scorecard" in name: + candidates.append(path) + + if not candidates: + return _empty_scorecard_workflow() + + # Prefer canonical scorecard.yml / scorecard.yaml names. + candidates.sort( + key=lambda p: (0 if Path(p).name.lower() in SCORECARD_WORKFLOW_NAMES else 1, p), + ) + path = candidates[0] + data = gh_api(f"repos/{repo}/contents/{path}") + if not data or not isinstance(data, dict) or "content" not in data: + result = _empty_scorecard_workflow() + result["present"] = True + result["path"] = path + return result + + try: + content = base64.b64decode(data["content"]).decode("utf-8") + except (ValueError, UnicodeDecodeError): + result = _empty_scorecard_workflow() + result["present"] = True + result["path"] = path + return result + + return _analyze_scorecard_workflow(content, path) + + +def _empty_scorecard_score(api_url: str) -> dict: + """Return the default Scorecard score payload. + + Args: + api_url: OpenSSF Scorecard API URL for the repo. + + Returns: + Empty normalized score dict. + + """ + return { + "available": False, + "source": None, + "score": None, + "date": None, + "scorecard_version": None, + "commit": None, + "checks": [], + "api_url": api_url, + "badge_url": f"{api_url}/badge", + "error": None, + } + + +def _normalize_scorecard_payload(data: dict, *, source: str, api_url: str) -> dict: + """Normalize OpenSSF API or Scorecard CLI JSON into a common shape. + + Args: + data: Raw Scorecard JSON document. + source: ``api`` or ``cli``. + api_url: Public OpenSSF API URL for the repository. + + Returns: + Normalized score payload with ``available=True``. + + """ + checks = [] + for check in data.get("checks") or []: + documentation = check.get("documentation") or {} + doc_url = documentation.get("url", "") + if not doc_url and isinstance(check.get("details"), list): + # CLI JSON sometimes omits documentation; keep empty. + doc_url = "" + checks.append( + { + "name": check.get("name", ""), + "score": check.get("score"), + "reason": check.get("reason", ""), + "documentation_url": doc_url, + }, + ) + + scorecard_meta = data.get("scorecard") or {} + repo_meta = data.get("repo") or {} + return { + "available": True, + "source": source, + "score": data.get("score"), + "date": data.get("date"), + "scorecard_version": scorecard_meta.get("version"), + "commit": repo_meta.get("commit"), + "checks": checks, + "api_url": api_url, + "badge_url": f"{api_url}/badge", + "error": None, + } + + +def _fetch_openssf_scorecard(repo: str) -> dict: + """Fetch published OpenSSF Scorecard results for a repository. + + Args: + repo: Repository name (``org/repo``). + + Returns: + Normalized score payload with availability flag. + + """ + api_url = f"https://api.securityscorecards.dev/projects/github.com/{repo}" + result = _empty_scorecard_score(api_url) + try: + req = urllib.request.Request( # noqa: S310 + api_url, + headers={"User-Agent": "supply-chain-audit/1.0", "Accept": "application/json"}, + ) + with urllib.request.urlopen( # noqa: S310 + req, + timeout=SCORECARD_REQUEST_TIMEOUT_SECONDS, + ) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as exc: + result["error"] = f"http_{exc.code}" + return result + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + result["error"] = type(exc).__name__ + return result + + if not isinstance(data, dict) or data.get("score") is None: + result["error"] = "api_payload_invalid" + return result + return _normalize_scorecard_payload(data, source="api", api_url=api_url) + + +def _resolve_github_token() -> str | None: + """Resolve a GitHub token for Scorecard CLI rate limits. + + Returns: + Token string, or ``None`` if unavailable. + + """ + for key in ("GITHUB_AUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"): + value = os.environ.get(key, "").strip() + if value: + return value + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + timeout=GH_VERSION_TIMEOUT_SECONDS, + check=False, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return None + token = (result.stdout or "").strip() + return token or None + + +def _scorecard_managed_bin_dir() -> Path: + """Return the directory used for auto-downloaded Scorecard binaries.""" + return Path(".supply-chain-audit") / "bin" + + +def _scorecard_platform_triple() -> tuple[str, str] | None: + """Map the current OS/arch to a Scorecard release asset triple. + + Returns: + ``(goos, goarch)`` such as ``("darwin", "arm64")`` or + ``("windows", "amd64")``, or ``None``. + + """ + system = platform.system().lower() + machine = platform.machine().lower() + if system == "darwin": + goos = "darwin" + elif system == "linux": + goos = "linux" + elif system == "windows": + goos = "windows" + else: + return None + + if machine in {"x86_64", "amd64"}: + goarch = "amd64" + elif machine in {"arm64", "aarch64"}: + goarch = "arm64" + else: + return None + return goos, goarch + + +def _scorecard_binary_name() -> str: + """Return the Scorecard executable filename for the current OS.""" + if platform.system().lower() == "windows": + return f"{SCORECARD_CLI_BIN}.exe" + return SCORECARD_CLI_BIN + + +def _scorecard_release_asset_name(goos: str, goarch: str) -> str: + """Build the Scorecard release tarball name for an OS/arch pair.""" + version = SCORECARD_CLI_VERSION.lstrip("v") + return f"scorecard_{version}_{goos}_{goarch}.tar.gz" + + +def _scorecard_archive_member_names() -> tuple[str, ...]: + """Filenames accepted inside a Scorecard release archive.""" + return (SCORECARD_CLI_BIN, f"{SCORECARD_CLI_BIN}.exe") + + +def _extract_scorecard_from_tar(tmp_path: Path, dest: Path) -> bool: + """Extract the Scorecard binary from a release tarball. + + Args: + tmp_path: Downloaded ``.tar.gz`` path. + dest: Destination executable path. + + Returns: + ``True`` on success. + + """ + with tarfile.open(tmp_path, "r:gz") as tar: + member = None + for entry in tar.getmembers(): + name = Path(entry.name).name + if name in _scorecard_archive_member_names() and entry.isfile(): + member = entry + break + if member is None: + print(" Scorecard CLI archive missing scorecard binary", file=sys.stderr) + return False + member.name = dest.name + try: + tar.extract(member, path=dest.parent, filter="data") + except TypeError: + # Python < 3.12 has no filter= argument. + tar.extract(member, path=dest.parent) + extracted = dest.parent / dest.name + if extracted != dest: + extracted.replace(dest) + # Windows may not support POSIX mode bits; execution still works. + with contextlib.suppress(OSError): + dest.chmod(0o755) + return True + + +def _download_scorecard_cli(dest: Path) -> str | None: + """Download and extract the pinned Scorecard CLI into ``dest``. + + Args: + dest: Destination path for the ``scorecard`` / ``scorecard.exe`` binary. + + Returns: + Path string on success, or ``None`` on failure. + + """ + triple = _scorecard_platform_triple() + if not triple: + print( + f" Scorecard CLI auto-install unsupported on {platform.system()}/{platform.machine()}", + file=sys.stderr, + ) + return None + + goos, goarch = triple + asset = _scorecard_release_asset_name(goos, goarch) + url = f"https://github.com/ossf/scorecard/releases/download/{SCORECARD_CLI_VERSION}/{asset}" + dest.parent.mkdir(parents=True, exist_ok=True) + print(f" Bootstrapping Scorecard CLI {SCORECARD_CLI_VERSION} ({goos}/{goarch})...") + + try: + req = urllib.request.Request( # noqa: S310 + url, + headers={"User-Agent": "supply-chain-audit/1.0"}, + ) + with ( + urllib.request.urlopen( # noqa: S310 + req, + timeout=SCORECARD_CLI_DOWNLOAD_TIMEOUT_SECONDS, + ) as resp, + tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp, + ): + shutil.copyfileobj(resp, tmp) + tmp_path = Path(tmp.name) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + print(f" Scorecard CLI download failed: {exc}", file=sys.stderr) + return None + + try: + if not _extract_scorecard_from_tar(tmp_path, dest): + return None + except (tarfile.TarError, OSError) as exc: + print(f" Scorecard CLI extract failed: {exc}", file=sys.stderr) + return None + finally: + tmp_path.unlink(missing_ok=True) + + print(f" Scorecard CLI ready: {dest}") + return str(dest) + + +def _managed_scorecard_binary() -> Path: + """Return the managed Scorecard binary path for this OS.""" + return _scorecard_managed_bin_dir() / _scorecard_binary_name() + + +def _is_usable_executable(path: Path) -> bool: + """Return whether ``path`` looks like a usable Scorecard binary.""" + if not path.is_file(): + return False + if platform.system().lower() == "windows": + return True + return os.access(path, os.X_OK) + + +def _ensure_scorecard_cli() -> str | None: + """Locate the pinned managed Scorecard CLI, downloading it if needed. + + Prefers the audit-managed binary (pinned ``SCORECARD_CLI_VERSION``) over any + ``scorecard`` on ``PATH`` so audits are reproducible and a poisoned PATH + cannot override the pinned tool. + + Returns: + Absolute path to the binary, or ``None`` if unavailable. + + """ + managed = _managed_scorecard_binary() + if _is_usable_executable(managed): + return str(managed.resolve()) + + downloaded = _download_scorecard_cli(managed) + if downloaded: + return downloaded + + # Last resort: PATH (may differ from the pinned version). + return shutil.which(SCORECARD_CLI_BIN) or shutil.which(f"{SCORECARD_CLI_BIN}.exe") + + +def _parse_scorecard_cli_stdout(stdout: str) -> dict | None: + """Parse Scorecard CLI JSON from stdout, tolerating leading log lines. + + Args: + stdout: Captured CLI standard output. + + Returns: + Parsed JSON object, or ``None`` when no valid object is found. + + """ + text = stdout.strip() + if not text: + return None + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + for raw_line in reversed(text.splitlines()): + candidate = raw_line.strip() + if candidate.startswith("{") and candidate.endswith("}"): + try: + payload = json.loads(candidate) + break + except json.JSONDecodeError: + continue + return payload if isinstance(payload, dict) else None + + +def _run_scorecard_cli(repo: str) -> dict: + """Run the local Scorecard CLI against a repository. + + Args: + repo: Repository name (``org/repo``). + + Returns: + Normalized score payload (``available`` may be false on failure). + + """ + api_url = f"https://api.securityscorecards.dev/projects/github.com/{repo}" + result = _empty_scorecard_score(api_url) + cli = _ensure_scorecard_cli() + if not cli: + result["error"] = "scorecard_cli_not_installed" + return result + + env = os.environ.copy() + token = _resolve_github_token() + if token: + # Scorecard accepts any of these; set all common variants. + env.setdefault("GITHUB_AUTH_TOKEN", token) + env.setdefault("GH_TOKEN", token) + env.setdefault("GITHUB_TOKEN", token) + + cmd = [ + cli, + f"--repo=github.com/{repo}", + "--format=json", + "--show-details=false", + f"--checks={SCORECARD_CLI_CHECKS}", + ] + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=SCORECARD_CLI_TIMEOUT_SECONDS, + check=False, + env=env, + ) + except subprocess.TimeoutExpired: + result["error"] = "scorecard_cli_timeout" + return result + except OSError as exc: + result["error"] = f"scorecard_cli_oserror:{type(exc).__name__}" + return result + + stdout = proc.stdout or "" + payload = _parse_scorecard_cli_stdout(stdout) + if payload is None: + kind = "empty_output" if not stdout.strip() else "bad_json" + result["error"] = f"scorecard_cli_{kind}:exit_{proc.returncode}" + return result + + normalized = _normalize_scorecard_payload(payload, source="cli", api_url=api_url) + if proc.returncode != 0 and normalized.get("score") is None: + result["error"] = f"scorecard_cli_exit_{proc.returncode}" + return result + # Record that CLI scores intentionally omit the OSV Vulnerabilities check. + normalized["cli_checks_excluded"] = ["Vulnerabilities"] + return normalized + + +def _print_scorecard_status(scorecard: dict) -> None: + """Print a one-line Scorecard collection summary for a repo. + + Args: + scorecard: Combined Scorecard audit payload. + + """ + workflow = scorecard.get("workflow") or {} + score_data = scorecard.get("scorecard") or {} + score_val = score_data.get("score") + source = score_data.get("source") or "none" + available = bool(score_data.get("available")) + if available: + score_msg = f"score={score_val} via {source}" + else: + err = score_data.get("error") or "unpublished" + score_msg = f"score=unavailable ({err})" + + if workflow.get("present"): + wf_path = workflow.get("path") or "scorecard.yml" + print(f" Scorecard workflow: {wf_path} ({score_msg})") + else: + print(f" Scorecard workflow: missing ({score_msg})") + + +def collect_scorecard(repo: str, *, use_cli: bool = True) -> dict: + """Collect Scorecard workflow presence and scores (API, then CLI fallback). + + Prefers the public OpenSSF Scorecard API. When no published score exists and + ``use_cli`` is true, runs the local ``scorecard`` binary to evaluate the + repo (requires CLI install + GitHub token for rate limits). + + Args: + repo: Repository name (``org/repo``). + use_cli: Whether to fall back to the Scorecard CLI. + + Returns: + Combined Scorecard audit payload for caching. + + """ + workflow = _find_scorecard_workflow(repo) + score = _fetch_openssf_scorecard(repo) + # Prefer published API scores; only fall back to CLI when API has nothing. + if not score.get("available") and use_cli: + print(" OpenSSF API miss — running Scorecard CLI...") + cli_score = _run_scorecard_cli(repo) + if cli_score.get("available"): + # Keep API error for diagnostics while using CLI results. + cli_score["api_error"] = score.get("error") + score = cli_score + else: + score["cli_error"] = cli_score.get("error") + print( + f" Scorecard CLI unavailable for {repo}: {cli_score.get('error')}", + ) + return { + "repo": repo, + "workflow": workflow, + "scorecard": score, + "collected_at": datetime.now(UTC).isoformat(), + } + + def _load_cached_repo_counts(cache_dir: Path, repo: str) -> tuple[int, int]: """Return commit and PR counts from cached repo data. @@ -1391,13 +2002,20 @@ def _scan_osv_for_repo(repo: str) -> list[dict]: return vuln_results -def _collect_repo_artifacts(repo: str, start_date: str, end_date: str) -> dict: +def _collect_repo_artifacts( + repo: str, + start_date: str, + end_date: str, + *, + use_scorecard_cli: bool = True, +) -> dict: """Collect all audit artifacts for a single repo. Args: repo: Repository name. start_date: Audit window start (YYYY-MM-DD). end_date: Audit window end (YYYY-MM-DD). + use_scorecard_cli: Fall back to local Scorecard CLI when API has no score. Returns: Dict of collected artifact categories. @@ -1445,6 +2063,10 @@ def _collect_repo_artifacts(repo: str, start_date: str, end_date: str) -> dict: protection_changes = collect_protection_changes(repo, start_date, end_date) print(f" Protection changes in window: {len(protection_changes)}") + print(" Fetching OpenSSF Scorecard status...") + scorecard = collect_scorecard(repo, use_cli=use_scorecard_cli) + _print_scorecard_status(scorecard) + return { "commits": commits, "prs": prs, @@ -1455,6 +2077,7 @@ def _collect_repo_artifacts(repo: str, start_date: str, end_date: str) -> dict: "vuln_results": vuln_results, "protection": protection, "protection_changes": protection_changes, + "scorecard": scorecard, } @@ -1489,6 +2112,28 @@ def _write_repo_cache_files(cache_dir: Path, repo: str, artifacts: dict) -> None "changes": artifacts["protection_changes"], }, ) + write_cache_file(cache_dir, "scorecard", cache_name, artifacts["scorecard"]) + + +def _collect_and_cache_scorecard( + repo: str, + cache_dir: Path, + *, + use_scorecard_cli: bool = True, +) -> None: + """Collect Scorecard data for a repo and write it to cache. + + Args: + repo: Repository name. + cache_dir: Root cache directory. + use_scorecard_cli: Fall back to local Scorecard CLI when API has no score. + + """ + print(" Fetching OpenSSF Scorecard status...") + scorecard = collect_scorecard(repo, use_cli=use_scorecard_cli) + cache_name = f"{repo_cache_name(repo)}.json" + write_cache_file(cache_dir, "scorecard", cache_name, scorecard) + _print_scorecard_status(scorecard) def collect_repo( @@ -1498,6 +2143,8 @@ def collect_repo( cache_dir: Path, *, force: bool = False, + use_scorecard_cli: bool = True, + refresh_scorecard: bool = False, ) -> tuple[int, int]: """Collect all data for a single repo. @@ -1507,6 +2154,8 @@ def collect_repo( end_date: Audit window end (YYYY-MM-DD). cache_dir: Root cache directory. force: Re-collect even if cached data exists. + use_scorecard_cli: Fall back to local Scorecard CLI when API has no score. + refresh_scorecard: Re-collect Scorecard even when other artifacts are cached. Returns: Tuple of (commit_count, pr_count). @@ -1519,9 +2168,42 @@ def collect_repo( if not force and has_cached_data(cache_dir, repo, "commits"): print(f" [cached] Skipping {repo} (already collected)") + # Backfill/refresh Scorecard when missing, forced, or still unscored + # so CLI auto-bootstrap fills gaps without a full re-collect. + existing = read_cache_file( + cache_dir, + "scorecard", + f"{repo_cache_name(repo)}.json", + ) + score_meta = (existing.get("scorecard") or {}) if isinstance(existing, dict) else {} + score_available = bool(score_meta.get("available")) + cli_only = score_available and score_meta.get("source") == "cli" + if refresh_scorecard or existing is None or not score_available: + _collect_and_cache_scorecard( + repo, + cache_dir, + use_scorecard_cli=use_scorecard_cli, + ) + elif cli_only: + # Re-check OpenSSF API only; keep the CLI snapshot if still unpublished. + print(" Checking whether OpenSSF API score is now published...") + api_only = collect_scorecard(repo, use_cli=False) + if (api_only.get("scorecard") or {}).get("available"): + write_cache_file( + cache_dir, + "scorecard", + f"{repo_cache_name(repo)}.json", + api_only, + ) + _print_scorecard_status(api_only) return _load_cached_repo_counts(cache_dir, repo) - artifacts = _collect_repo_artifacts(repo, start_date, end_date) + artifacts = _collect_repo_artifacts( + repo, + start_date, + end_date, + use_scorecard_cli=use_scorecard_cli, + ) _write_repo_cache_files(cache_dir, repo, artifacts) commits = artifacts["commits"] @@ -1553,6 +2235,24 @@ def main() -> None: nargs="*", help="Specific repos to collect (default: all)", ) + parser.add_argument( + "--scorecard-cli", + dest="scorecard_cli", + action="store_true", + default=True, + help="Fall back to local Scorecard CLI when OpenSSF API has no score (default)", + ) + parser.add_argument( + "--skip-scorecard-cli", + dest="scorecard_cli", + action="store_false", + help="Do not run the local Scorecard CLI; use OpenSSF API only", + ) + parser.add_argument( + "--refresh-scorecard", + action="store_true", + help="Re-fetch Scorecard (API/CLI) even when other repo data is cached", + ) args = parser.parse_args() try: @@ -1570,6 +2270,17 @@ def main() -> None: ) sys.exit(1) print(f"Using: {gh_version}") + if args.scorecard_cli: + # Lazy bootstrap: download only when a repo actually needs the CLI. + managed = _managed_scorecard_binary() + if _is_usable_executable(managed): + print(f"Scorecard CLI: {managed.resolve()} (pinned {SCORECARD_CLI_VERSION})") + else: + print( + f"Scorecard CLI: will auto-bootstrap {SCORECARD_CLI_VERSION} when OpenSSF API has no score", + ) + else: + print("Scorecard CLI: disabled (--skip-scorecard-cli)") repos = [normalize_repo(r) for r in (args.repos or TARGET_REPOS)] cache_dir = get_cache_dir(args.cache_dir, args.start, args.end) @@ -1584,7 +2295,15 @@ def main() -> None: total_prs = 0 for repo in repos: - c, p = collect_repo(repo, args.start, args.end, cache_dir, force=args.force) + c, p = collect_repo( + repo, + args.start, + args.end, + cache_dir, + force=args.force, + use_scorecard_cli=args.scorecard_cli, + refresh_scorecard=args.refresh_scorecard, + ) total_commits += c total_prs += p diff --git a/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html b/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html index 619d62e5..c3c2cd0e 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html +++ b/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html @@ -236,6 +236,7 @@ } .badge-critical { background: rgba(248, 81, 73, 0.2); color: var(--critical); } .badge-high { background: rgba(219, 109, 40, 0.2); color: var(--high); } +.text-muted { color: var(--text-muted); font-size: 0.85em; } .badge-medium { background: rgba(210, 153, 34, 0.2); color: var(--medium); } .badge-low { background: rgba(63, 185, 80, 0.2); color: var(--low); } .badge-info { background: rgba(139, 148, 158, 0.2); color: var(--info); } @@ -348,6 +349,7 @@

What Was Checked

  • Post-approval PR integrity: All commits within each PR branch inspected for pushes after the last review approval — especially from unexpected authors
  • Human review requirement: Every merged PR must have at least one approval from a human reviewer (bot-only approvals flagged)
  • Squash/merge via GitHub UI: Accepted as legitimate when they are the merge commit of a reviewed PR
  • +
  • OpenSSF Scorecard: Each repo should run ossf/scorecard-action (push + weekly schedule) and publish results. The audit prefers published OpenSSF API scores and falls back to a local scorecard CLI run when the API has no data; weak critical checks are flagged either way
  • @@ -413,6 +415,9 @@

    Dependency Changes

    {{renovate_section}} + +{{scorecard_section}} + {{findings_details_section}} diff --git a/.agents/skills/td-supply-chain-audit/scripts/report.py b/.agents/skills/td-supply-chain-audit/scripts/report.py index f342c7af..5875050d 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/report.py +++ b/.agents/skills/td-supply-chain-audit/scripts/report.py @@ -16,6 +16,11 @@ from pathlib import Path try: + from audit_models import ( # pylint: disable=import-error + SCORECARD_CRITICAL_CHECKS, + SCORECARD_HIGH_THRESHOLD, + SCORECARD_MEDIUM_THRESHOLD, + ) from cache_utils import ( # pylint: disable=import-error get_all_cached_checks, get_all_cached_commits, @@ -24,12 +29,18 @@ get_all_cached_protection, get_all_cached_prs, get_all_cached_renovate, + get_all_cached_scorecard, read_findings, read_manifest, read_package_focus, ) except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parent)) + from audit_models import ( + SCORECARD_CRITICAL_CHECKS, + SCORECARD_HIGH_THRESHOLD, + SCORECARD_MEDIUM_THRESHOLD, + ) from cache_utils import ( get_all_cached_checks, get_all_cached_commits, @@ -38,6 +49,7 @@ get_all_cached_protection, get_all_cached_prs, get_all_cached_renovate, + get_all_cached_scorecard, read_findings, read_manifest, read_package_focus, @@ -68,6 +80,7 @@ "cooldown_violated": "Renovate Cooldown Violated", "known_vulnerability": "Known Vulnerabilities (OSV.dev)", "self_approved": "Self-Approved PRs", + "scorecard": "OpenSSF Scorecard", } @@ -875,6 +888,145 @@ def generate_renovate_config_table( ) +def _format_scorecard_score(score: object) -> str: + """Format an OpenSSF Scorecard aggregate score with a risk badge. + + Args: + score: Numeric score or ``None``. + + Returns: + HTML fragment. + + """ + if not isinstance(score, (int, float)): + return f'{EM_DASH}' + if score < SCORECARD_HIGH_THRESHOLD: + badge = "badge-critical" + elif score < SCORECARD_MEDIUM_THRESHOLD: + badge = "badge-high" + else: + badge = "badge-low" + return f'{score}' + + +def _format_scorecard_workflow(workflow: dict) -> str: + """Format Scorecard workflow presence for the report table. + + Args: + workflow: Workflow metadata dict. + + Returns: + HTML fragment. + + """ + if not workflow.get("present"): + return 'missing' + path = esc(workflow.get("path") or "scorecard.yml") + flags = [] + if workflow.get("publish_results") is True: + flags.append("publish") + elif workflow.get("publish_results") is False: + flags.append("no-publish") + if workflow.get("has_schedule"): + flags.append("schedule") + if workflow.get("uploads_sarif"): + flags.append("sarif") + flag_str = ", ".join(flags) if flags else "present" + return f'{path} ({flag_str})' + + +def _weak_scorecard_checks(checks: list[dict]) -> str: + """Summarize weak critical Scorecard checks for display. + + Args: + checks: Scorecard check dicts. + + Returns: + Comma-separated weak check summary, or em dash. + + """ + weak = [] + for check in checks: + name = check.get("name", "") + score = check.get("score") + if ( + name in SCORECARD_CRITICAL_CHECKS + and isinstance(score, (int, float)) + and 0 <= score < SCORECARD_HIGH_THRESHOLD + ): + weak.append(f"{name}={score}") + return esc(", ".join(weak)) if weak else EM_DASH + + +def generate_scorecard_section( + scorecards: dict[str, dict], + repos: list[str], +) -> str: + """Generate the OpenSSF Scorecard status table. + + Args: + scorecards: Scorecard payloads keyed by repo. + repos: Repository names. + + Returns: + HTML section string. + + """ + if not scorecards and not repos: + return "" + + rows = [] + for repo in sorted(repos): + data = scorecards.get(repo, {}) + workflow = data.get("workflow") or {} + score_data = data.get("scorecard") or {} + score_cell = _format_scorecard_score(score_data.get("score")) + source = score_data.get("source") + if score_data.get("available") and score_data.get("api_url") and source == "api": + score_cell = ( + f'{score_cell}' + ) + if source == "api": + source_cell = 'API' + elif source == "cli": + source_cell = 'CLI' + else: + source_cell = f'{EM_DASH}' + date_str = esc(score_data.get("date") or EM_DASH) + weak = _weak_scorecard_checks(score_data.get("checks") or []) + rows.append( + "" + f"{esc(repo)}" + f"{_format_scorecard_workflow(workflow)}" + f"{score_cell}" + f"{source_cell}" + f"{date_str}" + f"{weak}" + "", + ) + + return ( + "

    OpenSSF Scorecard

    " + "

    Per-repository Scorecard workflow presence and scores. Scores come from " + "the public OpenSSF API when published, otherwise from a local " + "scorecard CLI run during collection (CLI omits the " + "Vulnerabilities check). Missing workflows are shown in this table only; " + "weak critical checks and low published API scores are raised as " + "findings.

    " + '
    ' + '' + '' + "" + '' + "" + "" + "" + "" + f"{''.join(rows)}" + "
    Repository \u25beWorkflowScore \u25beSourceDateWeak Critical Checks
    " + ) + + def generate_findings_details(findings: list[dict]) -> str: """Generate collapsible findings detail sections. @@ -1069,6 +1221,7 @@ def _load_report_data(cache_dir: Path) -> dict: protection = get_all_cached_protection(cache_dir) pr_audits = get_all_cached_pr_audits(cache_dir) renovate_configs = get_all_cached_renovate(cache_dir) + scorecards = get_all_cached_scorecard(cache_dir) findings = read_findings(cache_dir) package_data = read_package_focus(cache_dir) @@ -1085,6 +1238,7 @@ def _load_report_data(cache_dir: Path) -> dict: "protection": protection, "pr_audits": pr_audits, "renovate_configs": renovate_configs, + "scorecards": scorecards, "findings": findings, "package_data": package_data, "total_check_suites": total_check_suites, @@ -1132,6 +1286,7 @@ def _generate_report_sections(data: dict) -> dict[str, str]: findings = data["findings"] protection = data["protection"] renovate_configs = data["renovate_configs"] + scorecards = data["scorecards"] package_data = data["package_data"] total_prs = data["total_prs"] @@ -1158,6 +1313,7 @@ def _generate_report_sections(data: dict) -> dict[str, str]: ), "dep_section": generate_dep_section(deps, prs), "renovate_section": generate_renovate_config_table(renovate_configs, repos), + "scorecard_section": generate_scorecard_section(scorecards, repos), "findings_details_section": generate_findings_details(findings), "package_focus_section": generate_package_focus_section(package_data), } diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..77352bd2 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,78 @@ +--- +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. +# +# Adapted from https://github.com/redhat-developer/abbenay/pull/57 +# (OpenSSF Scorecard supply-chain security workflow). + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: "30 8 * * 0" + push: + branches: ["main"] +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + # `publish_results: true` only works when run from the default branch. + if: github.event.repository.default_branch == github.ref_name + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + # Uncomment the permissions below if installing in a private repository. + # contents: read + # actions: read + + steps: + - name: "Checkout code" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: + # - you want to enable the Branch-Protection check on a *public* repository, or + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in + # https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable + # uploads of run results in SARIF format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard (optional). + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26 # v3.37.1 + with: + sarif_file: results.sarif From 584c3bf8fb24dff0f88ced33d707660ba81dd231 Mon Sep 17 00:00:00 2001 From: Sudhir Verma <9924513+sudhirverma@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:56:46 +0530 Subject: [PATCH 2/3] feat(supply-chain-audit): add show-more button for overflow findings Replace static "... and N more" text with a button that expands remaining Anomaly Details items; keep all findings visible in PDF. --- .../scripts/html_templates/dashboard.html | 29 +++++++++ .../scripts/pdf_export.py | 2 + .../td-supply-chain-audit/scripts/report.py | 62 +++++++++++++------ 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html b/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html index c3c2cd0e..142c2f44 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html +++ b/.agents/skills/td-supply-chain-audit/scripts/html_templates/dashboard.html @@ -271,6 +271,26 @@ .package-focus h3 { color: var(--accent); margin-top: 0; } .no-data { color: var(--text-muted); font-style: italic; padding: 2rem; text-align: center; } +.finding-item { + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); +} +.finding-item-hidden { display: none; } +.show-more-btn { + display: block; + width: 100%; + margin-top: 0.75rem; + padding: 0.55rem 1rem; + background: var(--surface-2); + color: var(--accent); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font: inherit; + font-size: 0.9rem; + font-weight: 500; +} +.show-more-btn:hover { background: var(--border); } .footer { margin-top: 3rem; padding-top: 1rem; @@ -490,6 +510,15 @@

    Dependency Changes

    body.classList.toggle('open'); }); }); + +function showMoreFindings(btn) { + const body = btn.closest('.collapsible-body'); + if (!body) return; + body.querySelectorAll('.finding-item-hidden').forEach(el => { + el.classList.remove('finding-item-hidden'); + }); + btn.remove(); +} diff --git a/.agents/skills/td-supply-chain-audit/scripts/pdf_export.py b/.agents/skills/td-supply-chain-audit/scripts/pdf_export.py index 497d4fc9..2ec7ede1 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/pdf_export.py +++ b/.agents/skills/td-supply-chain-audit/scripts/pdf_export.py @@ -36,6 +36,8 @@ line-height: 1.4 !important; } .controls, .filter-btn { display: none !important; } + .show-more-btn { display: none !important; } + .finding-item-hidden { display: block !important; } .collapsible-body { display: block !important; } .collapsible-header .arrow { display: none !important; } .collapsible-header { cursor: default !important; } diff --git a/.agents/skills/td-supply-chain-audit/scripts/report.py b/.agents/skills/td-supply-chain-audit/scripts/report.py index 5875050d..41b94862 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/report.py +++ b/.agents/skills/td-supply-chain-audit/scripts/report.py @@ -1027,6 +1027,40 @@ def generate_scorecard_section( ) +def _format_finding_item(finding: dict, *, hidden: bool = False) -> str: + """Render a single finding row for the Anomaly Details section. + + Args: + finding: Finding dict. + hidden: When True, mark the row as initially collapsed (show-more). + + Returns: + HTML fragment for one finding. + + """ + pr_num = finding.get("pr_number") + repo = finding.get("repo", "") + pr_link = "" + if pr_num: + repo_gh = repo if "/" in repo else f"ansible/{repo}" + pr_link = ( + f' PR #{pr_num}' + ) + summary_html = _linkify_advisory_ids(esc(finding.get("summary", ""))) + details_html = _linkify_advisory_ids(esc(finding.get("details", "")[:300])) + hidden_attr = ' class="finding-item finding-item-hidden"' if hidden else ' class="finding-item"' + return ( + f"" + f'' + f'{finding.get("risk_level", "")} ' + f"{esc(repo)}{pr_link} \u2014 {summary_html}" + f'
    ' + f"{details_html}
    " + f"" + ) + + def generate_findings_details(findings: list[dict]) -> str: """Generate collapsible findings detail sections. @@ -1059,29 +1093,17 @@ def generate_findings_details(findings: list[dict]) -> str: max_risk = cat_findings[0].get("risk_level", "info") if cat_findings else "info" items_html = [] - for f in cat_findings[:MAX_FINDINGS_PER_CATEGORY]: - pr_num = f.get("pr_number") - repo = f.get("repo", "") - pr_link = "" - if pr_num: - repo_gh = repo if "/" in repo else f"ansible/{repo}" - pr_link = ( - f' PR #{pr_num}' - ) - summary_html = _linkify_advisory_ids(esc(f.get("summary", ""))) - details_html = _linkify_advisory_ids(esc(f.get("details", "")[:300])) + for idx, f in enumerate(cat_findings): items_html.append( - f'
    ' - f'{f.get("risk_level", "")} ' - f"{esc(repo)}{pr_link} \u2014 {summary_html}" - f'
    ' - f"{details_html}
    " - f"
    ", + _format_finding_item(f, hidden=idx >= MAX_FINDINGS_PER_CATEGORY), ) - if count > MAX_FINDINGS_PER_CATEGORY: + remaining = count - MAX_FINDINGS_PER_CATEGORY + if remaining > 0: items_html.append( - f'
    ... and {count - MAX_FINDINGS_PER_CATEGORY} more
    ', + f'", ) section = ( From c501d64b498cfe68d9f339a25c1b54cb90563f9d Mon Sep 17 00:00:00 2001 From: ansibuddy <107943535+ansibuddy@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:27:23 +0000 Subject: [PATCH 3/3] chore[bot]: auto-fix lint errors --- .agents/skills/td-supply-chain-audit/scripts/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/td-supply-chain-audit/scripts/report.py b/.agents/skills/td-supply-chain-audit/scripts/report.py index 41b94862..3e6abb6f 100644 --- a/.agents/skills/td-supply-chain-audit/scripts/report.py +++ b/.agents/skills/td-supply-chain-audit/scripts/report.py @@ -1053,7 +1053,7 @@ def _format_finding_item(finding: dict, *, hidden: bool = False) -> str: return ( f"" f'' - f'{finding.get("risk_level", "")} ' + f"{finding.get('risk_level', '')} " f"{esc(repo)}{pr_link} \u2014 {summary_html}" f'
    ' f"{details_html}
    "