chore(release): 3.4.0 - #120
Conversation
f9652d6 to
58f5e3c
Compare
|
bugbot run |
58f5e3c to
a23d9b0
Compare
|
bugbot run |
a23d9b0 to
edf77c9
Compare
Stamps [Unreleased] as [3.4.0], bumps the version files and uv.lock, and synchronizes the current-release references across README.md and docs/**. Minor rather than patch: #119 changes the snippet, detailed report, dataflow trace and description that every consumer of a finding reads, and adds the `redact` rule-metadata key. Also records the socketdev 3.5.0 -> 3.6.0 lockfile bump from #117, which merged without a changelog entry.
edf77c9 to
126754f
Compare
|
bugbot run |
The rule that fragment names matches password *handling* -- assigning request
input to a password field, or comparing against one -- so its match is an
expression rather than a literal. Running the literal pass on it reduced
`user.password = request.form.get('password')` to a row of asterisks, which is
the rule's main pattern and leaves nothing to act on. It belongs with
`hardcoded-ip` and the password-policy rules, which the same comment already
excludes for the same reason. A comparison against a hardcoded value is the one
shape it covers that carries a credential, and that is what the `hardcoded-*`
rules are for.
Also folds a duplicated TestRedactMessage class into one. The second definition
shadowed the first, so two message tests never ran.
|
bugbot run |
A literal can open on one line and close on another. Spans were computed per line, so a marker on a literal's second line read as a comment and the rest of that line was starred -- dropping the closing quote, after which the literal pass no longer matched and the opening line's value survived. Spans are now measured once over the whole snippet and consulted by absolute position. Two things fall out of that. A snippet is a slice of a file, so a literal can also never close. The quoted value branch deferred to the literal pass, which never matches an unterminated literal, so `password = "hunter2` was left untouched. It now defers only when the quote opens a span the pass can find, and masks the value whole otherwise. A multi-line literal body was measured as one value, so the head-and-tail reveal exposed the start of its first line. Each line of such a body is now masked whole, with the line breaks kept so the snippet still shows where the literal begins and ends.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Prefixed multiline strings leak credentials
- Prefixed openers such as r""" are now recognized as reconstructable literals, so the first-line quotes are kept and the later-line body is masked instead of leaking.
Or push these changes by commenting:
@cursor push a9e5624282
Preview (a9e5624282)
diff --git a/socket_basics/core/utils/redaction.py b/socket_basics/core/utils/redaction.py
--- a/socket_basics/core/utils/redaction.py
+++ b/socket_basics/core/utils/redaction.py
@@ -146,6 +146,11 @@
# such as ``# see get_secret()`` disable masking for the value in front of it.
_CALL_EXPRESSION = re.compile(r'^[\w.\[\]]+\s*\(')
+# Optional prefixes on a quoted literal. ``r"""`` / ``f"..."`` / ``b'...'``
+# still open a value the literal pass can find; the quote, not the prefix,
+# is what that pass matches.
+_STRING_PREFIX = re.compile(r'^[bBfFrRuU]*')
+
# Rule-name fragments whose finding *is* the credential. ``hardcoded-ip`` and
# the password-policy rules deliberately do not appear: their snippets are
# logic, and masking them would remove the reason the finding was raised.
@@ -252,8 +257,16 @@
# nothing to act on. A quoted value is the literal pass's job -- but only
# where the quote opens a literal that pass can find. A snippet cut mid
# string has an opening quote and no closing one, so nothing matches and
- # the value would survive untouched.
- opens_literal = value.startswith(('"', "'", '`')) and in_literal(offset + len(head))
+ # the value would survive untouched. A prefix such as ``r`` or ``f`` is
+ # not the quote, but the quote after it still is: starring the opener
+ # would drop those quotes, later lines would stay marked in-literal, and
+ # the body would survive the literal pass.
+ prefix_len = _STRING_PREFIX.match(value).end()
+ opens_literal = (
+ prefix_len < len(value)
+ and value[prefix_len] in '"\'`'
+ and in_literal(offset + len(head) + prefix_len)
+ )
if opens_literal or _CALL_EXPRESSION.match(value):
return code
@@ -292,10 +305,10 @@
finding actionable, while the value does not.
Where the shape of the value is not recognized the whole value is masked
- rather than guessed at, so a subscript, a ternary or a prefixed literal
- (``f"..."``, ``r'...'``) loses more of the line than a plain assignment
- does. That direction is deliberate: the rule ID, file and line still
- identify the finding, and the alternative is leaving a credential in place.
+ rather than guessed at, so a subscript or a ternary loses more of the
+ line than a plain assignment does. That direction is deliberate: the
+ rule ID, file and line still identify the finding, and the alternative
+ is leaving a credential in place.
"""
if not isinstance(text, str) or not text:
return text if isinstance(text, str) else ''
diff --git a/tests/test_secret_redaction.py b/tests/test_secret_redaction.py
--- a/tests/test_secret_redaction.py
+++ b/tests/test_secret_redaction.py
@@ -309,7 +309,20 @@
assert redacted.count("\n") == 2
assert "hunter2" not in redacted
+ def test_a_prefixed_multiline_literal_is_masked(self):
+ # The prefix is not a quote, so whole-masking the first line would
+ # replace the opener with stars. Later lines stay marked in-literal,
+ # assignment and comment masking skip them, and the body survives.
+ for prefix in ("r", "f", "b", "rf", "fr", "br", "R", "rb"):
+ for quote in ('"', "'"):
+ snippet = (
+ f"PASSWORD = {prefix}" + quote * 3
+ + "\npassword=hunter2\n# hunter2\n"
+ + quote * 3
+ )
+ assert "hunter2" not in redact_literals(snippet), snippet
+
class TestCredentialRuleSelection:
@pytest.mark.parametrize(
"rule_id",You can send follow-ups to the cloud agent here.
A prefixed opener such as r""" or f""" was not read as opening a literal, so
the opening line was starred and its quotes were removed. Later lines were
still measured against the original spans, which said they were inside a
literal, so nothing masked them and the final literal pass no longer matched.
The prefix is now part of the opener check.
That alone left a partial reveal: literal text around an interpolation inflates
the body past the reveal threshold, so f"{b}_SuperSecret123!" showed 123!. An
interpolated body is masked whole, on the same reasoning as a multi-line one --
it is a block of content, not a single opaque value.
|
bugbot run |
An unterminated triple-quoted value still produces a literal match: the engine backtracks past the triple alternative and reads the first two quotes as an empty string. That match was enough to send the value to the masking pass, which then covered only those two quotes, so the credential stayed in the snippet. Affects bare and prefixed openers alike. Deferring now requires a span that starts at the quote and holds both delimiters, which an empty match cannot satisfy. _STRING_LITERAL also gained triple-quoted alternatives so a terminated block matches once with its real body rather than as an empty string followed by a second literal.
|
bugbot run |
…ariant A generated-snippet sweep found three gaps the hand-written cases did not, all of them the same thing: masking depends on knowing where string literals start and end, and a truncated snippet can make that unknowable. An unterminated literal is not reached by the assignment fallback when the value sits in a comparison or a call argument, so it went to the masking pass, which cannot match it. A quote outside every matched span now marks the rest of the line as literal content. An unclosed triple-quoted block does not simply fail to match -- its first two quotes match as an empty string and the third pairs with any stray quote later, producing one long span that hides a real assignment on a later line. An odd count of triple delimiters now masks from the opener to the end. A " or ' literal cannot hold a raw newline in any language these rules cover, so a match that does is the same pairing artifact rather than a literal. Those spans are discarded; backticks and triple quotes keep theirs. tests/test_secret_redaction_fuzz.py generates the combinations rather than listing them, and asserts no credential survives, none is partly revealed, and non-credential snippets come through unchanged. 2,000,000 generated cases pass; 20,000 run in CI in about a second.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Span filter leaks continued secrets
- The span filter now keeps
"/'matches whose newlines are backslash continuations, so the literal pass still masks secrets on the next line.
- The span filter now keeps
Or push these changes by commenting:
@cursor push aa2f330ce9
Preview (aa2f330ce9)
diff --git a/socket_basics/core/utils/redaction.py b/socket_basics/core/utils/redaction.py
--- a/socket_basics/core/utils/redaction.py
+++ b/socket_basics/core/utils/redaction.py
@@ -170,6 +170,11 @@
# Triple-quote delimiters, counted to detect a block that never closes.
_TRIPLE_QUOTE = re.compile(r'\"\"\"|\'\'\'')
+# A newline that is not a backslash continuation. Distinguishes a pairing
+# artifact (an unclosed ``"`` / ``'`` that matched a stray quote later) from a
+# legitimate line-continued string, which most bundled languages allow.
+_UNESCAPED_NEWLINE = re.compile(r'(?<!\\)(?:\\\\)*\n')
+
# Rule-name fragments whose finding *is* the credential. ``hardcoded-ip`` and
# the password-policy rules deliberately do not appear: their snippets are
# logic, and masking them would remove the reason the finding was raised.
@@ -355,12 +360,15 @@
# rules cover, so a match that does is not a literal -- it is an unclosed
# quote that paired with a stray one further down, and the span between them
# would hide whatever it covers, including a real assignment on a later
- # line. Backticks and triple quotes span lines legitimately and are kept.
+ # line. A backslash-escaped newline is line continuation, which those
+ # languages do allow; OpenGrep's ``extra.lines`` includes every line of
+ # that match, so the span has to stay or the continuation is never masked.
+ # Backticks and triple quotes span lines without escaping and are kept.
spans = [
match.span() for match in _STRING_LITERAL.finditer(text)
if len(match.group('quote')) > 1
or match.group('quote') == '`'
- or '\n' not in match.group(0)
+ or not _UNESCAPED_NEWLINE.search(match.group(0))
]
def in_literal(position: int) -> bool:
diff --git a/tests/test_secret_redaction.py b/tests/test_secret_redaction.py
--- a/tests/test_secret_redaction.py
+++ b/tests/test_secret_redaction.py
@@ -316,6 +316,20 @@
for snippet in ('password = "hunter2\n# broken', "password = 'hunter2\n-- sql"):
assert "hunter2" not in redact_literals(snippet), snippet
+ def test_a_backslash_continued_literal_is_masked(self):
+ """A ``\\`` newline is a real literal, not a pairing artifact.
+
+ Dropping the span stars the first-line value, including the opener,
+ so the masking pass cannot see the rest of the string. A credential
+ on the continuation line would then survive.
+ """
+ for snippet in (
+ 'password = "\\\nhunter2"',
+ "password = 'first\\\nhunter2'",
+ 'password = r"\\\nhunter2"',
+ ):
+ assert "hunter2" not in redact_literals(snippet), snippet
+
def test_a_literal_spanning_lines_keeps_the_line_structure(self):
redacted = redact_literals(self.TRIPLE_DOUBLE)
assert redacted.count("\n") == 2You can send follow-ups to the cloud agent here.
Bugbot found that a credential on a continuation line survived, and extending the fuzzer to put the secret after the line break -- it had only ever put it before -- found a second case immediately. Both are the same thing: masking that stops at the opening line. Where a literal opens and its end is unknowable, everything after is inside it as far as any reader can tell, so masking now runs to the end of the snippet rather than the end of the line. The two ways state is lost -- a quote no surviving span covers, and an odd number of triple delimiters -- are handled together instead of separately. The second case was the opposite failure. Masking an unquoted value whole destroyed the opening quote of a literal that continued past the line, so the snippet-wide pass afterwards no longer matched and the rest of the literal was left alone. Such a value is now masked only up to the opener, and the pass takes the literal itself. Both generators put the secret on either side of a line break, so the shape is covered from here on. 1,000,000 generated cases pass.
|
bugbot run |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit d790339. Configure here.

Summary
Release prep for 3.4.0, covering #119 and the already-merged #117. Version files,
uv.lockand every current-release reference inREADME.mdanddocs/**are bumped, and the[Unreleased]changelog section is stamped as[3.4.0] - 2026-09-18.Stacked on #119. This branch is cut from #119's head so the changelog entries it stamps are present. Merge #119 first; this one merges last, and the tag goes on its merge commit.
Changes
pyproject.toml,socket_basics/version.py,socket_basics/__init__.py,action.yml,uv.lock→ 3.4.0README.mdanddocs/**(version strings only),scripts/check_release_docs.py --checkpassesCHANGELOG.md:[Unreleased]→[3.4.0] - 2026-09-18, plus a release summary and an### Upgrade notessection covering the snippet-content changeCHANGELOG.md: records socketdev 3.5.0 → 3.6.0 (chore(deps): bump socketdev from 3.5.0 to 3.6.0 in the python-minor-patch group #117), which merged without an entry. Lockfile only; the>=3.5.0floor inpyproject.tomlis unchanged.Not included
#118's tool pins. That branch is still a draft, blocked on
ghcr.io/socketdev/trivy:0.74.0being published, and maintenance upgrades are deferred to a later release. It will renumber when it comes off draft.Note
Medium Risk
Finding
codeSnippet, descriptions, and traces change for hardcoded-credential rules, which can break snippet-based baselines and alters what notifiers and facts files contain—intentionally reducing secret exposure but requiring consumer awareness on upgrade.Overview
Release 3.4.0 ships the credential-masking work from #119 plus lockfile refresh for
socketdev3.6.0 (#117). Version constants,action.yml/ GHCR image pins,uv.lock, and docs examples move from 3.3.0 → 3.4.0;CHANGELOG.mdadds upgrade notes because finding payloads change on upgrade.The functional change is a much more conservative
redact_literalspipeline insocket_basics/core/utils/redaction.py: assignment binding respects annotations, operators inside literals,;-separated statements, and trailing#////--comments; call-shaped assignments with literal arguments stay readable while literals are still masked; multiline / prefixed / interpolated literals and unterminated quotes are handled fail-closed. Credential rules still get composed masking viaredact_snippet/ messages / dataflow traces, withredactrule metadata documented in the changelog.Coverage expands with targeted unit tests (
tests/test_secret_redaction.py) and a 20k-iteration fuzz harness (tests/test_secret_redaction_fuzz.py) asserting secrets never leak from generated snippets.Reviewed by Cursor Bugbot for commit d790339. Configure here.