|
18 | 18 |
|
19 | 19 | if TYPE_CHECKING: |
20 | 20 | from socketsecurity.config import CliConfig |
| 21 | +from git import Repo |
21 | 22 | from socketdev import socketdev |
22 | 23 | from socketdev.exceptions import APIFailure |
23 | 24 | from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact |
|
56 | 57 | # Core.newest_persisted_scan_id), so a single result is not enough. |
57 | 58 | SCAN_LOOKUP_PAGE_SIZE = 10 |
58 | 59 |
|
| 60 | +# Bounds on the search for a scanned ancestor when the requested baseline commit has |
| 61 | +# no full scan of its own. The scan listing is fetched once and matched against local |
| 62 | +# history, so neither bound costs an extra request. |
| 63 | +ANCESTOR_SCAN_LOOKUP_LIMIT = 100 |
| 64 | +ANCESTOR_WALK_MAX_DEPTH = 100 |
| 65 | + |
59 | 66 | # Reachability facts-file upload compression. |
60 | 67 | # |
61 | 68 | # The Socket full-scan endpoint transparently brotli-decompresses any multipart part |
@@ -1555,6 +1562,102 @@ def newest_persisted_scan_id(results: List[dict]) -> Optional[str]: |
1555 | 1562 | return scan_id |
1556 | 1563 | return None |
1557 | 1564 |
|
| 1565 | + def first_parent_commits(self, start_commit_sha: str, max_count: int) -> List[str]: |
| 1566 | + """ |
| 1567 | + Lists a commit and its first-parent ancestors, newest first. |
| 1568 | +
|
| 1569 | + Follows only first parents so a merge commit contributes the branch's own |
| 1570 | + history rather than everything merged into it. A shallow checkout simply |
| 1571 | + yields fewer commits, which narrows the search rather than failing it. |
| 1572 | +
|
| 1573 | + Args: |
| 1574 | + start_commit_sha: Commit to walk back from, included in the result |
| 1575 | + max_count: Maximum number of commits to return |
| 1576 | +
|
| 1577 | + Returns: |
| 1578 | + Commit SHAs, newest first. Empty when the repository or commit is |
| 1579 | + unavailable locally. |
| 1580 | + """ |
| 1581 | + target_path = self.cli_config.target_path if self.cli_config else None |
| 1582 | + if not target_path: |
| 1583 | + return [] |
| 1584 | + try: |
| 1585 | + repo = Repo(target_path) |
| 1586 | + output = repo.git.rev_list( |
| 1587 | + "--first-parent", |
| 1588 | + f"--max-count={max_count}", |
| 1589 | + start_commit_sha, |
| 1590 | + ) |
| 1591 | + except Exception as error: |
| 1592 | + log.debug(f"Unable to walk history back from {start_commit_sha}: {error}") |
| 1593 | + return [] |
| 1594 | + return [line.strip() for line in output.splitlines() if line.strip()] |
| 1595 | + |
| 1596 | + def find_baseline_scan_for_ancestor( |
| 1597 | + self, |
| 1598 | + repo_slug: str, |
| 1599 | + commit_sha: str, |
| 1600 | + workspace: Optional[str] = None, |
| 1601 | + scan_type: Optional[str] = None, |
| 1602 | + ) -> Tuple[Optional[str], Optional[str], int]: |
| 1603 | + """ |
| 1604 | + Finds the nearest ancestor of a commit that does have a full scan. |
| 1605 | +
|
| 1606 | + Used when --base-commit-sha names a commit that was never scanned. Squash |
| 1607 | + merges and rebases rewrite commits, and a multi-commit push produces one scan |
| 1608 | + for the tip, so a merge base can be unscanned even when default-branch |
| 1609 | + scanning is configured correctly. Diffing against a slightly older ancestor |
| 1610 | + is a wider diff; failing outright is no diff at all. |
| 1611 | +
|
| 1612 | + One scan listing is fetched and matched against local first-parent history, |
| 1613 | + so the walk costs no additional requests. |
| 1614 | +
|
| 1615 | + Args: |
| 1616 | + repo_slug: Repository slug the scan belongs to |
| 1617 | + commit_sha: Commit that has no full scan of its own |
| 1618 | + workspace: Socket workspace the scan belongs to, if any |
| 1619 | + scan_type: Socket scan type to match, if any |
| 1620 | +
|
| 1621 | + Returns: |
| 1622 | + (scan_id, ancestor_commit_sha, commits_back), or (None, None, 0) when no |
| 1623 | + scanned ancestor is reachable. |
| 1624 | + """ |
| 1625 | + query_params = { |
| 1626 | + "repo": repo_slug, |
| 1627 | + "sort": "created_at", |
| 1628 | + "direction": "desc", |
| 1629 | + "per_page": ANCESTOR_SCAN_LOOKUP_LIMIT, |
| 1630 | + } |
| 1631 | + if workspace: |
| 1632 | + query_params["workspace"] = workspace |
| 1633 | + if scan_type: |
| 1634 | + query_params["scan_type"] = Core.query_param_value(scan_type) |
| 1635 | + |
| 1636 | + response = self.sdk.fullscans.get(self.config.org_slug, query_params) |
| 1637 | + results = response.get("results") if isinstance(response, dict) else None |
| 1638 | + if not results: |
| 1639 | + return None, None, 0 |
| 1640 | + |
| 1641 | + scans_by_commit = {} |
| 1642 | + for result in results: |
| 1643 | + if not isinstance(result, dict) or result.get("tmp"): |
| 1644 | + continue |
| 1645 | + result_commit = result.get("commit_hash") |
| 1646 | + scan_id = result.get("id") |
| 1647 | + # Newest first, so the first scan seen for a commit is the one to keep. |
| 1648 | + if result_commit and scan_id and result_commit not in scans_by_commit: |
| 1649 | + scans_by_commit[result_commit] = scan_id |
| 1650 | + |
| 1651 | + if not scans_by_commit: |
| 1652 | + return None, None, 0 |
| 1653 | + |
| 1654 | + ancestors = self.first_parent_commits(commit_sha, ANCESTOR_WALK_MAX_DEPTH) |
| 1655 | + for distance, ancestor in enumerate(ancestors): |
| 1656 | + scan_id = scans_by_commit.get(ancestor) |
| 1657 | + if scan_id: |
| 1658 | + return scan_id, ancestor, distance |
| 1659 | + return None, None, 0 |
| 1660 | + |
1558 | 1661 | def get_full_scan_id_by_commit( |
1559 | 1662 | self, |
1560 | 1663 | repo_slug: str, |
@@ -1628,19 +1731,38 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: |
1628 | 1731 | workspace=params.workspace, |
1629 | 1732 | scan_type=params.scan_type, |
1630 | 1733 | ) |
| 1734 | + baseline_source = "explicit-commit" |
| 1735 | + baseline_commit = commit_sha |
1631 | 1736 | if scan_id is None: |
1632 | | - log.error( |
1633 | | - f"No full scan found for commit {commit_sha} in repo {params.repo} " |
1634 | | - "(--base-commit-sha). Ensure a scan was created for that commit " |
1635 | | - "(e.g. the CLI runs on default-branch pushes), or pass " |
1636 | | - "--base-scan-id instead." |
| 1737 | + scan_id, ancestor_sha, commits_back = self.find_baseline_scan_for_ancestor( |
| 1738 | + params.repo, |
| 1739 | + commit_sha, |
| 1740 | + workspace=params.workspace, |
| 1741 | + scan_type=params.scan_type, |
1637 | 1742 | ) |
1638 | | - if self.cli_config.disable_blocking: |
1639 | | - sys.exit(0) |
1640 | | - sys.exit(self.cli_config.exit_code_on_api_error) |
| 1743 | + if scan_id: |
| 1744 | + baseline_source = "explicit-commit-ancestor" |
| 1745 | + baseline_commit = ancestor_sha |
| 1746 | + log.warning( |
| 1747 | + f"No full scan for commit {commit_sha} (--base-commit-sha). " |
| 1748 | + f"Diffing against its nearest scanned ancestor {ancestor_sha}, " |
| 1749 | + f"{commits_back} commit(s) earlier, so the diff is wider than " |
| 1750 | + "the merge base." |
| 1751 | + ) |
| 1752 | + else: |
| 1753 | + log.error( |
| 1754 | + f"No full scan found for commit {commit_sha} in repo {params.repo} " |
| 1755 | + "(--base-commit-sha), and no scanned ancestor within " |
| 1756 | + f"{ANCESTOR_WALK_MAX_DEPTH} commits of it. Ensure a scan was " |
| 1757 | + "created for that commit (e.g. the CLI runs on default-branch " |
| 1758 | + "pushes), or pass --base-scan-id instead." |
| 1759 | + ) |
| 1760 | + if self.cli_config.disable_blocking: |
| 1761 | + sys.exit(0) |
| 1762 | + sys.exit(self.cli_config.exit_code_on_api_error) |
1641 | 1763 | log.info( |
1642 | | - "Baseline selected: source=explicit-commit " |
1643 | | - f"scan_id={json.dumps(scan_id)} commit={json.dumps(commit_sha)}" |
| 1764 | + f"Baseline selected: source={baseline_source} " |
| 1765 | + f"scan_id={json.dumps(scan_id)} commit={json.dumps(baseline_commit)}" |
1644 | 1766 | ) |
1645 | 1767 | return scan_id |
1646 | 1768 |
|
|
0 commit comments