Skip to content

fix(sast): mask credential values in finding snippets - #119

Merged
lelia merged 7 commits into
mainfrom
leliahui/ce-473-redact-secrets-from-socket-basics-scan-output-and-dashboard
Sep 18, 2026
Merged

lelia merged 7 commits into
mainfrom
leliahui/ce-473-redact-secrets-from-socket-basics-scan-output-and-dashboard

Conversation

@lelia

@lelia lelia commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

A SAST finding's codeSnippet is 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.json in 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:

STRIPE_SECRET_KEY = "sk_l************************LzXc"

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-secret and *-empty-password. Selection is by rule name, and a custom rule can state it directly with a redact metadata 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[*].content and 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-ip and 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:

  • redactedValue kept 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.
  • TruffleHog no longer scans the facts file the run writes. That file lands inside the scan target, so a previous run's output was on disk during the walk and its contents were reported as findings of their own, pointing at the output file rather than the source line. The exclusion resolves relative output directories to the same absolute form used for scan targets.

Unrelated cleanup carried here

load_explicit_env_config logs which API key environment variables are set. It built that line from a dict mapping each name to bool(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 in tests/test_config_source.py pin both behaviors.

Verification

  • 452 tests pass, 50 of them in 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.
  • End-to-end against a fixture covering all fifteen languages (--all-languages --all-rules): 29 findings, every credential masked, and the written facts file contains none of the fixture's values in any field.
  • The non-credential rules were checked separately to confirm their snippets still come through unchanged.

Notes for review

  • Masking is deliberately conservative in two visible ways. define('SECRET', '...') masks the constant name along with the value, and password: "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.
  • The redact metadata key is documented under custom SAST rules in docs/parameters.md.
  • The sample values in tests/test_secret_redaction.py are 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.json and 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/redaction module masks alerts when OpenGrep builds them: credential rules (by name or custom metadata.redact) get string-literal redaction on codeSnippet and metavar-expanded message text; 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 same mask_value helper (full mask for values under 16 characters) and excludes .socket.facts.json and its .tmp staging path so prior-run output is not re-reported as secrets.

Docs and changelog document the redact metadata key; .github/secret_scanning.yml narrowly ignores the redaction test file. load_explicit_env_config logs 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.

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.
@lelia
lelia requested a review from a team as a code owner September 18, 2026 21:16
@lelia
lelia deployed to socket-firewall September 18, 2026 21:16 — with GitHub Actions Active
…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.
@lelia
lelia deployed to socket-firewall September 18, 2026 21:22 — with GitHub Actions Active
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.
@lelia
lelia deployed to socket-firewall September 18, 2026 21:37 — with GitHub Actions Active
@lelia
lelia deployed to socket-firewall September 18, 2026 21:49 — with GitHub Actions Active
@lelia lelia mentioned this pull request Sep 18, 2026
@lelia
lelia deployed to socket-firewall September 18, 2026 22:06 — with GitHub Actions Active
lelia added a commit that referenced this pull request Sep 18, 2026
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.
@lelia

lelia commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread socket_basics/core/utils/redaction.py Outdated
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.
@lelia
lelia deployed to socket-firewall September 18, 2026 22:21 — with GitHub Actions Active
lelia added a commit that referenced this pull request Sep 18, 2026
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.
@lelia

lelia commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@flowstate Eric Hibbs (flowstate) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.
@lelia
lelia deployed to socket-firewall September 18, 2026 22:36 — with GitHub Actions Active
lelia added a commit that referenced this pull request Sep 18, 2026
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.
@lelia
lelia merged commit fe0b636 into main Sep 18, 2026
22 checks passed
lelia added a commit that referenced this pull request Sep 18, 2026
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.
lelia added a commit that referenced this pull request Sep 19, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants