Conversation
The SAST connector copied OpenGrep's matched source line into `props.codeSnippet` and into the `detailedReport` markdown verbatim. For the hardcoded-credential rules the matched line is the credential, so the value reached `.socket.facts.json` in the scanned workspace, the Socket upload, the dashboard, and every notifier payload. Masking now happens once, where the alert is built, so the snippet, the detailed report, the dataflow trace and all nine notifiers inherit it: - Rules whose match is a credential get their string literals masked, keeping the assignment target, the syntax, the file and the line. Selection is by rule name, with a `redact` metadata key so custom rules can opt in or out. This covers 20 rules across all fifteen bundled language rule sets, not only the Python and JavaScript ones. - Every snippet, trace step and report, whatever rule produced it, is scrubbed of values matching a well-known credential format, since a rule unrelated to secrets can still match a line carrying one. Rules whose match is not a credential are untouched: `*-hardcoded-ip` and the password-policy rules keep their snippets, because masking those would remove the reason the finding was raised. Two fixes on the TruffleHog path, which was already masking: - `redactedValue` kept the first and last four characters of any value longer than eight, leaving most of a short password readable. Values under sixteen characters are now masked in full. - TruffleHog scanned the facts file this run writes, which lands inside the scan target, so a value another scanner recorded there was re-detected as a finding pointing at the output file rather than the source line.
…ning The redaction tests need examples in published credential formats, which are the same formats a scanner walking this repository looks for. A literal is reported as a finding in its own right and blocks the push outright, so the Postgres connection string and the PEM block join the other samples in being built from a prefix and a body at runtime. .github/secret_scanning.yml excludes the file from alerts as a backstop. It is scoped to that one path, and it does not affect push protection -- assembling the value is what handles that.
The debug line reports which API key environment variables are set. It was built from a dict mapping each name to bool(os.environ.get(name)), then unpacked with .items() keeping only the key, so the value never reached the log -- but static analysis reads the dict as carrying the value into the log call and reports clear-text logging of a credential. Iterating a tuple of names and testing each for emptiness produces the same line from the same inputs, including the exclusion of an exported-but-empty variable, and leaves nothing for that reading to follow.
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 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.
|
bugbot run |
Two gaps Bugbot caught on the previous commit. The unquoted-assignment fallback matched a bare [=:], which stops on the first character of := or ==. The rest of the operator then heads the value, which no longer looks quoted, so the line took the unquoted branch and was starred out whole -- losing the operator and quotes the literal pass exists to keep. go-hardcoded-credentials matches $VAR := "...", so this reached real findings. = and : now match only where they are not part of a longer operator, and a comparison assigns nothing so it no longer matches at all. redact_dataflow_trace ran only the token scrub, so a trace step kept a generic password that the vendor-format patterns do not recognize. It now takes the credential flag and gives each step the same treatment as the snippet. Only a taint rule declaring redact in its metadata reaches this today, since no bundled credential rule is taint-mode, but the trace should not be the one field that keeps the value.
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 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.
|
bugbot run |
Eric Hibbs (flowstate)
left a comment
There was a problem hiding this comment.
[agent] Ran the full suite (446 passed, 6 skipped) and the targeted redaction/config/trufflehog tests (163 passed) clean.
A few things worth a look, none of them blocking:
redact_message's global replace can mangle unrelated text on short/common metavar values. It does redacted.replace(value, mask_value(value)) for every metavar's abstract_content found in the message. If a bound metavar happens to be short or a common substring, that gets masked everywhere it appears in the message, not just where it's the credential:
redact_message('secret a is bad', {'$X': {'abstract_content': 'a'}}, credential_finding=True)
# -> 'secret * is b*d'Real credential values are long enough that this probably won't bite in practice, but a minimum-length guard on which metavar values get swept into the replace set would close it cheaply.
The unquoted-assignment fallback in redact_literals over-masks lines that aren't simple assignments. It anchors on the first =/: and, if a quoted literal shows up anywhere after that, masks the whole remainder rather than just the literal:
redact_snippet('if x == "admin" and y == "SuperSecret123!":', credential_finding=True)
# -> 'if x =*************************************'Fails safe (over-redaction, not under), and none of the 20 currently-bundled rules match this shape — I checked all of them. But it's a trap waiting for the first custom rule (via the new redact metadata key) that matches a comparison or multi-key line instead of a single assignment, since at that point the finding stops being readable at all.
Verbose-mode logging still emits the raw, unredacted OpenGrep JSON. logger.debug('OpenGrep stdout: %s', result.stdout) runs before any of the new redaction, so --verbose runs still put the matched credential line into whatever's capturing debug logs (CI logs, log aggregators). Outside this PR's diff, but directly adjacent to what it's trying to close off — probably worth a follow-up.
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 69f1817. Configure here.
A bound metavariable is masked by replacing its value in the expanded message. A short value is also an ordinary substring, so the replace rewrote words that merely contained it: binding "a" turned "secret a is bad" into "secret * is b*d". Values below eight characters are now replaced only between non-word boundaries. Skipping them is not an option -- a short credential still has to be masked -- and longer values stay an unanchored replace, being specific enough not to collide.
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 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.
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.
* chore(release): 3.4.0 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. * fix(redaction): treat plain-text-password as logic, not a credential 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. * fix(redaction): serve both shapes of the password-logic rule Dropping plain-text-password from the credential fragments fixed the over-masking but opened a hole: one of that rule's patterns is a comparison against a hardcoded string, and no hardcoded-* rule matches that shape, so `if user.password == "hunter2"` went into the facts file verbatim. Confirmed by scanning a file with exactly that line. The fragment goes back, and the over-masking is fixed where it belongs. An assigned value that calls something is an expression, not a bare credential, so it skips the unquoted fallback and the literal pass masks just the quoted parts. `user.password = request.form.get('password')` keeps its expression, the comparison value is masked, and a bare value with a trailing comment is still masked whole so a short credential cannot be partly revealed. * fix(redaction): bind the right operator and mask trailing comments Three defects in the unquoted-assignment fallback, found by working through the shapes the credential rules actually produce. A call matched anywhere in the value skipped masking entirely, so `password: hunter2 # see get_secret()` kept the credential. The check is now anchored: only a value that opens with a call is treated as an expression. The first operator on the line bound, so a type annotation won over the assignment after it and `password: str = "..."` was starred out whole rather than reaching the literal pass -- ordinary Python and TypeScript. The last operator now binds, and operators covered by a string literal are skipped so the `:` in `url = "https://..."` cannot bind either. That needs the literal spans, which one regex cannot express, so _split_assignment walks the matches. Masking the value left a comment beside it holding the plaintext, as in `password = get_secret() # real value is hunter2`. Text after an unquoted comment marker is now masked too. Also stops measuring an unquoted value together with whatever follows it. `password: hunter2 # plain comment` is long enough for a partial reveal even though `hunter2` is not, and it was rendering as `password: hunt...ment`. * fix(redaction): take the comment off first and mask per statement Two ways a credential stayed in the part of the line the value search never looked at. A comment can hold an operator later in the line than the real one. Because the comment was masked after the operator was chosen, that one bound and the value in front of it was left in the head: `password = hunter2 # see x = y` kept hunter2. The comment now comes off before anything else reads the line. A line can also carry more than one statement, and only one operator binds per statement, so `a = hunter2; password = x` masked the second value and left the first. Masking now runs per statement, split on separators outside string literals. * fix(redaction): measure literal spans over the snippet, not per line 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. * fix(redaction): recognize string prefixes and interpolated bodies 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. * fix(redaction): do not defer on a spurious empty-literal match 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. * fix(redaction): fail safe when string state is lost, and fuzz the invariant 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. * fix(redaction): mask to the end of a snippet once string state is lost 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. * fix(redaction): fail closed on ambiguous credential syntax
Summary
A SAST finding's
codeSnippetis the source line the rule matched. For nearly every rule that line is the code the finding is about, and showing it is the point of the finding. For the hardcoded-credential rules the matched line contains the credential, so the finding reproduced the value it was reporting — into.socket.facts.jsonin the scanned workspace, into the uploaded facts, and into whichever notifiers were configured.Snippets for those rules now keep the assignment target, the syntax, the file and the line, and mask the credential value:
The rule, severity, location and remediation text stay unchanged. If a custom credential rule interpolates matched metavariables into its message, those values are masked there as well.
What gets masked
The shared logic lives in
socket_basics/core/utils/redaction.py:Rules whose match is a credential. String literals and unquoted assignment values in the matched snippet are masked. This covers 20 rules across all fifteen bundled language rule sets, not just the Python and JavaScript ones:
*-hardcoded-secret(s),*-hardcoded-credentials,*-hardcoded-password-default,*-default-credentials,*-plain-text-password,*-weak-jwt-secretand*-empty-password. Selection is by rule name, and a custom rule can state it directly with aredactmetadata key, which also works to opt a rule out.Every snippet, whatever rule produced it. Values matching a well-known credential format are masked: AWS key IDs, GitHub tokens, Stripe keys, Slack tokens, Google API keys, npm and PyPI tokens, JWTs, PEM private key bodies, and credentials in a URL authority. A rule unrelated to secrets can still match a line that happens to carry one. URL masking uses the captured credential's position, so repeated username/password text cannot mask the wrong occurrence.
Credential-rule messages. OpenGrep expands metavariables before returning a result. Expanded values used in a credential finding's message are masked before the message becomes the alert description or part of the detailed report.
Masking happens once, where the alert is built, so
props.codeSnippet,description,props.detailedReport.content,props.dataflowTrace[*].contentand all nine notifier formatters inherit it rather than each needing their own pass.Rules whose match is not a credential keep their snippets verbatim.
*-hardcoded-ipand the password-policy rules are excluded on purpose: masking those would remove the reason the finding was raised.Also included
The TruffleHog path already masked its values, and keeps doing so. Two improvements there:
redactedValuekept the first and last four characters of any value longer than eight, which left most of a short password readable. Values under sixteen characters are now masked in full.Unrelated cleanup carried here
load_explicit_env_configlogs which API key environment variables are set. It built that line from a dict mapping each name tobool(os.environ.get(name))and then unpacked it with.items(), keeping only the key — so a value never reached the log, but static analysis reads the dict as carrying one into the log call and reports clear-text logging of a credential. Iterating a tuple of names instead produces the same line from the same inputs, including the exclusion of an exported-but-empty variable, and leaves nothing for that reading to follow. Two tests intests/test_config_source.pypin both behaviors.Verification
tests/test_secret_redaction.py. One sweeps every bundled rule file, so a new language rule set cannot land without the selector recognizing its credential rules.--all-languages --all-rules): 29 findings, every credential masked, and the written facts file contains none of the fixture's values in any field.Notes for review
define('SECRET', '...')masks the constant name along with the value, andpassword: "admin"hides which default was used. Both lose a little context; in each case the rule ID, file and line still identify the finding, and narrowing this would need language-specific parsing.redactmetadata key is documented under custom SAST rules indocs/parameters.md.tests/test_secret_redaction.pyare assembled from a prefix and a body at runtime rather than written as literals. They are published examples and placeholders, but the formats under test are the same ones a scanner walking this repository looks for, so a literal would be reported as a finding of its own.Fixes CE-473
Note
Medium Risk
Changes how all OpenGrep and TruffleHog findings serialize sensitive fields into
.socket.facts.jsonand downstream notifiers; behavior is intentional but reviewers will see masked snippets and TruffleHog will skip the facts file.Overview
Stops hardcoded-credential SAST findings from copying secret values into facts, uploads, and notifiers. A new
socket_basics/core/utils/redactionmodule masks alerts when OpenGrep builds them: credential rules (by name or custommetadata.redact) get string-literal redaction oncodeSnippetand metavar-expandedmessagetext; every snippet, message, and dataflow-trace step also scrubs well-known token formats (AWS, GitHub, Stripe, JWT, PEM, URL passwords, etc.). TruffleHog now uses the samemask_valuehelper (full mask for values under 16 characters) and excludes.socket.facts.jsonand its.tmpstaging path so prior-run output is not re-reported as secrets.Docs and changelog document the
redactmetadata key;.github/secret_scanning.ymlnarrowly ignores the redaction test file.load_explicit_env_configlogs API-key env var names via a tuple loop (same debug line, no value in the structure static analysis flagged).Reviewed by Cursor Bugbot for commit 07809fe. Configure here.