|
| 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 | +# Placeholder facts document (see empty_head_scan_file). A zero-byte file does not parse, |
| 75 | +# and the API answers an unparseable facts file with the marker artifact below. |
| 76 | +SOCKET_FACTS_EMPTY_DOCUMENT = '{"components": []}' |
| 77 | + |
| 78 | +# Synthetic artifact the API adds when an uploaded ``.socket.facts.json`` could not be |
| 79 | +# parsed. A diagnostic, not a dependency, so it is dropped from scan results and reported |
| 80 | +# as a warning instead. |
| 81 | +INVALID_FACTS_MARKER_TYPE = "generic" |
| 82 | +INVALID_FACTS_MARKER_NAME = "invalid-socket-facts" |
| 83 | + |
72 | 84 | # Full application reachability finalize retry policy. The finalize call links the reachability |
73 | 85 | # scan to the full scan and can fail transiently (network/API blips); a few backoff retries make it robust. |
74 | 86 | TIER1_FINALIZE_MAX_ATTEMPTS = 3 |
|
108 | 120 | DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 |
109 | 121 | DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 |
110 | 122 |
|
| 123 | +# Temp dirs holding placeholder facts files (see Core.empty_head_scan_file). Call sites unlink |
| 124 | +# the file itself once the upload finishes; the now-empty directory is removed at process exit |
| 125 | +# so a run that raises mid-scan doesn't leak one. |
| 126 | +_PLACEHOLDER_FACTS_DIRS: List[str] = [] |
| 127 | + |
| 128 | + |
| 129 | +@atexit.register |
| 130 | +def _cleanup_placeholder_facts_dirs() -> None: |
| 131 | + for placeholder_dir in _PLACEHOLDER_FACTS_DIRS: |
| 132 | + shutil.rmtree(placeholder_dir, ignore_errors=True) |
| 133 | + |
111 | 134 |
|
112 | 135 | def _humanize_alert_type(alert_type: str) -> str: |
113 | 136 | """Convert a camelCase/PascalCase alert type into a Title-Cased label. |
@@ -209,13 +232,54 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: |
209 | 232 | ) |
210 | 233 | if not hasattr(response, "artifacts") or not response.artifacts: |
211 | 234 | return {} |
212 | | - return response.artifacts |
| 235 | + artifacts = { |
| 236 | + artifact_id: artifact |
| 237 | + for artifact_id, artifact in response.artifacts.items() |
| 238 | + if not Core.is_invalid_facts_marker(artifact) |
| 239 | + } |
| 240 | + Core.warn_if_invalid_facts_marker(len(artifacts) != len(response.artifacts)) |
| 241 | + return artifacts |
213 | 242 |
|
214 | 243 | def get_sbom_data_list(self, artifacts_dict: Dict[str, SocketArtifact]) -> list[SocketArtifact]: |
215 | 244 | """Converts artifacts dictionary to a list.""" |
216 | 245 | return list(artifacts_dict.values()) |
217 | 246 |
|
| 247 | + @staticmethod |
| 248 | + def is_invalid_facts_marker(artifact) -> bool: |
| 249 | + """True for the API's ``generic/invalid-socket-facts`` unparseable-facts marker. |
| 250 | +
|
| 251 | + Treated as a package it becomes a blocking alert with an empty "Introduced by" and |
| 252 | + "Manifest File" that no developer can act on, so callers drop it and report the parse |
| 253 | + failure through ``warn_if_invalid_facts_marker`` instead. |
218 | 254 |
|
| 255 | + Matches any version; the API pins it to 1.0.0 but the version carries no meaning. |
| 256 | +
|
| 257 | + Args: |
| 258 | + artifact: A ``SocketArtifact`` or diff artifact (anything with ``type``/``name``). |
| 259 | +
|
| 260 | + Returns: |
| 261 | + True if the artifact is the marker rather than a real package. |
| 262 | + """ |
| 263 | + return ( |
| 264 | + getattr(artifact, "type", None) == INVALID_FACTS_MARKER_TYPE |
| 265 | + and getattr(artifact, "name", None) == INVALID_FACTS_MARKER_NAME |
| 266 | + ) |
| 267 | + |
| 268 | + @staticmethod |
| 269 | + def warn_if_invalid_facts_marker(found: bool) -> None: |
| 270 | + """Log the parse failure that ``is_invalid_facts_marker`` stands for. |
| 271 | +
|
| 272 | + Dropping the marker silently would hide a real, if non-blocking, problem: the scan ran |
| 273 | + without the reachability data it was supposed to carry. |
| 274 | + """ |
| 275 | + if not found: |
| 276 | + return |
| 277 | + log.warning( |
| 278 | + "Socket could not parse the uploaded .socket.facts.json, so reachability facts " |
| 279 | + "were not applied to this scan. Ignoring the " |
| 280 | + f"{INVALID_FACTS_MARKER_TYPE}/{INVALID_FACTS_MARKER_NAME} marker returned for it; " |
| 281 | + "other scan results are unaffected." |
| 282 | + ) |
219 | 283 |
|
220 | 284 | def create_sbom_output(self, diff: Diff) -> dict: |
221 | 285 | """Creates CycloneDX output for a given diff.""" |
@@ -809,20 +873,31 @@ def to_case_insensitive_regex(input_string: str) -> str: |
809 | 873 | @staticmethod |
810 | 874 | def empty_head_scan_file() -> List[str]: |
811 | 875 | """ |
812 | | - Creates a temporary empty file for baseline scans when no head scan exists. |
813 | | - |
| 876 | + Creates a temporary placeholder manifest for scans with no manifest files. |
| 877 | +
|
| 878 | + Used for baseline scans when a repository has no head scan yet, and for the new scan |
| 879 | + when no supported manifest files were found. The API rejects unsupported filenames, so |
| 880 | + the placeholder must be named ``.socket.facts.json`` - which means it must also parse |
| 881 | + as a facts document. A zero-byte file does not, and the API answers that by adding a |
| 882 | + blocking ``generic/invalid-socket-facts@1.0.0`` artifact to the scan. |
| 883 | +
|
| 884 | + Each call gets its own temp directory. The path used to be a fixed |
| 885 | + ``$TMPDIR/.socket.facts.json``, so two runs sharing a temp dir could delete or |
| 886 | + truncate each other's placeholder mid-upload. |
| 887 | +
|
814 | 888 | Returns: |
815 | | - List containing path to a temporary empty file |
| 889 | + List containing path to a temporary placeholder facts file |
816 | 890 | """ |
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}") |
| 891 | + # Own directory per call so concurrent runs can't clobber each other's placeholder; |
| 892 | + # the basename must stay exactly SOCKET_FACTS_FILENAME to pass the API's validator. |
| 893 | + temp_dir = tempfile.mkdtemp(prefix='socket_baseline_') |
| 894 | + _PLACEHOLDER_FACTS_DIRS.append(temp_dir) |
| 895 | + temp_path = os.path.join(temp_dir, SOCKET_FACTS_FILENAME) |
| 896 | + |
| 897 | + with open(temp_path, 'w') as f: |
| 898 | + f.write(SOCKET_FACTS_EMPTY_DOCUMENT) |
| 899 | + |
| 900 | + log.debug(f"Created temporary placeholder facts file for baseline scan: {temp_path}") |
826 | 901 | return [temp_path] |
827 | 902 |
|
828 | 903 | def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: |
@@ -959,7 +1034,7 @@ def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], |
959 | 1034 | exactly ``.socket.facts.json.br``, so compressing here keeps a large facts file under |
960 | 1035 | the server's per-file size cap without changing the stored result. Files whose |
961 | 1036 | 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). |
| 1037 | + matches that exact name), as are zero-byte files. |
963 | 1038 |
|
964 | 1039 | Compression never blocks an upload: if it fails for any reason (missing optional |
965 | 1040 | ``brotli`` dependency, unwritable directory, etc.) the original plain file is used. |
@@ -1780,16 +1855,25 @@ def get_added_and_removed_packages( |
1780 | 1855 |
|
1781 | 1856 | diff_end = time.time() |
1782 | 1857 | log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") |
| 1858 | + |
| 1859 | + # Left in, the invalid-socket-facts marker reads as a newly added blocking package. |
| 1860 | + # Drop it from every bucket before the counts below, which should describe what the |
| 1861 | + # CLI actually reports on. |
| 1862 | + marker_found = False |
| 1863 | + buckets: Dict[str, List] = {} |
| 1864 | + for name in ("added", "removed", "unchanged", "replaced", "updated"): |
| 1865 | + bucket = getattr(diff_artifacts, name) |
| 1866 | + buckets[name] = [a for a in bucket if not Core.is_invalid_facts_marker(a)] |
| 1867 | + marker_found = marker_found or len(buckets[name]) != len(bucket) |
| 1868 | + Core.warn_if_invalid_facts_marker(marker_found) |
| 1869 | + |
1783 | 1870 | 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 |
| 1871 | + for name, bucket in buckets.items(): |
| 1872 | + log.info(f"{name.capitalize()}: {len(bucket)}") |
| 1873 | + |
| 1874 | + added_artifacts = buckets["added"] + buckets["updated"] |
| 1875 | + removed_artifacts = buckets["removed"] + buckets["replaced"] |
| 1876 | + unchanged_artifacts = buckets["unchanged"] |
1793 | 1877 |
|
1794 | 1878 | added_packages: Dict[str, Package] = {} |
1795 | 1879 | removed_packages: Dict[str, Package] = {} |
|
0 commit comments