diff --git a/kotlin-checks-test-sources/src/main/kotlin/checks/HardcodedSecretsCheckSample.kt b/kotlin-checks-test-sources/src/main/kotlin/checks/HardcodedSecretsCheckSample.kt new file mode 100644 index 000000000..2710e23d9 --- /dev/null +++ b/kotlin-checks-test-sources/src/main/kotlin/checks/HardcodedSecretsCheckSample.kt @@ -0,0 +1,170 @@ +package checks + +/** + * This check detect hardcoded secrets in multiples cases: + * - 1. String literal + * - 2. Variable declaration + * - 3. Assignment + */ +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 = """ +
+

+ +
+
+

+ +
+ + """.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( + "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 secret777 = "sk_live_aaaaaaaaaaaaaaaaaaaaaaaa" // Compliant, not enough entropy + 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 = "" + } +} + diff --git a/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/AbstractHardcodedVisitor.kt b/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/AbstractHardcodedVisitor.kt new file mode 100644 index 000000000..97b869e9c --- /dev/null +++ b/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/AbstractHardcodedVisitor.kt @@ -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? = null + private var literalPatterns: Sequence? = 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) { + 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("").also { variablePatterns = it } + + private fun literalPatterns() = literalPatterns ?: toPatterns("""=([^\s&]+)""").also { literalPatterns = it } + + private fun toPatterns(suffix: String): Sequence { + return sensitiveWords.split(",").toTypedArray() + .asSequence() + .map { obj: String -> obj.trim { it <= ' ' } } + .map { word: String -> Regex("($word)$suffix", RegexOption.IGNORE_CASE) } + } +} diff --git a/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedCredentialsCheck.kt b/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedCredentialsCheck.kt index 517c80a6e..4c491d1fa 100644 --- a/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedCredentialsCheck.kt +++ b/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedCredentialsCheck.kt @@ -18,28 +18,21 @@ package org.sonarsource.kotlin.checks import java.net.URI import java.net.URISyntaxException -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.sonar.check.Rule import org.sonar.check.RuleProperty -import org.sonarsource.kotlin.api.checks.AbstractCheck import org.sonarsource.kotlin.api.frontend.KotlinFileContext @Rule(key = "S2068") -class HardcodedCredentialsCheck : AbstractCheck() { +class HardcodedCredentialsCheck : AbstractHardcodedVisitor() { @RuleProperty(key = "credentialWords", description = "Comma separated list of words identifying potential credentials", defaultValue = DEFAULT_VALUE) var credentialWords = DEFAULT_VALUE - private var variablePatterns: Sequence? = null - private var literalPatterns: Sequence? = null + override val sensitiveVariableKind: String + get() = "credential" + override val sensitiveWords: String + get() = credentialWords companion object { private const val DEFAULT_VALUE = "password,passwd,pwd,passphrase" @@ -53,34 +46,12 @@ class HardcodedCredentialsCheck : AbstractCheck() { val parts = userInfo.split(":").toTypedArray() return (parts.size > 1 && parts[0] != parts[1]) && !(parts.size == 2 && parts[1].isEmpty()) } - } catch (e: URISyntaxException) { + } catch (_: URISyntaxException) { // ignore, stringLiteral is not a valid URI } } return false } - - 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) { - 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) { @@ -88,56 +59,7 @@ class HardcodedCredentialsCheck : AbstractCheck() { if (isURIWithCredentials(content)) { context.reportIssue(expression, "Review this hard-coded URL, which may contain a credential.") } else { - literalPatterns() - .mapNotNull { regex -> regex.find(content) } - .map { matchResult -> matchResult.groups[1]!!.value } - .filter { match: String -> !isQuery(content, match) } - .forEach { credential: String -> context.report(expression, credential) } - } - } - - private fun KtElement.isNotEmptyString() = this is KtStringTemplateExpression - && !this.hasInterpolation() - && this.asConstant().isNotEmpty() - - private fun KotlinFileContext.report(tree: PsiElement, matchName: String) { - reportIssue(tree, """"$matchName" detected here, make sure this is not a hard-coded credential.""") - } - - 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.isNotEmptyString()) { - variablePatterns() - .mapNotNull { regex -> regex.find(variableName)?.let { it to regex } } - .forEach { (matcher, regex) -> - ctx.checkAssignedValue( - matcher, - regex, - variable, - (value as KtStringTemplateExpression).asConstant()) - } + super.visitStringTemplateExpression(expression, context) } } - - private fun variablePatterns() = variablePatterns ?: toPatterns("") - - private fun literalPatterns() = literalPatterns ?: toPatterns("""=\S""") - - private fun toPatterns(suffix: String): Sequence { - return credentialWords.split(",").toTypedArray() - .asSequence() - .map { obj: String -> obj.trim { it <= ' ' } } - .map { word: String -> Regex("($word)$suffix", RegexOption.IGNORE_CASE) } - } } diff --git a/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedSecretsCheck.kt b/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedSecretsCheck.kt new file mode 100644 index 000000000..196b3a440 --- /dev/null +++ b/sonar-kotlin-checks/src/main/java/org/sonarsource/kotlin/checks/HardcodedSecretsCheck.kt @@ -0,0 +1,76 @@ +/* + * 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 org.sonar.check.Rule +import org.sonar.check.RuleProperty +import org.sonarsource.analyzer.commons.EntropyDetector +import org.sonarsource.analyzer.commons.HumanLanguageDetector + +private const val DEFAULT_SECRET_WORDS = "api[_.-]?key,auth,credential,secret,token" +private const val DEFAULT_RANDOMNESS_SENSIBILITY = "3.0" + +private const val MAX_RANDOMNESS_SENSIBILITY = 10 +private const val MINIMUM_CREDENTIAL_LENGTH = 17 +private const val LANGUAGE_SCORE_INCREMENT = 0.3 + +@Rule(key = "S6418") +class HardcodedSecretsCheck : AbstractHardcodedVisitor() { + + @RuleProperty( + key = "secretWords", + description = "Comma separated list of words identifying potential secrets", + defaultValue = DEFAULT_SECRET_WORDS + ) + var secretWords: String = DEFAULT_SECRET_WORDS + + @RuleProperty( + key = "randomnessSensibility", + description = "Allows to tune the Randomness Sensibility (from 0 to 10)", + defaultValue = DEFAULT_RANDOMNESS_SENSIBILITY + ) + var randomnessSensibility: Double = DEFAULT_RANDOMNESS_SENSIBILITY.toDouble() + + private lateinit var entropyDetector: EntropyDetector + private var maxLanguageScore = 0.0 + + override val sensitiveVariableKind: String + get() = "secret" + override val sensitiveWords: String + get() = secretWords + + private fun getEntropyDetector(): EntropyDetector { + if (::entropyDetector.isInitialized.not()) { + entropyDetector = EntropyDetector(randomnessSensibility) + } + return entropyDetector + } + + override fun isSensitiveStringLiteral(value: String): Boolean { + return value.isNotEmpty() + && value.length >= MINIMUM_CREDENTIAL_LENGTH + && getEntropyDetector().hasEnoughEntropy(value) + && HumanLanguageDetector.humanLanguageScore(value) < maxLanguageScore() + } + + private fun maxLanguageScore(): Double { + if (maxLanguageScore == 0.0) { + maxLanguageScore = (MAX_RANDOMNESS_SENSIBILITY - randomnessSensibility) * LANGUAGE_SCORE_INCREMENT + } + return maxLanguageScore + } +} diff --git a/sonar-kotlin-checks/src/test/java/org/sonarsource/kotlin/checks/HardcodedSecretsCheckTest.kt b/sonar-kotlin-checks/src/test/java/org/sonarsource/kotlin/checks/HardcodedSecretsCheckTest.kt new file mode 100644 index 000000000..0e958a236 --- /dev/null +++ b/sonar-kotlin-checks/src/test/java/org/sonarsource/kotlin/checks/HardcodedSecretsCheckTest.kt @@ -0,0 +1,19 @@ +/* + * 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 + +internal class HardcodedSecretsCheckTest : CheckTest(HardcodedSecretsCheck()) diff --git a/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinCheckList.kt b/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinCheckList.kt index ef5e056b8..af7dbff83 100644 --- a/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinCheckList.kt +++ b/sonar-kotlin-plugin/src/main/java/org/sonarsource/kotlin/plugin/KotlinCheckList.kt @@ -71,6 +71,7 @@ import org.sonarsource.kotlin.checks.FunctionCognitiveComplexityCheck import org.sonarsource.kotlin.checks.GraphemeClustersInClassesCheck import org.sonarsource.kotlin.checks.HardcodedCredentialsCheck import org.sonarsource.kotlin.checks.HardcodedIpCheck +import org.sonarsource.kotlin.checks.HardcodedSecretsCheck import org.sonarsource.kotlin.checks.IdenticalBinaryOperandCheck import org.sonarsource.kotlin.checks.IdenticalConditionsCheck import org.sonarsource.kotlin.checks.IfConditionalAlwaysTrueOrFalseCheck @@ -209,6 +210,7 @@ val KOTLIN_CHECKS = listOf( GraphemeClustersInClassesCheck::class.java, HardcodedCredentialsCheck::class.java, HardcodedIpCheck::class.java, + HardcodedSecretsCheck::class.java, IdenticalBinaryOperandCheck::class.java, IdenticalConditionsCheck::class.java, IfConditionalAlwaysTrueOrFalseCheck::class.java, diff --git a/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/S6418.html b/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/S6418.html new file mode 100644 index 000000000..0f0153c67 --- /dev/null +++ b/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/S6418.html @@ -0,0 +1,92 @@ +

Because it is easy to extract strings from an application source code or binary, secrets should not be hard-coded. This is particularly true for +applications that are distributed or that are open-source.

+

In the past, it has led to the following vulnerabilities:

+ +

Secrets should be stored outside of the source code in a configuration file or a management service for secrets.

+

This rule detects variables/fields having a name matching a list of words (secret, token, credential, auth, api[_.-]?key) being assigned a +pseudorandom hard-coded value. The pseudorandomness of the hard-coded value is based on its entropy and the probability to be human-readable. The +randomness sensibility can be adjusted if needed. Lower values will detect less random values, raising potentially more false positives.

+

Ask Yourself Whether

+
    +
  • The secret allows access to a sensitive component like a database, a file storage, an API, or a service.
  • +
  • The secret is used in a production environment.
  • +
  • Application re-distribution is required before updating the secret.
  • +
+

There would be a risk if you answered yes to any of those questions.

+

Recommended Secure Coding Practices

+
    +
  • Store the secret in a configuration file that is not pushed to the code repository.
  • +
  • Use your cloud provider’s service for managing secrets.
  • +
  • If a secret has been disclosed through the source code: revoke it and create a new one.
  • +
+

Sensitive Code Example

+
+private val MY_SECRET = "47828a8dd77ee1eb9dde2d5e93cb221ce8c32b37"
+
+fun main() {
+  MyClass.callMyService(MY_SECRET)
+}
+
+

Compliant Solution

+

Using AWS Secrets Manager:

+
+import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
+import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
+
+fun main() {
+  SecretsManagerClient secretsClient = ...
+  MyClass.doSomething(secretsClient, "MY_SERVICE_SECRET")
+}
+
+fun doSomething(secretsClient: SecretsManagerClient, secretName: String) {
+  val valueRequest = GetSecretValueRequest.builder()
+    .secretId(secretName)
+    .build()
+
+  val valueResponse = secretsClient.getSecretValue(valueRequest)
+  val secret = valueResponse.secretString()
+  // do something with the secret
+  MyClass.callMyService(secret)
+}
+
+

Using Azure Key Vault Secret:

+
+import com.azure.identity.DefaultAzureCredentialBuilder;
+
+import com.azure.security.keyvault.secrets.SecretClient;
+import com.azure.security.keyvault.secrets.SecretClientBuilder;
+import com.azure.security.keyvault.secrets.models.KeyVaultSecret;
+
+fun main() {
+  val keyVaultName = System.getenv("KEY_VAULT_NAME")
+  val keyVaultUri = "https://$keyVaultName.vault.azure.net"
+
+  val secretClient = SecretClientBuilder()
+    .vaultUrl(keyVaultUri)
+    .credential(DefaultAzureCredentialBuilder().build())
+    .buildClient()
+
+  MyClass.doSomething(secretClient, "MY_SERVICE_SECRET")
+}
+
+fun doSomething(secretClient: SecretClent, secretName: String) {
+  val retrievedSecret = secretClient.getSecret(secretName)
+  val secret = retrievedSecret.getValue()
+
+  // do something with the secret
+  MyClass.callMyService(secret)
+}
+
+

See

+ + diff --git a/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/S6418.json b/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/S6418.json new file mode 100644 index 000000000..ef2b584ce --- /dev/null +++ b/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/S6418.json @@ -0,0 +1,48 @@ +{ + "title": "Hard-coded secrets are security-sensitive", + "type": "SECURITY_HOTSPOT", + "code": { + "impacts": { + "SECURITY": "BLOCKER" + }, + "attribute": "TRUSTWORTHY" + }, + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "30min" + }, + "tags": [ + "cwe" + ], + "defaultSeverity": "Blocker", + "ruleSpecification": "RSPEC-6418", + "sqKey": "S6418", + "scope": "Main", + "securityStandards": { + "CERT": [ + "MSC03-J." + ], + "CWE": [ + 798 + ], + "OWASP": [ + "A2" + ], + "OWASP Top 10 2021": [ + "A7" + ], + "PCI DSS 3.2": [ + "6.5.10" + ], + "PCI DSS 4.0": [ + "6.2.4" + ], + "ASVS 4.0": [ + "2.10.4", + "3.5.2", + "6.4.1" + ] + }, + "quickfix": "unknown" +} diff --git a/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/Sonar_way_profile.json b/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/Sonar_way_profile.json index c41b970ab..4dbda0491 100644 --- a/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/Sonar_way_profile.json +++ b/sonar-kotlin-plugin/src/main/resources/org/sonar/l10n/kotlin/rules/kotlin/Sonar_way_profile.json @@ -94,6 +94,7 @@ "S6318", "S6362", "S6363", + "S6418", "S6432", "S6474", "S6508",