|
| 1 | +import atexit |
1 | 2 | import copy |
2 | 3 | import fnmatch |
3 | 4 | import importlib |
|
6 | 7 | import os |
7 | 8 | import random |
8 | 9 | import re |
| 10 | +import shutil |
9 | 11 | import sys |
10 | 12 | import tarfile |
11 | 13 | import tempfile |
|
69 | 71 | # Stream the facts file in 1 MiB chunks so large files aren't held fully in memory. |
70 | 72 | SOCKET_FACTS_BROTLI_CHUNK_SIZE = 1024 * 1024 |
71 | 73 |
|
| 74 | +# Minimal well-formed facts document used for placeholder uploads (see empty_head_scan_file). |
| 75 | +# A zero-byte ``.socket.facts.json`` is not parseable, and the API answers an unparseable |
| 76 | +# facts file by synthesising the marker artifact below. |
| 77 | +SOCKET_FACTS_EMPTY_DOCUMENT = '{"components": []}' |
| 78 | + |
| 79 | +# Synthetic artifact the Socket API adds to a full scan when an uploaded |
| 80 | +# ``.socket.facts.json`` could not be parsed. It is a diagnostic marker rather than a real |
| 81 | +# dependency: it has no manifest file and no introducing package, so the blocking alert it |
| 82 | +# carries is not actionable by a developer, and a PR comment about it is pure noise |
| 83 | +# (CE-422). Drop it from scan results and surface the parse failure as a warning instead. |
| 84 | +INVALID_FACTS_MARKER_TYPE = "generic" |
| 85 | +INVALID_FACTS_MARKER_NAME = "invalid-socket-facts" |
| 86 | + |
72 | 87 | # Full application reachability finalize retry policy. The finalize call links the reachability |
73 | 88 | # scan to the full scan and can fail transiently (network/API blips); a few backoff retries make it robust. |
74 | 89 | TIER1_FINALIZE_MAX_ATTEMPTS = 3 |
|
108 | 123 | DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 |
109 | 124 | DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 |
110 | 125 |
|
| 126 | +# Temp dirs holding placeholder facts files (see Core.empty_head_scan_file). Call sites unlink |
| 127 | +# the file itself once the upload finishes; the now-empty directory is removed at process exit |
| 128 | +# so a run that raises mid-scan doesn't leak one. |
| 129 | +_PLACEHOLDER_FACTS_DIRS: List[str] = [] |
| 130 | + |
| 131 | + |
| 132 | +@atexit.register |
| 133 | +def _cleanup_placeholder_facts_dirs() -> None: |
| 134 | + for placeholder_dir in _PLACEHOLDER_FACTS_DIRS: |
| 135 | + shutil.rmtree(placeholder_dir, ignore_errors=True) |
| 136 | + |
111 | 137 |
|
112 | 138 | def _humanize_alert_type(alert_type: str) -> str: |
113 | 139 | """Convert a camelCase/PascalCase alert type into a Title-Cased label. |
@@ -209,13 +235,58 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: |
209 | 235 | ) |
210 | 236 | if not hasattr(response, "artifacts") or not response.artifacts: |
211 | 237 | return {} |
212 | | - return response.artifacts |
| 238 | + artifacts = { |
| 239 | + artifact_id: artifact |
| 240 | + for artifact_id, artifact in response.artifacts.items() |
| 241 | + if not Core.is_invalid_facts_marker(artifact) |
| 242 | + } |
| 243 | + Core.warn_if_invalid_facts_marker(len(artifacts) != len(response.artifacts)) |
| 244 | + return artifacts |
213 | 245 |
|
214 | 246 | def get_sbom_data_list(self, artifacts_dict: Dict[str, SocketArtifact]) -> list[SocketArtifact]: |
215 | 247 | """Converts artifacts dictionary to a list.""" |
216 | 248 | return list(artifacts_dict.values()) |
217 | 249 |
|
| 250 | + @staticmethod |
| 251 | + def is_invalid_facts_marker(artifact) -> bool: |
| 252 | + """True for the API's ``generic/invalid-socket-facts`` unparseable-facts marker. |
| 253 | +
|
| 254 | + The marker is a signal that the uploaded ``.socket.facts.json`` failed to parse, not a |
| 255 | + dependency anyone added. Treating it as a package makes the CLI report a new blocking |
| 256 | + alert with an empty "Introduced by" and "Manifest File" and post a PR comment a |
| 257 | + developer has no way to act on (CE-422), so it is filtered out of scan results and |
| 258 | + reported through ``warn_if_invalid_facts_marker`` instead. |
218 | 259 |
|
| 260 | + Matches on any version: the API currently pins it to 1.0.0, but the version carries no |
| 261 | + meaning here. |
| 262 | +
|
| 263 | + Args: |
| 264 | + artifact: A ``SocketArtifact`` or diff artifact (anything with ``type``/``name``). |
| 265 | +
|
| 266 | + Returns: |
| 267 | + True if the artifact is the marker rather than a real package. |
| 268 | + """ |
| 269 | + return ( |
| 270 | + getattr(artifact, "type", None) == INVALID_FACTS_MARKER_TYPE |
| 271 | + and getattr(artifact, "name", None) == INVALID_FACTS_MARKER_NAME |
| 272 | + ) |
| 273 | + |
| 274 | + @staticmethod |
| 275 | + def warn_if_invalid_facts_marker(found: bool) -> None: |
| 276 | + """Log the reachability-facts parse failure that ``is_invalid_facts_marker`` stands for. |
| 277 | +
|
| 278 | + Dropping the marker silently would hide a real (if non-blocking) problem: the scan ran |
| 279 | + without the reachability data it was supposed to carry. |
| 280 | + """ |
| 281 | + if not found: |
| 282 | + return |
| 283 | + log.warning( |
| 284 | + "Socket could not parse the uploaded .socket.facts.json, so reachability facts " |
| 285 | + "were not applied to this scan. Ignoring the " |
| 286 | + f"{INVALID_FACTS_MARKER_TYPE}/{INVALID_FACTS_MARKER_NAME} marker the API returns " |
| 287 | + "for this: it is a diagnostic, not a dependency, so it does not block the build " |
| 288 | + "or appear in reports. Scan results are otherwise unaffected." |
| 289 | + ) |
219 | 290 |
|
220 | 291 | def create_sbom_output(self, diff: Diff) -> dict: |
221 | 292 | """Creates CycloneDX output for a given diff.""" |
@@ -809,20 +880,35 @@ def to_case_insensitive_regex(input_string: str) -> str: |
809 | 880 | @staticmethod |
810 | 881 | def empty_head_scan_file() -> List[str]: |
811 | 882 | """ |
812 | | - Creates a temporary empty file for baseline scans when no head scan exists. |
813 | | - |
| 883 | + Creates a temporary placeholder manifest for scans with no manifest files. |
| 884 | +
|
| 885 | + Used both for baseline scans when a repository has no head scan yet and for the new |
| 886 | + scan when no supported manifest files were found. The API rejects unsupported |
| 887 | + filenames, so the placeholder is named ``.socket.facts.json``; it must therefore also |
| 888 | + *parse* as a facts document. A zero-byte file does not, and the API answers an |
| 889 | + unparseable facts file by adding a blocking ``generic/invalid-socket-facts@1.0.0`` |
| 890 | + artifact to the scan - which then surfaces as a new blocking package with no manifest |
| 891 | + and no introducer (CE-422). Writing an empty-but-well-formed document instead yields a |
| 892 | + genuinely empty scan. |
| 893 | +
|
| 894 | + Each call gets its own temp directory: the path used to be a fixed |
| 895 | + ``$TMPDIR/.socket.facts.json``, so two CLI runs sharing a temp dir (back-to-back |
| 896 | + invocations in the same CI job, matrix jobs on one runner) could delete or truncate |
| 897 | + each other's placeholder mid-upload. |
| 898 | +
|
814 | 899 | Returns: |
815 | | - List containing path to a temporary empty file |
| 900 | + List containing path to a temporary placeholder facts file |
816 | 901 | """ |
817 | | - # Create a temporary directory and then create our specific filename |
818 | | - temp_dir = tempfile.gettempdir() |
819 | | - temp_path = os.path.join(temp_dir, '.socket.facts.json') |
820 | | - |
821 | | - # Create the empty file |
822 | | - with open(temp_path, 'w'): |
823 | | - pass # Creates an empty file |
824 | | - |
825 | | - log.debug(f"Created temporary empty file for baseline scan: {temp_path}") |
| 902 | + # Own directory per call so concurrent runs can't clobber each other's placeholder; |
| 903 | + # the basename must stay exactly SOCKET_FACTS_FILENAME to pass the API's validator. |
| 904 | + temp_dir = tempfile.mkdtemp(prefix='socket_baseline_') |
| 905 | + _PLACEHOLDER_FACTS_DIRS.append(temp_dir) |
| 906 | + temp_path = os.path.join(temp_dir, SOCKET_FACTS_FILENAME) |
| 907 | + |
| 908 | + with open(temp_path, 'w') as f: |
| 909 | + f.write(SOCKET_FACTS_EMPTY_DOCUMENT) |
| 910 | + |
| 911 | + log.debug(f"Created temporary placeholder facts file for baseline scan: {temp_path}") |
826 | 912 | return [temp_path] |
827 | 913 |
|
828 | 914 | def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: |
@@ -959,7 +1045,7 @@ def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], |
959 | 1045 | exactly ``.socket.facts.json.br``, so compressing here keeps a large facts file under |
960 | 1046 | the server's per-file size cap without changing the stored result. Files whose |
961 | 1047 | basename is not exactly ``.socket.facts.json`` are left untouched (the server only |
962 | | - matches that exact name), as are empty placeholder files (e.g. baseline scans). |
| 1048 | + matches that exact name), as are zero-byte files. |
963 | 1049 |
|
964 | 1050 | Compression never blocks an upload: if it fails for any reason (missing optional |
965 | 1051 | ``brotli`` dependency, unwritable directory, etc.) the original plain file is used. |
@@ -1780,16 +1866,26 @@ def get_added_and_removed_packages( |
1780 | 1866 |
|
1781 | 1867 | diff_end = time.time() |
1782 | 1868 | log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") |
| 1869 | + |
| 1870 | + # A scan whose facts file failed to parse carries the API's invalid-socket-facts |
| 1871 | + # marker. Left in, it reads as a newly added blocking package (CE-422), so drop it |
| 1872 | + # from every bucket - before the counts below, which should describe what the CLI |
| 1873 | + # actually reports on. |
| 1874 | + marker_found = False |
| 1875 | + buckets: Dict[str, List] = {} |
| 1876 | + for name in ("added", "removed", "unchanged", "replaced", "updated"): |
| 1877 | + bucket = getattr(diff_artifacts, name) |
| 1878 | + buckets[name] = [a for a in bucket if not Core.is_invalid_facts_marker(a)] |
| 1879 | + marker_found = marker_found or len(buckets[name]) != len(bucket) |
| 1880 | + Core.warn_if_invalid_facts_marker(marker_found) |
| 1881 | + |
1783 | 1882 | log.info("Diff report artifact counts:") |
1784 | | - log.info(f"Added: {len(diff_artifacts.added)}") |
1785 | | - log.info(f"Removed: {len(diff_artifacts.removed)}") |
1786 | | - log.info(f"Unchanged: {len(diff_artifacts.unchanged)}") |
1787 | | - log.info(f"Replaced: {len(diff_artifacts.replaced)}") |
1788 | | - log.info(f"Updated: {len(diff_artifacts.updated)}") |
1789 | | - |
1790 | | - added_artifacts = diff_artifacts.added + diff_artifacts.updated |
1791 | | - removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced |
1792 | | - unchanged_artifacts = diff_artifacts.unchanged |
| 1883 | + for name, bucket in buckets.items(): |
| 1884 | + log.info(f"{name.capitalize()}: {len(bucket)}") |
| 1885 | + |
| 1886 | + added_artifacts = buckets["added"] + buckets["updated"] |
| 1887 | + removed_artifacts = buckets["removed"] + buckets["replaced"] |
| 1888 | + unchanged_artifacts = buckets["unchanged"] |
1793 | 1889 |
|
1794 | 1890 | added_packages: Dict[str, Package] = {} |
1795 | 1891 | removed_packages: Dict[str, Package] = {} |
|
0 commit comments