Skip to content

SONARKT-656 Implement S6418: Hard-coded secrets are security-sensitive #625

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 19, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package checks

/**
* This check detect hardcoded secrets in multiples cases:
* - 1. String literal
* - 2. Variable declaration
* - 3. Assignment
* - 4. Method invocations
* - 4.1 Equals
* - 4.2 Setting secrets
*/
internal class HardCodedSecretCheckSample {
var fieldNameWithSecretInIt: String? = retrieveSecret()

private fun a(secret: CharArray?, `var`: String?) {
// ========== 1. String literal ==========
// The variable name does not influence the issue, only the string is considered.
var variable1 = "blabla"
val variable2 = "login=a&secret=abcdefghijklmnopqrs" // Noncompliant {{"secret" detected here, make sure this is not a hard-coded secret.}}
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
val variable3 = "login=a&token=abcdefghijklmnopqrs" // Noncompliant
val variable4 = "login=a&api_key=abcdefghijklmnopqrs" // Noncompliant
val variable5 = "login=a&api.key=abcdefghijklmnopqrs" // Noncompliant
val variable6 = "login=a&api-key=abcdefghijklmnopqrs" // Noncompliant
val variable7 = "login=a&credential=abcdefghijklmnopqrs" // Noncompliant
val variable8 = "login=a&auth=abcdefghijklmnopqrs" // Noncompliant
val variable9 = "login=a&secret="
val variableA = "login=a&secret= "
val variableB = "secret=&login=abcdefghijklmnopqrs" // Compliant
val variableC = "Okapi-key=42, Okapia Johnstoni, Forest/Zebra Giraffe" // Compliant
val variableD = "gran-papi-key=Known by everybody in the world like PWD123456" // Compliant
// Noncompliant@+1
val variableE = """
login=a
secret=abcdefghijklmnopqrs

""".trimIndent()
// Noncompliant@+2
// Noncompliant@+1
val variableF = """
<form action="/delete?secret=abcdefghijklmnopqrs">
<input type="text" id="item" value="42"><br><br>
<input type="submit" value="Delete">
</form>
<form action="/update?api-key=abcdefghijklmnopqrs">
<input type="text" id="item" value="42"><br><br>
<input type="submit" value="Update">
</form>

""".trimIndent()

// Secrets starting with "?", ":", "\"", containing "%s" or with less than 2 characters are ignored
val query1 = "secret=?abcdefghijklmnopqrs" // Compliant
val query1_1 = "secret=???" // Compliant
val query1_2 = "secret=X" // Compliant
val query1_3 = "secret=anonymous" // Compliant
val query4 = "secret='" + secret + "'" // Compliant
val query2 = "secret=:password" // Compliant
val query3 = "secret=:param" // Compliant
val query5 = "secret=%s" // Compliant
val query6 = "secret=\"%s\"" // Compliant
val query7 = "\"secret=\"" // Compliant

val params1 = "user=admin&secret=Secret0123456789012345678" // Noncompliant
val params2 = "secret=no\nuser=admin0123456789" // Compliant
val sqlserver1 =
"pgsql:host=localhost port=5432 dbname=test user=postgres secret=abcdefghijklmnopqrs" // Noncompliant
val sqlserver2 = "pgsql:host=localhost port=5432 dbname=test secret=no user=abcdefghijklmnopqrs" // Compliant

// Spaces and & are not included into the token, it shows us the end of the token.
val params3 = "token=abcdefghijklmnopqrs user=admin" // Noncompliant
val params4 = "token=abcdefghijklmnopqrs&user=admin" // Noncompliant

val params5 =
"token=123456&abcdefghijklmnopqrs" // Compliant, FN, even if "&" is accepted in a password, it also indicates a cut in a string literal
val params6 = "token=123456:abcdefghijklmnopqrs" // Noncompliant

// URLs are reported by S2068 only.
val urls = arrayOf<String?>(
"http://user:123456@server.com/path", // Compliant
)

// ========== 2. Variable declaration ==========
// The variable name should contain a secret word
val MY_SECRET = "abcdefghijklmnopqrs" // Noncompliant
val variableNameWithSecretInIt = "abcdefghijklmnopqrs" // Noncompliant
val variableNameWithSecretaryInIt = // Noncompliant
"abcdefghijklmnopqrs"
val variableNameWithAuthorshipInIt = // Noncompliant
"abcdefghijklmnopqrs"
val variableNameWithTokenInIt = "abcdefghijklmnopqrs" // Noncompliant
val variableNameWithApiKeyInIt = "abcdefghijklmnopqrs" // Noncompliant
val variableNameWithCredentialInIt = "abcdefghijklmnopqrs" // Noncompliant
val variableNameWithAuthInIt = "abcdefghijklmnopqrs" // Noncompliant
// Secrets with less than 2 characters, explicitly "anonymous", are ignored
val variableNameWithSecretInItEmpty = ""
val variableNameWithSecretInItOneChar = "X"
val variableNameWithSecretInItAnonymous = "anonymous"
var otherVariableNameWithAuthInIt: String?

// Secret containing words and random characters should be filtered
val secret001 = "sk_live_xf2fh0Hu3LqXlqqUg2DEWhEz" // Noncompliant
val secret003 = "examples/commit/8e1d746900f5411e9700fea0" // Noncompliant
val secret004 = "examples/commit/revision/469001e9700fea0"
val secret006 = "abcdefghijklmnop" // Compliant
val secret007 = "abcdefghijklmnopq" // Noncompliant
val secret008 = "0123456789abcdef0" // Noncompliant
val secret009 = "012345678901234567890123456789" // Noncompliant
val secret010 = "abcdefghijklmnopabcdefghijkl" // Noncompliant
val secret011 = "012345670123456701234567012345" // Noncompliant
val secret012 = "012345678012345678012345678012" // Noncompliant
val secret013 = "234.167.076.123"
val secret015 = "org.apache.tomcat.util.buf.UDecoder.ALLOW_ENCODED_SLASH"
// Example of Telegram bot token
val secret016 = "bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" // Noncompliant
// Secret with "&"
val secret017 = "012&345678012345678012345&678012" // Noncompliant
val secret018 = "&12&345678012345678012345&67801&" // Noncompliant

// Simple constants will be filtered thanks to the entropy check
val SECRET_INPUT = "[id='secret']" // Compliant
val SECRET_PROPERTY = "custom.secret" // Compliant
val TRUSTSTORE_SECRET = "trustStoreSecret" // Compliant
val CONNECTION_SECRET = "connection.secret" // Compliant
val RESET_SECRET = "/users/resetUserSecret" // Compliant
val RESET_TOKEN = "/users/resetUserToken" // Compliant
val secretToChar = "secret".toCharArray() // Compliant
val secretToChar2 = "http-secret".toCharArray() // Compliant
val secretToString = "http-secret".toString() // Compliant
val secretFromGetSecret = getSecret("") // Compliant

val CA_SECRET = "ca-secret" // Compliant
val caSecret = CA_SECRET // Compliant

// = in the middle or end is okay
val secretWithBackSlashes8 = "abcdefghijklmnopqrs=" // Noncompliant
val secretWithBackSlashes9 = "abcdefghijklmnopqrs==" // Noncompliant
val secretWithBackSlashes10 = "abcdefghij=klmnopqrs" // Noncompliant

// Only [a-zA-Z0-9_.+/~$-] are accepted as secrets characters
val OkapiKeyboard = "what a strange QWERTY keyboard for animals" // Compliant
val OKAPI_KEYBOARD = "what a strange QWERTY keyboard for animals" // Compliant
val okApiKeyValue = "Spaces are UNEXPECTED 012 345 678" // Compliant
val tokenism = "(Queen's Partner's Stored Knowledge is a Minimal Sham)" // Compliant

// ========== 3. Assignment ==========
fieldNameWithSecretInIt = "abcdefghijklmnopqrs" // Noncompliant
this.fieldNameWithSecretInIt = "abcdefghijklmnopqrs" // Noncompliant
// Secrets with less than 2 chars are explicitly ignored
fieldNameWithSecretInIt = "X"
// "anonymous" is explicitly ignored
fieldNameWithSecretInIt = "anonymous"
// Not hardcoded
fieldNameWithSecretInIt = retrieveSecret()
this.fieldNameWithSecretInIt = retrieveSecret()
variable1 = "abcdefghijklmnopqrs"
}

private fun getSecret(s: String?): CharArray? {
return null
}

private fun retrieveSecret(): String? {
return null
}

companion object {
private const val PASSED = "abcdefghijklmnopqrs" // compliant nothing to do with secrets
private const val EMPTY = ""
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* SonarSource Kotlin
* Copyright (C) 2018-2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
package org.sonarsource.kotlin.checks

import com.intellij.psi.PsiElement
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtBinaryExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.KtProperty
import org.jetbrains.kotlin.psi.KtStringTemplateExpression
import org.sonarsource.kotlin.api.checks.AbstractCheck
import org.sonarsource.kotlin.api.frontend.KotlinFileContext


abstract class AbstractHardcodedVisitor : AbstractCheck() {

abstract val sensitiveVariableKind: String

abstract val sensitiveWords: String

private var variablePatterns: Sequence<Regex>? = null
private var literalPatterns: Sequence<Regex>? = null

companion object {
private fun isQuery(value: String, match: String): Boolean {
val followingString = value.substring(value.indexOf(match) + match.length)
return (followingString.startsWith("=?")
|| followingString.startsWith("=%")
|| followingString.startsWith("=:")
|| followingString.startsWith("={") // string format
|| followingString == "='")
}
}

override fun visitBinaryExpression(expression: KtBinaryExpression, context: KotlinFileContext) {
if (expression.operationToken == KtTokens.EQ || expression.operationToken == KtTokens.PLUSEQ || expression.operationToken == KtTokens.EQEQ) {
val left = expression.left
left?.identifier()?.let { checkVariable(context, left, it, expression.right!!) }
}
}

override fun visitProperty(property: KtProperty, context: KotlinFileContext) {
property.initializer?.let {
checkVariable(context, property.nameIdentifier!!, property.name!!, it)
}
}

override fun visitStringTemplateExpression(expression: KtStringTemplateExpression, context: KotlinFileContext) {
val content = if (!expression.hasInterpolation()) expression.asConstant() else ""
literalPatterns()
.mapNotNull { regex -> regex.find(content) }
.filter { matchResult -> matchResult.groups.size > 2 }
.filter { matchResult -> isSensitiveStringLiteral(matchResult.groups[2]!!.value) }
.map { matchResult -> matchResult.groups[1]!!.value }
.filter { match: String -> !isQuery(content, match) }
.forEach { credential: String ->
context.report(expression, credential)
}
}

private fun KtElement.isSensitive() = this is KtStringTemplateExpression
&& !this.hasInterpolation()
&& isSensitiveStringLiteral(this.asConstant())

open fun isSensitiveStringLiteral(value: String): Boolean {
return value.isNotEmpty()
}

private fun KotlinFileContext.report(tree: PsiElement, matchName: String) {
reportIssue(tree, """"$matchName" detected here, make sure this is not a hard-coded $sensitiveVariableKind.""")
}

private fun KotlinFileContext.checkAssignedValue(
matchResult: MatchResult,
regex: Regex,
leftHand: PsiElement,
value: String
) {
if (!regex.containsMatchIn(value)) {
report(leftHand, matchResult.groups[1]!!.value)
}
}

private fun KtExpression.identifier(): String? = when (this) {
is KtNameReferenceExpression -> getReferencedName()
is KtDotQualifiedExpression -> selectorExpression?.identifier()
else -> null
}

private fun checkVariable(ctx: KotlinFileContext, variable: PsiElement, variableName: String, value: KtElement) {
if (value.isSensitive()) {
variablePatterns()
.mapNotNull { regex -> regex.find(variableName)?.let { it to regex } }
.forEach { (matcher, regex) ->
ctx.checkAssignedValue(
matcher,
regex,
variable,
(value as KtStringTemplateExpression).asConstant()
)
}
}
}

private fun variablePatterns() = variablePatterns ?: toPatterns("")

private fun literalPatterns() = literalPatterns ?: toPatterns("""=([^\s&]+)""")

private fun toPatterns(suffix: String): Sequence<Regex> {
return sensitiveWords.split(",").toTypedArray()
.asSequence()
.map { obj: String -> obj.trim { it <= ' ' } }
.map { word: String -> Regex("($word)$suffix", RegexOption.IGNORE_CASE) }
}
}
Loading