Skip to content

Commit 1d193c7

Browse files
authored
Don't render .socket.facts.json placeholder as blocking package (#321)
Scans with no supported manifest files uploaded a zero-byte `.socket.facts.json` placeholder. The API cannot parse that and responds by adding a synthetic `generic/invalid-socket-facts@1.0.0` artifact, which the CLI then reported as a new blocking package with no manifest file and no introducing dependency, failing the run and posting a pull request comment that could not be acted on. - Write an empty but well-formed facts document as the placeholder. - Give each placeholder its own temp directory, so concurrent runs cannot remove each other's file mid-upload. - Filter the `generic/invalid-socket-facts` marker out of full scan and diff artifacts, logging a warning instead. It is a diagnostic, not a dependency.
1 parent d6cd454 commit 1d193c7

7 files changed

Lines changed: 407 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## 2.6.12
4+
5+
### Fixed: unreadable reachability facts no longer report a blocking package
6+
7+
- Scans with no supported manifest files uploaded a zero-byte `.socket.facts.json`
8+
placeholder. The API cannot parse that, and answers by adding a
9+
`generic/invalid-socket-facts@1.0.0` artifact to the scan, which the CLI then reported
10+
as a new blocking package with no manifest file and no introducing dependency —
11+
failing the run and, on pull requests, leaving a security comment that could not be
12+
acted on. The placeholder is now an empty but well-formed facts document.
13+
- When the API does report `generic/invalid-socket-facts` (a diagnostic for a facts file
14+
it could not parse, not a real dependency), the CLI now excludes it from scan results
15+
and logs a warning instead. It no longer blocks a run, appears in reports, or triggers
16+
a pull request comment.
17+
- Each placeholder is written to its own temporary directory. Two CLI runs sharing a
18+
temporary directory previously used the same path and could remove each other's
19+
placeholder mid-upload.
20+
321
## 2.6.11
422

523
### Changed: bump pinned @coana-tech/cli to 15.10.32

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.6.11"
9+
version = "2.6.12"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.6.11'
2+
__version__ = '2.6.12'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/core/__init__.py

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import atexit
12
import copy
23
import fnmatch
34
import importlib
@@ -6,6 +7,7 @@
67
import os
78
import random
89
import re
10+
import shutil
911
import sys
1012
import tarfile
1113
import tempfile
@@ -69,6 +71,16 @@
6971
# Stream the facts file in 1 MiB chunks so large files aren't held fully in memory.
7072
SOCKET_FACTS_BROTLI_CHUNK_SIZE = 1024 * 1024
7173

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+
7284
# Full application reachability finalize retry policy. The finalize call links the reachability
7385
# scan to the full scan and can fail transiently (network/API blips); a few backoff retries make it robust.
7486
TIER1_FINALIZE_MAX_ATTEMPTS = 3
@@ -108,6 +120,17 @@
108120
DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5
109121
DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0
110122

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+
111134

112135
def _humanize_alert_type(alert_type: str) -> str:
113136
"""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]:
209232
)
210233
if not hasattr(response, "artifacts") or not response.artifacts:
211234
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
213242

214243
def get_sbom_data_list(self, artifacts_dict: Dict[str, SocketArtifact]) -> list[SocketArtifact]:
215244
"""Converts artifacts dictionary to a list."""
216245
return list(artifacts_dict.values())
217246

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.
218254
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+
)
219283

220284
def create_sbom_output(self, diff: Diff) -> dict:
221285
"""Creates CycloneDX output for a given diff."""
@@ -809,20 +873,31 @@ def to_case_insensitive_regex(input_string: str) -> str:
809873
@staticmethod
810874
def empty_head_scan_file() -> List[str]:
811875
"""
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+
814888
Returns:
815-
List containing path to a temporary empty file
889+
List containing path to a temporary placeholder facts file
816890
"""
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}")
826901
return [temp_path]
827902

828903
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],
9591034
exactly ``.socket.facts.json.br``, so compressing here keeps a large facts file under
9601035
the server's per-file size cap without changing the stored result. Files whose
9611036
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.
9631038
9641039
Compression never blocks an upload: if it fails for any reason (missing optional
9651040
``brotli`` dependency, unwritable directory, etc.) the original plain file is used.
@@ -1780,16 +1855,25 @@ def get_added_and_removed_packages(
17801855

17811856
diff_end = time.time()
17821857
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+
17831870
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"]
17931877

17941878
added_packages: Dict[str, Package] = {}
17951879
removed_packages: Dict[str, Package] = {}

tests/core/test_facts_compression.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def test_compress_for_upload_preserves_directory_prefix(tmp_path):
9999

100100

101101
def test_empty_facts_file_is_not_compressed(tmp_path):
102-
"""Empty placeholder facts files (e.g. baseline scans) are uploaded as-is."""
102+
"""A zero-byte facts file has nothing to compress and is uploaded as-is."""
103103
core = Core.__new__(Core)
104104
empty_facts = _write(str(tmp_path / SOCKET_FACTS_FILENAME), b"")
105105

0 commit comments

Comments
 (0)