Skip to content

Commit 87a631b

Browse files
committed
fix(review): address authorization and full-scan findings
1 parent a320008 commit 87a631b

10 files changed

Lines changed: 233 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,21 @@
4343
- `--generate-license` and `--legal-format fossa` fetch the package list on this
4444
path, so attribution files generated from a branch pipeline are complete rather
4545
than empty.
46+
- Console-only full scans link to the Socket report and state that findings were
47+
not fetched for console output instead of presenting an empty local alert list
48+
as "No issues found."
49+
- License enrichment keeps the package namespace in PURL requests and response
50+
matching, so scoped npm packages and namespaced Maven packages receive their
51+
license details.
4652

4753
### Changed: `@SocketSecurity ignore` requires write access
4854

4955
- An ignore command suppresses a security alert, but the CLI honored one from any
5056
commenter, including a drive-by comment from someone with no access to the
5157
repository. Commands are now accepted only from an author with write access.
52-
- On GitHub this is read from the `author_association` GitHub already returns with
53-
each comment, so it costs no extra request: `OWNER`, `MEMBER` and `COLLABORATOR`
54-
are honored, and `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, `MANNEQUIN` and `NONE`
55-
are not.
58+
- On GitHub this is read from the effective repository permission and cached per
59+
commenter for the run. Write, maintain, or admin access is required; relationship
60+
labels such as `MEMBER` and `COLLABORATOR` are not treated as permissions.
5661
- GitLab notes carry no equivalent field, so project membership is read once per
5762
run (only when an ignore command is present) and Developer or above is required.
5863
If that lookup cannot be answered — a `CI_JOB_TOKEN` generally cannot read the

docs/cli-reference.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -701,11 +701,12 @@ reported. `--disable-ignore` turns the feature off entirely.
701701
702702
| Provider | How access is determined | If it cannot be determined |
703703
|:---------|:-------------------------|:---------------------------|
704-
| GitHub | The `author_association` returned with each comment. `OWNER`, `MEMBER` and `COLLABORATOR` are honored. | Treated as unauthorized. |
704+
| GitHub | Effective repository permission, read once per commenter per run. Write, maintain, or admin access is honored. | The command is honored and a warning is logged. |
705705
| GitLab | Project membership, read once per run when an ignore command is present. Developer (30) or above is honored. | The command is honored and a warning is logged. |
706706
707-
GitLab notes carry no permission field, so the check needs a `GITLAB_TOKEN` that
708-
can read `GET /projects/:id/members/all`. A `CI_JOB_TOKEN` generally cannot.
707+
The GitHub check needs a token that can read repository metadata. GitLab notes
708+
carry no permission field, so that check needs a `GITLAB_TOKEN` that can read
709+
`GET /projects/:id/members/all`. A `CI_JOB_TOKEN` generally cannot.
709710
710711
`--ignore-authorization` decides what happens when access cannot be determined:
711712

socketsecurity/core/__init__.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,6 +1279,7 @@ def create_full_scan_with_report_url(
12791279
diff.report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}"
12801280
diff.diff_url = diff.report_url
12811281
diff.id = new_full_scan.id
1282+
diff.is_full_scan = True
12821283

12831284
needs_alerts = (
12841285
self.cli_config is not None
@@ -1324,6 +1325,7 @@ def create_full_scan_with_report_url(
13241325
if (alert.error or alert.warn) and alert_str not in consolidated:
13251326
diff.new_alerts.append(alert)
13261327
consolidated.add(alert_str)
1328+
diff.alerts_fetched = True
13271329

13281330
sbom_end = time.time()
13291331
log.info(
@@ -1344,11 +1346,16 @@ def _add_license_details(self, packages: dict[str, Package]) -> dict[str, Packag
13441346
enrichment lands on the map the caller keeps.
13451347
"""
13461348
batch_size = self.cli_config.max_purl_batch_size if self.cli_config else 5000
1349+
packages_by_purl = {}
1350+
for package in packages.values():
1351+
qualified_name = package.name
1352+
if package.namespace:
1353+
qualified_name = f"{package.namespace.strip('/')}/{qualified_name}"
1354+
packages_by_purl[
1355+
f"{package.type}/{qualified_name}@{package.version}"
1356+
] = package
13471357
self.get_license_text_via_purl(
1348-
{
1349-
f"{package.type}/{package.name}@{package.version}": package
1350-
for package in packages.values()
1351-
},
1358+
packages_by_purl,
13521359
batch_size=batch_size,
13531360
)
13541361
return packages
@@ -1657,6 +1664,9 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in
16571664
for result in results:
16581665
ecosystem = result["type"]
16591666
name = result["name"]
1667+
namespace = (result.get("namespace") or "").strip("/")
1668+
if namespace and not name.startswith(f"{namespace}/"):
1669+
name = f"{namespace}/{name}"
16601670
package_version = result["version"]
16611671
licenseDetails = result.get("licenseDetails")
16621672
licenseAttrib = result.get("licenseAttrib")

socketsecurity/core/classes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ class Diff:
520520
report_url: str
521521
diff_url: str
522522
new_scan_id: str
523+
is_full_scan: bool
524+
alerts_fetched: bool
523525

524526
def __init__(self, **kwargs):
525527
if kwargs:
@@ -541,6 +543,10 @@ def __init__(self, **kwargs):
541543
self.removed_alerts = []
542544
if not hasattr(self, "new_capabilities"):
543545
self.new_capabilities = {}
546+
if not hasattr(self, "is_full_scan"):
547+
self.is_full_scan = False
548+
if not hasattr(self, "alerts_fetched"):
549+
self.alerts_fetched = False
544550

545551
def __str__(self):
546552
return json.dumps(self.__dict__)

socketsecurity/core/messages.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -997,8 +997,6 @@ def security_comment_template(diff: Diff, config=None) -> str:
997997
# Generate proper manifest URL
998998
manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config)
999999
pkg_label = Messages.html_text(f"{alert.pkg_name}@{alert.pkg_version}")
1000-
# The marker is read back verbatim when the comment is rewritten, so it
1001-
# keeps the raw name and only loses the ability to close the comment.
10021000
pkg_marker = Messages.comment_marker_text(f"{alert.pkg_name}@{alert.pkg_version}")
10031001
# Generate a table row for each alert
10041002
ignore_html = (
@@ -1045,8 +1043,6 @@ def security_comment_template(diff: Diff, config=None) -> str:
10451043
license_label = Messages.html_text(
10461044
f"{first_alert.pkg_name}@{first_alert.pkg_version}"
10471045
)
1048-
# The marker is read back verbatim when the comment is rewritten, so it
1049-
# keeps the raw name and only loses the ability to close the comment.
10501046
license_marker = Messages.comment_marker_text(
10511047
f"{first_alert.pkg_name}@{first_alert.pkg_version}"
10521048
)
@@ -1293,8 +1289,7 @@ def create_remove_line(diff: Diff, md: MdUtils) -> MdUtils:
12931289

12941290
# Change types the shared badge host publishes an image for. Removed and
12951291
# replaced have no artwork, so they fall back to a bold text label rather than
1296-
# rendering a broken image; added and updated keep the badge the overview
1297-
# comment has always used.
1292+
# rendering a broken image; added and updated render the available badges.
12981293
DIFF_BADGES = {
12991294
"Added": "diff-added.svg",
13001295
"Updated": "diff-updated.svg",

socketsecurity/core/scm/github.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig':
154154

155155

156156
class Github:
157+
WRITE_PERMISSIONS = frozenset({"write", "maintain", "admin"})
158+
157159
def __init__(
158160
self,
159161
client: CliClient,
@@ -163,6 +165,10 @@ def __init__(
163165
self.config = config or GithubConfig.from_env()
164166
self.client = client
165167
self.ignore_authorization = ignore_authorization
168+
# Permission is stable for the duration of one CLI run. Cache both
169+
# positive and negative answers so several ignore comments by the same
170+
# author do not each make an API request.
171+
self._ignore_permission_cache: dict[str, Optional[bool]] = {}
166172

167173
if not self.config.token:
168174
log.error("Unable to get Github API Token")
@@ -236,12 +242,59 @@ def get_comments_for_pr(self) -> dict:
236242
def is_ignore_authorized(self, comment: Comment) -> bool:
237243
"""Whether a commenter may suppress alerts with @SocketSecurity ignore.
238244
239-
GitHub returns the author's relationship to the repository on every issue
240-
comment, so this costs no extra request and is definitive. A missing value
241-
is treated as unauthorized rather than trusted.
245+
``author_association`` describes a social relationship to the repository,
246+
not the author's role: an organization member or outside collaborator can
247+
still have read-only access. Ask GitHub for the effective repository
248+
permission instead, and cache the answer for subsequent comments.
242249
"""
243-
association = (getattr(comment, "author_association", "") or "").upper()
244-
return association in Comments.WRITE_ACCESS_ASSOCIATIONS
250+
author = Comments.comment_author_name(comment)
251+
if author == "an unknown user":
252+
permission = None
253+
elif author in self._ignore_permission_cache:
254+
permission = self._ignore_permission_cache[author]
255+
else:
256+
path = (
257+
f"repos/{self.config.owner}/{self.config.repository}/"
258+
f"collaborators/{author}/permission"
259+
)
260+
try:
261+
response = self.client.request(
262+
path=path,
263+
headers=self.config.headers,
264+
base_url=self.config.api_url,
265+
)
266+
result = response.json()
267+
if not isinstance(result, dict) or not isinstance(
268+
result.get("permission"), str
269+
):
270+
log.warning("Unexpected GitHub repository permission response")
271+
permission = None
272+
else:
273+
permission = (
274+
result["permission"].casefold() in self.WRITE_PERMISSIONS
275+
)
276+
except Exception as error:
277+
log.warning(
278+
f"Could not read GitHub repository permission for {author}: {error}"
279+
)
280+
permission = None
281+
self._ignore_permission_cache[author] = permission
282+
283+
if permission is not None:
284+
return permission
285+
if self.ignore_authorization == "strict":
286+
log.warning(
287+
f"Rejecting @SocketSecurity ignore from {author}: GitHub repository "
288+
"permission could not be read and --ignore-authorization is strict."
289+
)
290+
return False
291+
log.warning(
292+
f"Honoring @SocketSecurity ignore from {author} without verifying write "
293+
"access: GitHub repository permission could not be read. Use a token "
294+
"with repository metadata access, or --ignore-authorization strict to "
295+
"reject instead."
296+
)
297+
return True
245298

246299
def add_socket_comments(
247300
self,

socketsecurity/core/scm_comments.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@
1212
class Comments:
1313
VIEW_REPORT_PATTERN = re.compile(r"\[View full report\]\(([^)\s]+)\)")
1414

15-
# GitHub stamps every issue comment with the author's relationship to the
16-
# repository. Only these three imply write access; CONTRIBUTOR, MANNEQUIN,
17-
# MENTIONEE, FIRST_TIMER, FIRST_TIME_CONTRIBUTOR and NONE do not.
18-
WRITE_ACCESS_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
19-
2015
@staticmethod
2116
def comment_author_name(comment: Comment) -> str:
2217
"""Best-effort display name for a comment author, across providers."""
@@ -151,10 +146,8 @@ def parse_alert_table_row(line: str) -> Optional[tuple[str, str, str]]:
151146
152147
Returns None for any row that does not have the expected shape rather than
153148
raising. The row comes back from the provider's API, so its contents are
154-
outside this process's control: a cell carrying an extra ``|``, a package
155-
cell that is not a markdown link, or a name with no version all used to
156-
raise out of the comment rewrite and take the run down before it reported
157-
status. A row that cannot be read is a row whose alert stays reported.
149+
outside this process's control. Malformed cells must not interrupt status
150+
reporting. A row that cannot be read is a row whose alert stays reported.
158151
"""
159152
cells = line.strip().lstrip("|").rstrip("|").split("|")
160153
if len(cells) != 5:

socketsecurity/output.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,18 @@ def save_sbom_file(self, diff_report: Diff, sbom_file_name: Optional[str] = None
229229

230230
def build_summary_text(self, diff_report: Diff) -> str:
231231
"""Render the console summary text for stdout and file output."""
232+
if (
233+
getattr(diff_report, "is_full_scan", False)
234+
and not getattr(diff_report, "alerts_fetched", False)
235+
):
236+
lines = ["Full scan completed. Findings were not fetched for console output."]
237+
report_link = getattr(diff_report, "report_url", "") or getattr(
238+
diff_report, "diff_url", ""
239+
)
240+
if report_link:
241+
lines.append(f"Report Url: {report_link}")
242+
return "\n".join(lines)
243+
232244
selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking)
233245
has_new_alerts = len(selected_alerts) > 0
234246
has_unchanged_alerts = (

tests/core/test_full_scan_outputs.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
from socketsecurity.config import CliConfig
1111
from socketsecurity.core import Core
12+
from socketsecurity.core.classes import Package
1213
from socketsecurity.core.socket_config import SocketConfig
14+
from socketsecurity.output import OutputHandler
1315

1416

1517
def _core(sdk, **cli_overrides):
@@ -61,6 +63,41 @@ def test_license_details_are_requested_for_the_scanned_packages(sdk, params):
6163
assert any("@" in component["purl"] for component in components)
6264

6365

66+
def test_license_details_preserve_package_namespace(sdk):
67+
core = _core(sdk, generate_license=True)
68+
package = Package(
69+
id="artifact-id",
70+
type="maven",
71+
namespace="org.apache.logging.log4j",
72+
name="log4j-core",
73+
version="2.24.3",
74+
score={},
75+
alerts=[],
76+
)
77+
sdk.purl.post.return_value = [
78+
{
79+
"type": "maven",
80+
"namespace": "org.apache.logging.log4j",
81+
"name": "log4j-core",
82+
"version": "2.24.3",
83+
"licenseAttrib": [{"name": "Apache-2.0"}],
84+
"licenseDetails": [{"license": "Apache-2.0"}],
85+
}
86+
]
87+
88+
packages = core._add_license_details({package.id: package})
89+
90+
assert sdk.purl.post.call_args.kwargs["components"] == [
91+
{
92+
"purl": (
93+
"pkg:/maven/org.apache.logging.log4j/"
94+
"log4j-core@2.24.3"
95+
)
96+
}
97+
]
98+
assert packages[package.id].licenseDetails == [{"license": "Apache-2.0"}]
99+
100+
64101
def test_alert_formats_still_fetch_the_sbom(sdk, params):
65102
core = _core(sdk, enable_json=True)
66103

@@ -84,3 +121,8 @@ def test_console_only_run_skips_the_sbom_fetch(sdk, params):
84121
assert diff.packages == {}
85122
assert diff.new_alerts == []
86123
sdk.fullscans.stream.assert_not_called()
124+
125+
summary = OutputHandler(core.cli_config, sdk).build_summary_text(diff)
126+
assert "No issues found" not in summary
127+
assert "Findings were not fetched for console output" in summary
128+
assert diff.report_url in summary

0 commit comments

Comments
 (0)