From c2f7b6edb8a74190a367283dac1302bc9c618c1f Mon Sep 17 00:00:00 2001 From: Fynn Thierling Date: Tue, 10 Nov 2026 17:21:02 +0100 Subject: [PATCH 1/2] Adds detection rules for Dicom (if tls is used or not for fo-dicom and dcm4che) and utility detection rules for tls Signed-off-by: Fynn Thierling --- .gitignore | 4 + .../rules/detection/CSharpDetectionRules.java | 10 +- .../rules/detection/dotnet/DotNetTls.java | 218 ++++++++++++++++++ .../rules/detection/fodicom/FoDicom.java | 207 +++++++++++++++++ .../rules/detection/mailkit/MailKit.java | 94 ++++++++ .../plugin/rules/detection/npgsql/Npgsql.java | 73 ++++++ .../translator/CSharpTranslator.java | 4 +- .../CSharpProtocolContextTranslator.java | 184 +++++++++++++++ .../detection/dotnet/DotNetTlsTestFile.cs | 118 ++++++++++ .../detection/fodicom/FoDicomTestFile.cs | 186 +++++++++++++++ .../detection/mailkit/MailKitTestFile.cs | 74 ++++++ .../rules/detection/npgsql/NpgsqlTestFile.cs | 74 ++++++ .../rules/detection/dotnet/DotNetTlsTest.java | 109 +++++++++ .../rules/detection/fodicom/FoDicomTest.java | 99 ++++++++ .../rules/detection/mailkit/MailKitTest.java | 74 ++++++ .../rules/detection/npgsql/NpgsqlTest.java | 73 ++++++ .../csharp/CSharpDetectionEngine.java | 29 ++- .../language/csharp/CSharpTreeConverter.java | 73 +++++- .../language/csharp/CSharpTypeInference.java | 65 +++++- .../engine/model/factory/IValueFactory.java | 24 +- .../engine/model/factory/ProtocolFactory.java | 10 + java/pom.xml | 47 ++++ .../rules/detection/JavaDetectionRules.java | 6 +- .../dcm4che/Dcm4cheConnectionTls.java | 121 ++++++++++ .../rules/detection/dcm4che/Dcm4cheDicom.java | 155 +++++++++++++ .../detection/ssl/SSLDetectionRules.java | 3 +- .../detection/ssl/SSLEnabledSettings.java | 127 ++++++++++ .../ssl/SSLParametersSetCipherSuites.java | 59 +++++ .../rules/detection/ssl/SSLSetParameters.java | 18 +- .../JavaProtocolContextTranslator.java | 47 +++- .../dcm4che/Dcm4cheConnectionTlsTestFile.java | 51 ++++ .../dcm4che/Dcm4cheDicomTestFile.java | 81 +++++++ .../ssl/SSLEnabledSettingsTestFile.java | 69 ++++++ .../SSLSetParametersEndpointsTestFile.java | 33 +++ .../dcm4che/Dcm4cheConnectionTlsTest.java | 112 +++++++++ .../detection/dcm4che/Dcm4cheDicomTest.java | 103 +++++++++ .../rules/detection/dcm4che/Dcm4cheJars.java | 119 ++++++++++ .../detection/ssl/SSLEnabledSettingsTest.java | 100 ++++++++ .../ssl/SSLSetParametersEndpointsTest.java | 89 +++++++ .../mapper/mapper/ssl/CipherSuiteMapper.java | 24 ++ .../rules/CipherSuiteReorganizer.java | 33 ++- 41 files changed, 3180 insertions(+), 19 deletions(-) create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetTls.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/fodicom/FoDicom.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/mailkit/MailKit.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/npgsql/Npgsql.java create mode 100644 csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java create mode 100644 csharp/src/test/files/rules/detection/dotnet/DotNetTlsTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/fodicom/FoDicomTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/mailkit/MailKitTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/npgsql/NpgsqlTestFile.cs create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetTlsTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/fodicom/FoDicomTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/mailkit/MailKitTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/npgsql/NpgsqlTest.java create mode 100644 java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTls.java create mode 100644 java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicom.java create mode 100644 java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettings.java create mode 100644 java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLParametersSetCipherSuites.java create mode 100644 java/src/test/files/rules/detection/dcm4che/Dcm4cheConnectionTlsTestFile.java create mode 100644 java/src/test/files/rules/detection/dcm4che/Dcm4cheDicomTestFile.java create mode 100644 java/src/test/files/rules/detection/ssl/SSLEnabledSettingsTestFile.java create mode 100644 java/src/test/files/rules/detection/ssl/SSLSetParametersEndpointsTestFile.java create mode 100644 java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTlsTest.java create mode 100644 java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicomTest.java create mode 100644 java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheJars.java create mode 100644 java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettingsTest.java create mode 100644 java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLSetParametersEndpointsTest.java diff --git a/.gitignore b/.gitignore index 74637741a..bd353d5b6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,10 @@ build/ ### Add binaries for testing ### !go/build/ +# The dcm4che jars are fetched into java/target/test-jars by java/pom.xml instead of being +# checked in -- dcm4che is MPL/GPL/LGPL, and a copy here would be redistribution. This guards +# against one being added back by hand. +java/src/test/resources/test-jars/dcm4che-*.jar ### Generated scan output ### cbom.json diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java index 0b72c568f..1feae384a 100755 --- a/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java @@ -47,8 +47,12 @@ import com.ibm.plugin.rules.detection.dotnet.DotNetSHA; import com.ibm.plugin.rules.detection.dotnet.DotNetSHA3; import com.ibm.plugin.rules.detection.dotnet.DotNetSlhDsa; +import com.ibm.plugin.rules.detection.dotnet.DotNetTls; import com.ibm.plugin.rules.detection.dotnet.DotNetTripleDES; import com.ibm.plugin.rules.detection.dotnet.DotNetX25519DiffieHellman; +import com.ibm.plugin.rules.detection.fodicom.FoDicom; +import com.ibm.plugin.rules.detection.mailkit.MailKit; +import com.ibm.plugin.rules.detection.npgsql.Npgsql; import java.util.List; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -85,7 +89,11 @@ protected List> buildRules() { RuleSets.rulesOf(DotNetAlgorithmFactory.class).stream(), RuleSets.rulesOf(DotNetCertificateKeys.class).stream(), RuleSets.rulesOf(DotNetIncrementalHash.class).stream(), - RuleSets.rulesOf(DotNetCngKey.class).stream()) + RuleSets.rulesOf(DotNetCngKey.class).stream(), + RuleSets.rulesOf(DotNetTls.class).stream(), + RuleSets.rulesOf(MailKit.class).stream(), + RuleSets.rulesOf(Npgsql.class).stream(), + RuleSets.rulesOf(FoDicom.class).stream()) .flatMap(i -> i) .toList(); } diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetTls.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetTls.java new file mode 100644 index 000000000..896f2ceb9 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetTls.java @@ -0,0 +1,218 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dotnet; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * .NET transport security: {@code SslStream} and the properties that configure it. + * + *

The Java side has read {@code SSLContext}, {@code SSLParameters.setProtocols} and {@code + * setEnabledCipherSuites} for a long time; C# had nothing of the kind, so every .NET service that + * speaks TLS was invisible to a bill of materials. These rules close that, and they are what makes + * the DICOM rules say something: fo-dicom's {@code DefaultTlsInitiator} and {@code + * DefaultTlsAcceptor} configure the handshake through a {@code Protocols} property, so a DICOM + * association and the TLS version it offers are read by two rule sets that meet in the same {@code + * TLS} component. + * + *

Three shapes carry a version in real code, measured across MailKit, RabbitMQ's .NET client, + * Npgsql and fo-dicom: + * + *

    + *
  • A property assignment, by far the most common: {@code EnabledSslProtocols = + * SslProtocols.None} inside an options object, {@code client.SslProtocols = + * SslProtocols.Tls12 | SslProtocols.Tls13}, {@code cf.Ssl.Version = SslProtocols.Tls12}. Both + * a plain assignment and an object initializer arrive as a synthetic {@code set_} + * call. + *
  • The handshake itself, {@code AuthenticateAsClient} and its three siblings, which take the + * versions as an argument in their long overload. + *
  • The legacy global switch {@code ServicePointManager.SecurityProtocol}. + *
+ * + *

The property names split into two groups, and the split is what keeps this honest. {@code + * EnabledSslProtocols}, {@code SslProtocols} and {@code DefaultSslProtocols} can mean nothing else, + * so their rule accepts any argument and reads whatever name it resolves to. {@code Protocols}, + * {@code Version} and {@code SecurityProtocol} are ordinary words that any class may use, so their + * rules additionally require the argument to be a member of the enum itself -- {@code + * SslProtocols.Tls12} resolves to that type, a string or a number does not, and the rule then + * refuses the call rather than reporting a protocol named after someone's version field. + * + *

A flags combination resolves to both members, so {@code SslProtocols.Tls12 | + * SslProtocols.Tls13} states two versions rather than none. {@code SslProtocols.None} means the + * platform chooses, which is reported as TLS without a version -- that is exactly what is known. + */ +@SuppressWarnings("java:S1192") +public final class DotNetTls extends DetectionRuleSet { + + private static final String TLS = "TLS"; + private static final String SSL_PROTOCOLS = "SslProtocols"; + + @Nonnull + private static ProtocolContext tlsContext() { + return new ProtocolContext(Map.of("kind", TLS)); + } + + /** + * Properties whose name can only mean the TLS versions in force. + * + *

The receiver is any type, because a synthetic setter carries the variable it was written + * on rather than its type, and because these property names belong to half a dozen unrelated + * classes -- {@code SslClientAuthenticationOptions}, {@code HttpClientHandler}, MailKit's + * {@code MailService}, {@code ServicePointManager}. The name alone is the guard. + */ + private static final IDetectionRule SET_UNAMBIGUOUS_PROTOCOLS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods( + "set_EnabledSslProtocols", + "set_SslProtocols", + "set_DefaultSslProtocols") + .withMethodParameter(MethodMatcher.ANY) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(tlsContext()) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + /** + * Properties whose name is an ordinary word, so the argument has to prove itself. + * + *

{@code Protocols} is fo-dicom's, {@code Version} is RabbitMQ's {@code SslOption}. Both are + * names a class may use for anything, so the parameter is named rather than positional, which + * brings the strict side of the type check into play: a member access reports {@code + * SslProtocols} as its type and passes, while a string or a number is refused because the enum + * is a recognized named type. The positional form would not do -- the matcher there asks only + * whether an argument might fit, and a string might fit anything. + */ + private static final IDetectionRule SET_AMBIGUOUS_PROTOCOLS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("set_Protocols", "set_Version") + .withNamedMethodParameter("value", SSL_PROTOCOLS) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(tlsContext()) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + /** + * {@code ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12} — the global + * switch. + */ + private static final IDetectionRule SET_SECURITY_PROTOCOL = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("set_SecurityProtocol") + .withNamedMethodParameter("value", "SecurityProtocolType") + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(tlsContext()) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + /** + * The handshake itself, client side. + * + *

The call states that TLS is spoken at all, whichever overload is used, and the long + * overload names the versions. MailKit calls the options overload and fo-dicom the long one, so + * both occur; the short ones contribute the protocol without a version, which is what is known + * about them. + */ + /** + * {@code listenOptions.UseHttps(...)} -- ASP.NET Core's server-side TLS. + * + *

The counterpart of the handshake rules on the serving side. A Kestrel endpoint set up with + * {@code UseHttps} terminates TLS, and that is the whole statement the call makes: every + * overload means the same thing, and none of them names a version, so the arity is left open + * and nothing is read out of the arguments. The method name carries the match on its own -- + * unlike {@code Connect} or {@code set_Enabled}, {@code UseHttps} cannot plausibly mean + * anything but HTTPS, which is why an {@code ANY} receiver is safe here. + * + *

The versions are not in the call. Kestrel takes them from {@code + * HttpsConnectionAdapterOptions.SslProtocols}, which the protocol setters above already read, + * and a configuration that leaves them alone gets the operating system's defaults -- which is + * exactly what a TLS component without a version says. + */ + private static final IDetectionRule USE_HTTPS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("UseHttps") + .shouldBeDetectedAs(new ValueActionFactory<>(TLS)) + .withAnyParameters() + .buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + private static final IDetectionRule AUTHENTICATE_AS_CLIENT = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("AuthenticateAsClient", "AuthenticateAsClientAsync") + .shouldBeDetectedAs(new ValueActionFactory<>(TLS)) + .withNamedMethodParameter("targetHost", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("clientCertificates", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("enabledSslProtocols", SSL_PROTOCOLS) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .asChildOfParameterWithId(-1) + .withOptionalNamedMethodParameter("checkCertificateRevocation", "bool") + .buildForContext(tlsContext()) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + /** The handshake, server side: the certificate comes first and a client-auth flag follows. */ + private static final IDetectionRule AUTHENTICATE_AS_SERVER = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("AuthenticateAsServer", "AuthenticateAsServerAsync") + .shouldBeDetectedAs(new ValueActionFactory<>(TLS)) + .withNamedMethodParameter("serverCertificate", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("clientCertificateRequired", "bool") + .withOptionalNamedMethodParameter("enabledSslProtocols", SSL_PROTOCOLS) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .asChildOfParameterWithId(-1) + .withOptionalNamedMethodParameter("checkCertificateRevocation", "bool") + .buildForContext(tlsContext()) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of( + SET_UNAMBIGUOUS_PROTOCOLS, + SET_AMBIGUOUS_PROTOCOLS, + SET_SECURITY_PROTOCOL, + AUTHENTICATE_AS_CLIENT, + AUTHENTICATE_AS_SERVER, + USE_HTTPS); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/fodicom/FoDicom.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/fodicom/FoDicom.java new file mode 100644 index 000000000..0d9f07682 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/fodicom/FoDicom.java @@ -0,0 +1,207 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.fodicom; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.BooleanFactory; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * Detection rules for fo-dicom, the most used DICOM library for .NET. + * + *

DICOM is a network protocol before it is a file format, and what a bill of materials wants to + * know about a DICOM connection is whether it is protected at all. fo-dicom answers that in the + * call itself: {@code DicomClientFactory.Create(host, port, useTls, callingAe, calledAe)} takes the + * decision as a {@code bool}, and the sibling overload takes an {@code ITlsInitiator} instead, + * which exists only to start TLS. Across fo-dicom and its sample repository 75 of 77 calls write + * that argument as a literal, so the answer is nearly always in the source. + * + *

The finding is the protocol, not an algorithm: a {@code DICOM} protocol component, with a + * {@code TLS} protocol beneath it where the call asks for TLS. A connection made without it is + * reported as a DICOM component with nothing beneath, which is the honest statement -- these are + * the call sites a reader is looking for. + * + *

Both overloads are read by one rule, because two rules on {@code Create} would both match and + * count the call twice. {@code tlsInitiator} is declared where it sits in the signature, before + * {@code useTls}, and that is what makes the pair separable without any notion of subtyping: + * + *

    + *
  • Flag overload: the {@code bool} is offered to {@code tlsInitiator} first and refused, + * because a primitive against a name in {@code KNOWN_NAMED_TYPES} is definitely incompatible. + * {@code useTls} is then offered the calling AE title at its own position, refuses a string + * for a bool, and finds the flag as the call's only bool. + *
  • Initiator overload: the initiator is offered to {@code tlsInitiator} at its own position + * and nothing rules it out, so it binds there. {@code useTls} then finds no bool anywhere and + * stays empty. + *
+ * + *

A non-null initiator is itself the statement -- fo-dicom's own documentation says TLS is off + * exactly when it is null -- so the presence of an argument in that slot reports TLS, and no + * further value is read from it. A written {@code null} resolves to no value at all and reports + * nothing, which is the same answer. An initiator a consumer wrote themselves still binds by + * position; only a value that cannot be a {@code bool} ever reaches this parameter. + * + *

This matters because the two cases are otherwise indistinguishable in the output: before, + * {@code Create(host, port, tlsInitiator, ...)} and {@code Create(host, port, false, ...)} both + * produced a bare {@code DICOM} component, and a reader would take the protected association for an + * unprotected one. + * + *

The listening side is read the same way, from {@code DicomServerFactory.Create}: a DICOM + * server with an {@code ITlsAcceptor} requires TLS, one without accepts plaintext associations. It + * is the weaker of the two readings, because the server factory's overloads disagree about what + * sits at the acceptor's position and because an injected factory is invisible to receiver matching + * -- {@code DICOM_SERVER} says which, and why that costs recall rather than precision. + * + *

No algorithm is invented. fo-dicom's own {@code DefaultTlsInitiator} defaults to {@code + * SslProtocols.Tls12 | SslProtocols.Tls13} and names no cipher suite anywhere, and the tags it + * generates for DICOM's own security profile ({@code MACAlgorithm}, {@code + * EncryptedAttributesSequence}) are dictionary entries rather than operations -- referencing one + * says a field is read or written, not that this code computes a MAC. + */ +@SuppressWarnings("java:S1192") +public final class FoDicom extends DetectionRuleSet { + + private static final String DICOM = "DICOM"; + + // DicomClientFactory.Create(host, port, useTls|ITlsInitiator, callingAe, calledAe) + private static final IDetectionRule DICOM_CLIENT = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes("DicomClientFactory", "IDicomClientFactory") + .forMethods("Create") + .shouldBeDetectedAs(new ValueActionFactory<>(DICOM)) + .withNamedMethodParameter("host", MethodMatcher.ANY) + .withNamedMethodParameter("port", "int") + // Declared where it sits in the signature, before useTls, so that both + // overloads bind by position alone. See the class comment. + .withOptionalNamedMethodParameter("tlsInitiator", "ITlsInitiator") + .shouldBeDetectedAs(new ProtocolFactory<>("TLS")) + .asChildOfParameterWithId(-1) + .withOptionalNamedMethodParameter("useTls", "bool") + .shouldBeDetectedAs(new BooleanFactory<>()) + .asChildOfParameterWithId(-1) + .withOptionalNamedMethodParameter("callingAe", "string") + .withOptionalNamedMethodParameter("calledAe", "string") + .buildForContext(new ProtocolContext(Map.of("kind", DICOM))) + .inBundle(() -> "FoDicom") + .withoutDependingDetectionRules(); + + /** + * {@code new DicomClient(host, port, tlsInitiator, callingAe, calledAe, ...)}. + * + *

The constructor the factory itself ends up calling, and the clearest statement of the two: + * it has no flag overload at all, only the initiator, and its own documentation says "if null + * then no TLS is enabled, otherwise the handler to initiate TLS security". There is nothing to + * tell apart, so the initiator binds at its own position. + * + *

All nine parameters are declared although only the first three are read. The binder + * refuses a call with more arguments than the rule declares, and this constructor is always + * called with all nine. + */ + private static final IDetectionRule DICOM_CLIENT_CONSTRUCTOR = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes("DicomClient") + .forMethods("") + .shouldBeDetectedAs(new ValueActionFactory<>(DICOM)) + .withNamedMethodParameter("host", MethodMatcher.ANY) + .withNamedMethodParameter("port", "int") + .withOptionalNamedMethodParameter("tlsInitiator", "ITlsInitiator") + .shouldBeDetectedAs(new ProtocolFactory<>("TLS")) + .asChildOfParameterWithId(-1) + .withOptionalNamedMethodParameter("callingAe", "string") + .withOptionalNamedMethodParameter("calledAe", "string") + .withOptionalNamedMethodParameter("clientOptions", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("serviceOptions", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("loggerFactory", MethodMatcher.ANY) + .withOptionalNamedMethodParameter( + "advancedDicomClientConnectionFactory", MethodMatcher.ANY) + .buildForContext(new ProtocolContext(Map.of("kind", DICOM))) + .inBundle(() -> "FoDicom") + .withoutDependingDetectionRules(); + + /** + * {@code DicomServerFactory.Create(port, tlsAcceptor, ...)} and its two siblings. + * + *

The listening side of the same statement, and for a bill of materials the more pointed + * one: a DICOM server is an endpoint that accepts associations, so whether it requires TLS is a + * property of the deployment and not of a single connection. {@code tlsAcceptor} answers it the + * way {@code tlsInitiator} does on the client -- the parameter exists only to accept TLS + * connections, and the factory treats a null one as plaintext. + * + *

The server factory is harder to read than the client factory, and the reason is worth + * stating because it decides the layout below. Its three overloads do not share a parameter + * prefix: {@code Create} starts with the port, {@code Create(string, int, ...)} starts + * with the address, and {@code Create} puts {@code object userState} exactly where + * the previous one puts {@code ITlsAcceptor}. An {@code object} parameter cannot be told apart + * from an interface parameter by type -- neither is a primitive, so {@code + * isDefinitelyIncompatible} rules out nothing -- so whichever of the two is declared at that + * index will claim a positional argument written there. + * + *

This rule therefore declares {@code userState} at that index, not {@code tlsAcceptor}: a + * third positional argument is then read as the opaque state it is in one overload and silently + * dropped in the other, which costs a TLS acceptor written positionally in the middle overload + * and can never invent one. The acceptor is still found wherever it is unambiguous -- written + * as {@code tlsAcceptor:}, which is how every call site in fo-dicom and its sample repository + * writes it, or as the call's only argument of acceptor type, which is what {@code + * KNOWN_IMPLEMENTATIONS} in {@code CSharpTypeInference} is for. + * + *

Every parameter is optional because none of them sits at a fixed index across the + * overloads, so no single one can be demanded without rejecting a legal call: {@code + * Create(port)} would be thrown away by a required {@code port} at index 1 the moment the + * port is a variable rather than a literal. The DICOM component does not depend on any of them + * -- the call itself is the association. + */ + private static final IDetectionRule DICOM_SERVER = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes("DicomServerFactory", "IDicomServerFactory") + .forMethods("Create") + .shouldBeDetectedAs(new ValueActionFactory<>(DICOM)) + .withOptionalNamedMethodParameter("ipAddress", "string") + .withOptionalNamedMethodParameter("port", "int") + // Declared before tlsAcceptor on purpose, so that a third positional + // argument can never be read as an acceptor. See the comment above. + .withOptionalNamedMethodParameter("userState", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("tlsAcceptor", "ITlsAcceptor") + .shouldBeDetectedAs(new ProtocolFactory<>("TLS")) + .asChildOfParameterWithId(-1) + .withOptionalNamedMethodParameter("fallbackEncoding", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("logger", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("configure", MethodMatcher.ANY) + .buildForContext(new ProtocolContext(Map.of("kind", DICOM))) + .inBundle(() -> "FoDicom") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(DICOM_CLIENT, DICOM_CLIENT_CONSTRUCTOR, DICOM_SERVER); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/mailkit/MailKit.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/mailkit/MailKit.java new file mode 100644 index 000000000..d967d9159 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/mailkit/MailKit.java @@ -0,0 +1,94 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.mailkit; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * How a MailKit client asks for transport security. + * + *

MailKit is the .NET mail library, and the TLS decision is not made with .NET's own API: it is + * the {@code SecureSocketOptions} argument of {@code Connect}. That matters because the framework + * rules in {@code DotNetTls} only see MailKit's own {@code SslStream} handshakes, which live inside + * the library. An application that *uses* MailKit contains no handshake and no {@code SslProtocols} + * anywhere -- just {@code client.Connect(host, 993, SecureSocketOptions.SslOnConnect)} -- so + * without this rule its mail traffic is invisible in the bill of materials. + * + *

Only two of the five members are reported, and the reason is the difference between a decision + * and a preference. {@code SslOnConnect} and {@code StartTls} guarantee TLS: MailKit fails the + * connection rather than continue without it. {@code Auto} and {@code StartTlsWhenAvailable} + * continue in plaintext when the server does not offer TLS, so whether any cryptography happens is + * not knowable from the call, and {@code None} states that none does. The mapping lives in {@code + * CSharpProtocolContextTranslator}, where the closed member set is also what keeps this rule quiet + * on every unrelated {@code Connect} in a code base. + * + *

The receiver is {@code ANY}, which it has to be: the client is held in a variable and the C# + * frontend matches a receiver by its text, not by a variable's declared type. {@code Connect} is + * about the most common method name there is, so nothing here may state anything from a call's + * shape alone -- there is no action on the method and no flag is read. The one value this rule can + * produce is a name that resolves to a member of {@code SecureSocketOptions}, and that is also why + * MailKit's obsolete {@code Connect(host, port, useSsl)} overload is deliberately not read: a + * {@code bool} at that position cannot be told from the second argument of any other {@code + * Connect} in the world. + */ +public final class MailKit extends DetectionRuleSet { + + private static final String SECURE_SOCKET_OPTIONS = "SecureSocketOptions"; + + /** + * {@code Connect(host, port, options, ct)} and its socket- and stream-taking siblings, plus the + * {@code ...Async} spellings of all three. + * + *

{@code options} is declared at index 2, where the {@code (string, int, ...)} overload puts + * it. In the {@code (Socket, string, int, ...)} and {@code (Stream, string, int, ...)} + * overloads it sits one later, and it still binds: the port refuses it there because an {@code + * int} can never be a {@code SecureSocketOptions}, and it is then the call's only argument of + * that type. + */ + private static final IDetectionRule CONNECT = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("Connect", "ConnectAsync") + .withOptionalNamedMethodParameter("host", MethodMatcher.ANY) + .withOptionalNamedMethodParameter("port", "int") + .withOptionalNamedMethodParameter("options", SECURE_SOCKET_OPTIONS) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .withOptionalNamedMethodParameter("useSsl", "bool") + .withOptionalNamedMethodParameter("cancellationToken", MethodMatcher.ANY) + .buildForContext(new ProtocolContext(Map.of("kind", "MAILKIT"))) + .inBundle(() -> "MailKit") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(CONNECT); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/npgsql/Npgsql.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/npgsql/Npgsql.java new file mode 100644 index 000000000..0d95da729 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/npgsql/Npgsql.java @@ -0,0 +1,73 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.npgsql; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * Whether a database connection requires TLS. + * + *

{@code SslMode} is how the .NET database drivers state it -- Npgsql for PostgreSQL and + * MySqlConnector for MySQL spell the property and most of its members the same way -- and like + * MailKit's {@code SecureSocketOptions} it is the only TLS statement that appears in code that + * *uses* the driver. The driver's own {@code SslStream} handshake is inside the driver. + * + *

Three members require TLS and are reported: {@code Require}, and {@code VerifyCA} and {@code + * VerifyFull}, which require it and additionally validate the certificate. {@code Prefer}, {@code + * Allow} and MySqlConnector's {@code Preferred} fall back to plaintext when the server declines, so + * nothing is reported for them, and {@code Disable} and {@code None} state that there is nothing to + * report. Certificate validation itself is not recorded: CycloneDX protocol properties have no + * field for it, which is the same reason fo-dicom's {@code RequireMutualAuthentication} is left + * out. + * + *

Only the property assignment is read. The same setting can be written into a connection string + * ({@code "Host=db;SslMode=Require"}), and that is deliberately not matched -- a connection string + * is an opaque literal, and picking settings out of one by substring would report a mode that any + * number of unrelated strings could contain. + */ +public final class Npgsql extends DetectionRuleSet { + + /** {@code builder.SslMode = SslMode.Require}, as a synthetic property setter. */ + private static final IDetectionRule SET_SSL_MODE = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("set_SslMode") + .withNamedMethodParameter("value", "SslMode") + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", "SSL_MODE"))) + .inBundle(() -> "Npgsql") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(SET_SSL_MODE); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java b/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java index fa3cac7a7..d1b94efb6 100755 --- a/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java +++ b/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java @@ -47,6 +47,7 @@ import com.ibm.plugin.translation.translator.contexts.CSharpKeyContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpMacContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpPRNGContextTranslator; +import com.ibm.plugin.translation.translator.contexts.CSharpProtocolContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpSignatureContextTranslator; import java.util.List; import java.util.Optional; @@ -115,7 +116,8 @@ public Optional translate( } if (detectionValueContext.is(ProtocolContext.class)) { - return Optional.empty(); + return new CSharpProtocolContextTranslator() + .translate(bundleIdentifier, value, detectionValueContext, detectionLocation); } if (detectionValueContext.is(KeyDerivationFunctionContext.class)) { diff --git a/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java b/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java new file mode 100644 index 000000000..a4f7e106e --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java @@ -0,0 +1,184 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.translation.translator.contexts; + +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.IValue; +import com.ibm.engine.model.OperationMode; +import com.ibm.engine.model.ValueAction; +import com.ibm.engine.model.context.DetectionContext; +import com.ibm.engine.rule.IBundle; +import com.ibm.mapper.mapper.ssl.SSLVersionMapper; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.Protocol; +import com.ibm.mapper.model.protocol.TLS; +import com.ibm.mapper.utils.DetectionLocation; +import java.util.Optional; +import javax.annotation.Nonnull; + +/** + * Translates the protocol findings of the medical-imaging and health-exchange rule sets. + * + *

These rule sets report a standard rather than an algorithm. DICOM and FHIR name no + * cryptography of their own: what a bill of materials can learn from a call is which standard is + * spoken and whether the transport beneath it is protected. Both answers are worth having -- a + * DICOM association opened without TLS, or a FHIR client pointed at an {@code http://} endpoint, is + * exactly the call site a reader is looking for -- and neither is an algorithm. + * + *

A protocol with nothing beneath it is a complete CycloneDX component: only {@code assetType} + * and the protocol {@code type} are required, and the project's own {@code ProtocolTest} asserts + * that shape for a TLS carrying only a version. This follows the precedent set on the algorithm + * side by {@code NATIVEPRNG} and DPAPI, where the API does not expose the concrete algorithm and a + * generic node is recorded rather than an invented specific one. + */ +public final class CSharpProtocolContextTranslator { + + /** The value {@link com.ibm.engine.model.factory.BooleanFactory} gives a {@code true}. */ + private static final int TRUE = 1; + + public Optional translate( + @Nonnull IBundle bundleIdentifier, + @Nonnull IValue value, + @Nonnull DetectionContext detectionContext, + @Nonnull DetectionLocation detectionLocation) { + final String kind = detectionContext.get("kind").orElse(""); + + if (value instanceof ValueAction valueAction) { + // "DICOM" or "FHIR" for the call itself, "TLS" for an argument that can only mean TLS + // -- fo-dicom's ITlsInitiator overload exists for no other purpose. + return "TLS".equals(valueAction.asString()) + ? Optional.of(new TLS(detectionLocation)) + : Optional.of(new Protocol(valueAction.asString(), detectionLocation)); + } + + if (value instanceof com.ibm.engine.model.Protocol protocol) { + // The name of an enum member, read from a property assignment or a call argument -- + // plus the constant "TLS" of fo-dicom's ITlsInitiator overload, where an initiator + // exists for one purpose and its presence is the statement. + // + // Which enum it is has to be decided by the kind rather than by the name, because the + // names collide and mean opposite things: SslProtocols.None is "let the platform + // choose", which is TLS, while SecureSocketOptions.None is "no encryption at all". + return switch (kind) { + case "MAILKIT" -> secureSocketOptions(protocol.asString(), detectionLocation); + case "SSL_MODE" -> sslMode(protocol.asString(), detectionLocation); + default -> dotNetProtocol(protocol.asString(), detectionLocation); + }; + } + + if (value instanceof OperationMode operationMode && !kind.isEmpty()) { + // The useTls flag of fo-dicom's DicomClientFactory.Create. Only a true is recorded: a + // false means the connection carries no TLS, and the absence of a TLS node under the + // protocol already says that. There is no node for "explicitly not protected", and + // inventing one would read as a finding of its own. + return TRUE == operationMode.get() + ? Optional.of(new TLS(detectionLocation)) + : Optional.empty(); + } + + return Optional.empty(); + } + + /** + * The protocol a .NET name stands for, or nothing when the name is not one of them. + * + *

{@code SslProtocols} and {@code SecurityProtocolType} have a closed set of members, and + * that set is the guard. A property assignment reaches this point with whatever name its + * argument resolved to, and the argument is not always a member of the enum: RabbitMQ writes + * {@code EnabledSslProtocols = opts.Version}, which resolves to the name {@code Version} + * because that is what the member access is called. Passing such a name through would put a + * protocol called "Version" in the bill of materials. Neither the matcher nor the argument + * binder can rule it out -- the root of that access is a variable, not a type -- so the + * decision belongs here, where the name itself can be checked. + * + *

{@code None}, {@code Default} and {@code SystemDefault} mean "whatever the platform picks" + * and so state TLS without a version, which is exactly what is known. {@code Ssl2} and {@code + * Ssl3} get a protocol of their own rather than a version: SSL is not TLS, and a reader has to + * see what was actually offered. + */ + @Nonnull + private static Optional dotNetProtocol( + @Nonnull String name, @Nonnull DetectionLocation detectionLocation) { + return switch (name) { + case "Tls13" -> versioned("TLSv1.3", detectionLocation); + case "Tls12" -> versioned("TLSv1.2", detectionLocation); + case "Tls11" -> versioned("TLSv1.1", detectionLocation); + case "Tls" -> versioned("TLSv1.0", detectionLocation); + case "Ssl3" -> Optional.of(new Protocol("SSLv3", detectionLocation)); + case "Ssl2" -> Optional.of(new Protocol("SSLv2", detectionLocation)); + case "TLS", "None", "Default", "SystemDefault" -> + Optional.of(new TLS(detectionLocation)); + default -> Optional.empty(); + }; + } + + /** + * The member of MailKit's {@code SecureSocketOptions} that a name stands for, when it states + * TLS. + * + *

Two of the five members are a decision and three are not. {@code SslOnConnect} negotiates + * TLS before anything else is sent and {@code StartTls} requires the server to offer the + * STARTTLS extension, failing the connection when it does not -- both mean TLS happened. {@code + * Auto} and {@code StartTlsWhenAvailable} continue in plaintext against a server that offers + * nothing, so the call does not say whether any cryptography takes place, and {@code None} says + * that none does. CycloneDX has no component for "explicitly unprotected", and an unversioned + * TLS would claim more than the call supports, so those three report nothing. + * + *

No version is recorded for either member. MailKit takes it from the client's own {@code + * SslProtocols} property, which {@code DotNetTls} reads separately. + */ + @Nonnull + private static Optional secureSocketOptions( + @Nonnull String name, @Nonnull DetectionLocation detectionLocation) { + return switch (name) { + case "SslOnConnect", "StartTls" -> Optional.of(new TLS(detectionLocation)); + default -> Optional.empty(); + }; + } + + /** + * The member of a database driver's {@code SslMode} that a name stands for, when it requires + * TLS. + * + *

Npgsql and MySqlConnector share the property name and most of the members. {@code Require} + * and MySqlConnector's {@code Required} require TLS; {@code VerifyCA} and {@code VerifyFull} + * require it and validate the certificate as well, which is not separately representable and so + * is not recorded. {@code Prefer}, {@code Allow} and {@code Preferred} fall back to an + * unencrypted connection when the server declines, and {@code Disable} and {@code None} ask for + * one, so none of those five states that cryptography happens. + */ + @Nonnull + private static Optional sslMode( + @Nonnull String name, @Nonnull DetectionLocation detectionLocation) { + return switch (name) { + case "Require", "Required", "VerifyCA", "VerifyFull" -> + Optional.of(new TLS(detectionLocation)); + default -> Optional.empty(); + }; + } + + @Nonnull + private static Optional versioned( + @Nonnull String wireName, @Nonnull DetectionLocation detectionLocation) { + return new SSLVersionMapper() + .parse(wireName, detectionLocation) + .map(version -> (INode) new TLS(version)); + } +} diff --git a/csharp/src/test/files/rules/detection/dotnet/DotNetTlsTestFile.cs b/csharp/src/test/files/rules/detection/dotnet/DotNetTlsTestFile.cs new file mode 100644 index 000000000..e3ee368cf --- /dev/null +++ b/csharp/src/test/files/rules/detection/dotnet/DotNetTlsTestFile.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Security; +using System.Security.Authentication; +using FellowOakDicom.Network.Tls; + +/* + * Every shape below is taken from real code: MailKit, RabbitMQ's .NET client, Npgsql and + * fo-dicom. The counter-checks at the end are the forms that must stay silent. + */ +public class DotNetTlsUsage +{ + /* Npgsql: the options object an SslStream handshake is driven with. */ + public SslClientAuthenticationOptions NpgsqlOptions(string host) + { + return new SslClientAuthenticationOptions + { + TargetHost = host, + EnabledSslProtocols = SslProtocols.None + }; + } + + /* MailKit: a property on the client, written as a flags combination. */ + public void MailKitClientProperty(MailService client) + { + client.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13; + } + + /* A handler default, single member. */ + public void HandlerDefault(HttpClientHandler handler) + { + handler.DefaultSslProtocols = SslProtocols.Tls12; + } + + /* RabbitMQ: SslOption.Version — an ordinary word, so the enum type has to carry it. */ + public void RabbitSslOption(SslOption ssl) + { + ssl.Version = SslProtocols.Tls12; + } + + /* fo-dicom: the handler that a DICOM association is given. This is where the two rule sets meet. */ + public void DicomInitiatorProtocols() + { + var tlsInitiator = new DefaultTlsInitiator { Protocols = SslProtocols.Tls13 }; + var client = DicomClientFactory.Create("pacs.example.org", 2762, tlsInitiator, "SCU", "STORESCP"); + } + + /* The legacy global switch, still common in older services. */ + public void LegacyGlobalSwitch() + { + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; + } + + /* An obsolete version has to be visible as itself, not as a nameless TLS. */ + public void ObsoleteVersion(HttpClientHandler handler) + { + handler.SslProtocols = SslProtocols.Ssl3; + } + + /* The handshake, long overload: fo-dicom calls it exactly like this. */ + public void HandshakeWithVersions(SslStream ssl, string host) + { + ssl.AuthenticateAsClient(host, null, SslProtocols.Tls12, false); + } + + /* The handshake, options overload: MailKit calls it like this. The call is still TLS. */ + public void HandshakeWithOptions(SslStream ssl, SslClientAuthenticationOptions options) + { + ssl.AuthenticateAsClient(options); + } + + /* + * Counter-check, taken from RabbitMQ: the versions are read off another object. The member + * access resolves to the name "Version", which is not a member of the enum and must not + * become a protocol of that name. + */ + public SslClientAuthenticationOptions VersionsFromAnotherObject(SslOption opts) + { + return new SslClientAuthenticationOptions { EnabledSslProtocols = opts.Version }; + } + + /* Counter-check: an ordinary Version property holding something else must stay silent. */ + public void UnrelatedVersion(SomeConfig config) + { + config.Version = "1.0"; + } + + /* Counter-check: the versions come from a property, so none may be invented. */ + public void VersionsFromProperty(SslStream ssl, string host, SslProtocols configured) + { + ssl.AuthenticateAsClient(host, null, configured, false); + } + + /* ASP.NET Core's server-side TLS, in the three shapes Kestrel is configured with. */ + public void KestrelDefaults(ListenOptions listenOptions) + { + listenOptions.UseHttps(); + } + + public void KestrelWithCertificate(ListenOptions listenOptions, X509Certificate2 certificate) + { + listenOptions.UseHttps(certificate); + } + + /* Configured with a block-bodied lambda, which is how Kestrel is usually written. */ + public void KestrelPinnedVersion(ListenOptions listenOptions) + { + listenOptions.UseHttps(httpsOptions => + { + httpsOptions.SslProtocols = SslProtocols.Tls13; + }); + } + + /* And with an expression-bodied one. */ + public void KestrelPinnedVersionInline(ListenOptions listenOptions) + { + listenOptions.UseHttps(httpsOptions => httpsOptions.SslProtocols = SslProtocols.Tls12); + } +} diff --git a/csharp/src/test/files/rules/detection/fodicom/FoDicomTestFile.cs b/csharp/src/test/files/rules/detection/fodicom/FoDicomTestFile.cs new file mode 100644 index 000000000..fc4fe31cd --- /dev/null +++ b/csharp/src/test/files/rules/detection/fodicom/FoDicomTestFile.cs @@ -0,0 +1,186 @@ +using FellowOakDicom.Network; +using FellowOakDicom.Network.Client; +using FellowOakDicom.Network; +using FellowOakDicom.Network.Client.Advanced.Connection; +using Microsoft.Extensions.Logging; + +/* + * The shapes fo-dicom consumers actually write. Every line below is modelled on + * fo-dicom/fo-dicom-samples, where 75 of 77 Create calls state the TLS decision as a literal. + */ +/* A consumer's own handler, to check that the slot is not restricted to fo-dicom's classes. */ +public class MyTlsInitiator : ITlsInitiator +{ + public Stream InitiateTls(Stream plainStream, string remoteAddress, int remotePort) => plainStream; +} + +public class FoDicomUsage +{ + /* C-Store SCU from the samples: a plain association, no transport security. */ + public void PlainAssociation() + { + var client = DicomClientFactory.Create("127.0.0.1", 11112, false, "SCU", "STORESCP"); + } + + /* The same call asking for TLS. */ + public void TlsAssociation() + { + var client = DicomClientFactory.Create("pacs.example.org", 2762, true, "SCU", "STORESCP"); + } + + /* Host and AE titles from fields, the TLS decision still written out. */ + public void FieldsButLiteralTls(string host, int port, string callingAe, string calledAe) + { + var client = DicomClientFactory.Create(host, port, false, callingAe, calledAe); + } + + /* + * The sibling overload. A non-null initiator is the statement that TLS is on -- fo-dicom's own + * documentation says TLS is off exactly when it is null -- so this has to read as TLS and not + * as a bare DICOM association. + */ + public void InitiatorOverload(ITlsInitiator tlsInitiator) + { + var client = DicomClientFactory.Create("pacs.example.org", 2762, tlsInitiator, "SCU", "STORESCP"); + } + + /* The form fo-dicom's own tests write: the concrete class, held in a var. */ + public void ConcreteInitiator() + { + var tlsInitiator = new DefaultTlsInitiator(); + var client = DicomClientFactory.Create("pacs.example.org", 2762, tlsInitiator, "SCU", "STORESCP"); + } + + /* Constructed in the argument itself. */ + public void InlineInitiator() + { + var client = DicomClientFactory.Create("pacs.example.org", 2762, new DefaultTlsInitiator(), "SCU", "STORESCP"); + } + + /* An initiator a consumer wrote themselves still occupies the slot. */ + public void OwnInitiator(MyTlsInitiator tlsInitiator) + { + var client = DicomClientFactory.Create("pacs.example.org", 2762, tlsInitiator, "SCU", "STORESCP"); + } + + /* + * Counter-check: a written null means TLS is off. It must not read as TLS, and it must not + * read as TLS by way of the calling AE title sliding into the initiator slot either. + */ + public void NullInitiator() + { + var client = DicomClientFactory.Create("pacs.example.org", 2762, null, "SCU", "STORESCP"); + } + + /* Keyword arguments, written out of the declared order. */ + public void KeywordArguments() + { + var client = DicomClientFactory.Create( + calledAe: "STORESCP", callingAe: "SCU", useTls: true, port: 2762, host: "pacs.example.org"); + } + + /* + * The constructor behind the factory. No flag overload exis s, so the initiator is the only + * statement there is -- and it is called with all nine arguments. + */ + public void ConstructorWithInitiator(ITlsInitiator tlsInitiator, DicomClientOptions clientOptions, + DicomServiceOptions serviceOptions, ILoggerFactory loggerFactory, + IAdvancedDicomClientConnectionFactory advancedFactory) + { + var client = new DicomClient("pacs.example.org", 2762, tlsInitiator, "SCU", "STORESCP", + clientOptions, serviceOptions, loggerFactory, advancedFactory); + } + + /* The same constructor without transport security. */ + public void ConstructorWithoutTls(DicomClientOptions clientOptions, DicomServiceOptions serviceOptions, + ILoggerFactory loggerFactory, IAdvancedDicomClientConnectionFactory advancedFactory) + { + var client = new DicomClient("127.0.0.1", 11112, null, "SCU", "STORESCP", + clientOptions, serviceOptions, loggerFactory, advancedFactory); + } + + /* Counter-check: the decision comes from a method parameter, so none may be invented. */ + public void UnresolvableTls(bool useTls) + { + var client = DicomClientFactory.Create("127.0.0.1", 11112, useTls, "SCU", "STORESCP"); + } + + /* --- The listening side. ------------------------------------------------------------- */ + + /* A server on the plaintext DICOM port. Nothing accepts TLS, and nothing should claim it. */ + public void PlainServer() + { + var server = DicomServerFactory.Create(104); + } + + /* + * The port from a field. This is the shape that forbids demanding the port: it sits at index 0 + * in this overload and at index 1 in the other two, so a required port would throw the call + * away the moment it is not a literal -- and with it the DICOM association. + */ + public void PlainServerFromField(int port) + { + var server = DicomServerFactory.Create(port); + } + + /* + * The form fo-dicom's own TLS test writes, and the only TLS server call in fo-dicom and its + * sample repository: the acceptor as a keyword argument, past a keyword logger. + */ + public void TlsServerKeywordAcceptor(string ipAddress, int port, ITlsAcceptor tlsAcceptor, ILogger logger) + { + var server = DicomServerFactory.Create>( + ipAddress, port, logger: logger, tlsAcceptor: tlsAcceptor); + } + + /* + * Positional, in the overload that puts the acceptor straight after the port. The port refuses + * it because a primitive can never be an acceptor, and it is then the call's only argument of + * acceptor type, which is what makes it findable without guessing. + */ + public void TlsServerInlineAcceptor() + { + var server = DicomServerFactory.Create(2762, new DefaultTlsAcceptor("./cert.p12", "pw")); + } + + /* + * Known gap, kept here so it is visible: an injected factory held in a variable is NOT + * detected, and this call yields nothing at all -- not even the DICOM association. The C# + * receiver is matched by its literal text only, never by a variable's declared type + * (CSharpLanguageTranslation.getInvokedObjectTypeString says why: resolving declared types made + * instance calls match static-only rules as well). fo-dicom's ASP.NET hosting writes exactly + * this shape, so the servers it starts are invisible until receiver resolution grows a way to + * opt in. Widening this rule to ANY receiver is not that way -- "Create" is far too common a + * method name to claim a DICOM association from. + */ + public void TlsServerViaInterface(IDicomServerFactory factory, ITlsAcceptor tlsAcceptor) + { + var server = factory.Create(2762, tlsAcceptor); + } + + /* + * Counter-check: opaque user state, named. It must not be read as an acceptor -- an object and + * an interface are indistinguishable by type here, so only the keyword keeps them apart. + */ + public void ServerWithUserState(object counter, ILogger logger) + { + var server = DicomServerFactory.Create(104, logger: logger, userState: counter); + } + + /* + * The counter-check this rule's parameter order exists for: the third overload takes the user + * state positionally, exactly where the second takes the acceptor. Nothing here accepts TLS. + */ + public void ServerWithPositionalUserState(string ipAddress, int port, object state) + { + var server = DicomServerFactory.Create>( + ipAddress, port, state); + } + + /* Counter-check: a written null acceptor is the factory's own way of saying plaintext. */ + public void ServerWithNullAcceptor() + { + var server = DicomServerFactory.Create(104, null); + } + +} diff --git a/csharp/src/test/files/rules/detection/mailkit/MailKitTestFile.cs b/csharp/src/test/files/rules/detection/mailkit/MailKitTestFile.cs new file mode 100644 index 000000000..ab253da6f --- /dev/null +++ b/csharp/src/test/files/rules/detection/mailkit/MailKitTestFile.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Sockets; +using MailKit; +using MailKit.Net.Imap; +using MailKit.Net.Smtp; +using MailKit.Security; + +/* + * The shapes MailKit consumers write. SecureSocketOptions appears 848 times in MailKit's own tree + * alone, and in code that merely uses MailKit it is the only TLS statement there is. + */ +public class MailKitUsage +{ + /* Implicit TLS on the IMAPS port: TLS before anything is sent. */ + public void ImplicitTls(ImapClient client) + { + client.Connect("imap.example.org", 993, SecureSocketOptions.SslOnConnect); + } + + /* STARTTLS on the submission port, required: MailKit fails rather than continue plaintext. */ + public void RequiredStartTls(SmtpClient client) + { + client.ConnectAsync("smtp.example.org", 587, SecureSocketOptions.StartTls); + } + + /* The socket-taking overload: the options sit one position later and still have to be found. */ + public void OverSocket(ImapClient client, Socket socket) + { + client.Connect(socket, "imap.example.org", 993, SecureSocketOptions.SslOnConnect); + } + + /* Keyword arguments, written out of order. */ + public void KeywordArguments(ImapClient client) + { + client.Connect(options: SecureSocketOptions.SslOnConnect, port: 993, host: "imap.example.org"); + } + + /* Counter-check: no encryption at all. There is no component for that, and TLS would be a lie. */ + public void NoEncryption(ImapClient client) + { + client.Connect("imap.example.org", 143, SecureSocketOptions.None); + } + + /* + * Counter-check: Auto and StartTlsWhenAvailable both continue in plaintext against a server + * that offers nothing, so the call does not state that any cryptography happens. + */ + public void Opportunistic(ImapClient client) + { + client.Connect("imap.example.org", 143, SecureSocketOptions.Auto); + client.Connect("imap.example.org", 143, SecureSocketOptions.StartTlsWhenAvailable); + } + + /* Counter-check: the decision arrives as a parameter, so none may be invented. */ + public void FromParameter(ImapClient client, SecureSocketOptions configured) + { + client.Connect("imap.example.org", 993, configured); + } + + /* Counter-check: an ordinary socket connect. Connect is matched on any receiver. */ + public void UnrelatedConnect(Socket socket, EndPoint endPoint) + { + socket.Connect(endPoint); + } + + /* + * Counter-check: a bool third argument. MailKit's own obsolete overload writes useSsl there, + * but so does any other Connect in the world, so it is deliberately not read. + */ + public void UnrelatedBoolean(ImapClient client) + { + client.Connect("imap.example.org", 993, true); + } +} diff --git a/csharp/src/test/files/rules/detection/npgsql/NpgsqlTestFile.cs b/csharp/src/test/files/rules/detection/npgsql/NpgsqlTestFile.cs new file mode 100644 index 000000000..d7a239aa6 --- /dev/null +++ b/csharp/src/test/files/rules/detection/npgsql/NpgsqlTestFile.cs @@ -0,0 +1,74 @@ +using Npgsql; +using MySqlConnector; + +/* + * How .NET database drivers state transport security. Npgsql's own tree writes + * "SslMode = SslMode.Require" twelve times; code that merely uses the driver writes nothing else. + */ +public class NpgsqlUsage +{ + /* TLS required, certificate not validated. */ + public void Required(NpgsqlConnectionStringBuilder builder) + { + builder.SslMode = SslMode.Require; + } + + /* An object initializer, which becomes the same property setter. */ + public NpgsqlConnectionStringBuilder VerifiedFully() + { + return new NpgsqlConnectionStringBuilder { SslMode = SslMode.VerifyFull }; + } + + /* Required plus chain validation. The validation itself has nowhere to go in the CBOM. */ + public void VerifiedCa(NpgsqlConnectionStringBuilder builder) + { + builder.SslMode = SslMode.VerifyCA; + } + + /* MySqlConnector spells the same decision "Required". */ + public void MySqlRequired(MySqlConnectionStringBuilder builder) + { + builder.SslMode = SslMode.Required; + } + + /* + * Counter-check: Prefer, Allow and Preferred all fall back to an unencrypted connection when + * the server declines, so none of them states that cryptography happens. + */ + public void Opportunistic(NpgsqlConnectionStringBuilder builder) + { + builder.SslMode = SslMode.Prefer; + builder.SslMode = SslMode.Allow; + } + + /* Counter-check: TLS switched off. */ + public void Disabled(NpgsqlConnectionStringBuilder builder) + { + builder.SslMode = SslMode.Disable; + } + + /* Counter-check: the mode arrives as a parameter, so none may be invented. */ + public void FromParameter(NpgsqlConnectionStringBuilder builder, SslMode configured) + { + builder.SslMode = configured; + } + + /* + * Counter-check: an unrelated class with a property of the same name holding a string. The + * declared type is what refuses it -- a string can never be an SslMode. + */ + public void UnrelatedProperty(SomeConfig config) + { + config.SslMode = "Require"; + } + + /* + * Counter-check: the same setting inside a connection string. A connection string is an opaque + * literal, and picking settings out of one by substring would report any string that mentions + * them. + */ + public NpgsqlConnection FromConnectionString() + { + return new NpgsqlConnection("Host=db.example.org;Username=app;SslMode=Require"); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetTlsTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetTlsTest.java new file mode 100644 index 000000000..4b6db0da2 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetTlsTest.java @@ -0,0 +1,109 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dotnet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** The TLS versions a .NET service states, in the shapes real code writes them. */ +class DotNetTlsTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/dotnet/DotNetTlsTestFile.cs", this); + assertThat(found) + .as("every TLS version a .NET service states, and nothing it does not") + .containsExactly( + // SslProtocols.None lets the platform choose, so TLS without a version is + // the whole of what is known. + "Zeile 18 TLS=TLS", + // A flags combination is a set: both versions are offered, not a number. + "Zeile 25 TLS=TLSv1.2[Version=1.2]", + "Zeile 25 TLS=TLSv1.3[Version=1.3]", + "Zeile 31 TLS=TLSv1.2[Version=1.2]", + "Zeile 37 TLS=TLSv1.2[Version=1.2]", + // fo-dicom's handler, and the association it is handed to on the next + // line. The two rule sets meet here. + "Zeile 43 TLS=TLSv1.3[Version=1.3]", + "Zeile 44 Protocol=DICOM[TLS=TLS]", + "Zeile 50 TLS=TLSv1.2[Version=1.2]", + // SSL 3.0 is not TLS, so it keeps its own name instead of becoming a + // nameless TLS -- a reader has to see what was actually offered. + "Zeile 56 Protocol=SSLv3", + "Zeile 62 TLS=TLSv1.2[Version=1.2]", + // The options overload names no version at the call, so none is claimed. + "Zeile 68 TLS=TLS", + // Two counter-checks come before the last entry and must not appear at + // all: `EnabledSslProtocols = opts.Version` reads the versions off another + // object and resolves to the name "Version", and `config.Version = "1.0"` + // is not a protocol in the first place. + "Zeile 90 TLS=TLS", + // ASP.NET Core's server-side TLS. Every UseHttps overload means the same + // thing and names no version, so the call reports TLS and nothing more. + "Zeile 96 TLS=TLS", + "Zeile 101 TLS=TLS", + // A configuration callback: the version assigned inside the lambda is + // reported before the UseHttps that carries it, in both lambda forms. The + // expression-bodied one used to produce nothing at all -- an assignment of + // a constant contains no call site to fall back on. + "Zeile 109 TLS=TLSv1.3[Version=1.3]", + "Zeile 107 TLS=TLS", + "Zeile 116 TLS=TLSv1.2[Version=1.2]", + "Zeile 116 TLS=TLS"); + } + + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream().map(DotNetTlsTest::render).sorted().toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach( + node -> + found.add( + (node instanceof com.ibm.mapper.model.Protocol p + ? "Zeile " + p.getDetectionContext().lineNumber() + : "Zeile ?") + + " " + + render(node))); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/fodicom/FoDicomTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/fodicom/FoDicomTest.java new file mode 100644 index 000000000..54cc470d8 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/fodicom/FoDicomTest.java @@ -0,0 +1,99 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.fodicom; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.protocol.TLS; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * A DICOM association is reported as the protocol it is, with TLS beneath it only where the call + * says so. + * + *

The fixture is modelled on fo-dicom's sample repository, where the transport decision is + * written as a literal in 75 of 77 calls. Both overloads of {@code Create} state it: the flag + * directly, and the {@code ITlsInitiator} by being there at all, since fo-dicom's documentation + * says TLS is off exactly when the initiator is null. + * + *

Two counter-checks guard the slot from either side. A written {@code null} must not read as + * TLS, and a decision that arrives as a method parameter is not knowable, so that connection is + * reported as DICOM with nothing beneath rather than as either secured or unsecured. The flag cases + * also prove the calling AE title never slides into the initiator slot: were it to, every unsecured + * association in the fixture would read as TLS. + */ +class FoDicomTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/fodicom/FoDicomTestFile.cs", this); + assertThat(found) + .as("one protocol per association, TLS only where the call states it") + .containsExactly( + "DICOM", // Create(..., false, ...) + "DICOM+TLS", // Create(..., true, ...) + "DICOM", // host and AE titles from fields, useTls still false + "DICOM+TLS", // the ITlsInitiator overload + "DICOM+TLS", // a DefaultTlsInitiator held in a var + "DICOM+TLS", // a DefaultTlsInitiator constructed in the argument + "DICOM+TLS", // a consumer's own ITlsInitiator implementation + "DICOM", // a written null: fo-dicom reads that as TLS off + "DICOM+TLS", // keyword arguments out of order + "DICOM+TLS", // new DicomClient(...) with an initiator + "DICOM", // new DicomClient(...) with a written null + "DICOM", // useTls is a method parameter: nothing invented + // The listening side. + "DICOM", // a server on the plaintext port + "DICOM", // the port from a field, so no parameter may be demanded + "DICOM+TLS", // tlsAcceptor: as a keyword, past a keyword logger + "DICOM+TLS", // a DefaultTlsAcceptor built in the argument + // No entry for the injected-factory call: a receiver held in a variable is + // matched by its text, not its declared type, so that call site yields + // nothing. The fixture says why. + "DICOM", // named user state is not an acceptor + "DICOM", // positional user state where a sibling overload puts the acceptor + "DICOM"); // a written null acceptor means plaintext + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + assertThat(nodes).hasSize(1); + final INode node = nodes.get(0); + found.add(node.asString() + (node.hasChildOfType(TLS.class).isPresent() ? "+TLS" : "")); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/mailkit/MailKitTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/mailkit/MailKitTest.java new file mode 100644 index 000000000..ece2b180d --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/mailkit/MailKitTest.java @@ -0,0 +1,74 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.mailkit; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * MailKit states TLS with a {@code SecureSocketOptions} argument, and in code that uses the library + * that is the only place it is written. + * + *

Only the two members that guarantee TLS are reported. The counter-checks are the greater part + * of this fixture on purpose: the rule matches {@code Connect} on any receiver, because the client + * is held in a variable, so what keeps it quiet is that nothing but a member of that enum can + * produce a value. An opportunistic member, a decision taken from a parameter, an unrelated socket + * connect and a {@code bool} written in the same position all have to stay silent. + */ +class MailKitTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/mailkit/MailKitTestFile.cs", this); + assertThat(found) + .as("TLS only where the options guarantee it") + .containsExactly( + "TLS", // SslOnConnect + "TLS", // StartTls + "TLS", // the socket-taking overload: the options sit one position later + "TLS"); // keyword arguments out of order + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + // A detection whose value the translator declines -- an opportunistic or disabled + // option -- arrives here with no nodes at all, and contributes nothing. + nodes.forEach(node -> found.add(node.asString())); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/npgsql/NpgsqlTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/npgsql/NpgsqlTest.java new file mode 100644 index 000000000..f66aa11ef --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/npgsql/NpgsqlTest.java @@ -0,0 +1,73 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.npgsql; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * Whether a database connection requires TLS, read from the driver's {@code SslMode} property. + * + *

Three members require it and are reported; the opportunistic ones and the disabled one are + * not, because they do not state that any cryptography happens. The last two counter-checks guard + * the two ways this could go wrong: a property of the same name holding a string, which the + * declared type refuses, and the same setting written inside a connection string, which is + * deliberately not read out of the literal. + */ +class NpgsqlTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/npgsql/NpgsqlTestFile.cs", this); + assertThat(found) + .as("TLS only where the mode requires it") + .containsExactly( + "TLS", // SslMode.Require + "TLS", // SslMode.VerifyFull, written as an object initializer + "TLS", // SslMode.VerifyCA + "TLS"); // MySqlConnector's SslMode.Required + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + // A detection whose value the translator declines -- an opportunistic or disabled + // option -- arrives here with no nodes at all, and contributes nothing. + nodes.forEach(node -> found.add(node.asString())); + } +} diff --git a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java index ede833a19..b2bbd2ca3 100755 --- a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java +++ b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java @@ -44,6 +44,7 @@ import com.ibm.engine.rule.DetectionRule; import com.ibm.engine.rule.MethodDetectionRule; import com.ibm.engine.rule.Parameter; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.IdentityHashMap; @@ -295,6 +296,19 @@ private void processParameter( List> resolved = resolveValuesInInnerScope( Object.class, expression, detectable.getiValueFactory()); + if (resolved.isEmpty() + && detectable.getiValueFactory().isPresenceBased() + && !(expression instanceof CSharpLiteralTree)) { + // The parameter says something by being there rather than by its value, so the + // factory is called once for the bound expression. A literal is excluded: the only + // literal that reaches such a parameter is `null`, and for fo-dicom's TLS handlers + // null is precisely the argument that means the opposite. + final ResolvedValue presence = + new ResolvedValue<>(expression, expression); + detectionStore.onReceivingNewDetection( + new ValueDetection<>(presence, detectable, parentTree, parentTree)); + return; + } if (resolved.isEmpty()) { resolveValuesInOuterScope(expression, detectable); // A parameter that declares both a value factory and depending rules falls back to @@ -393,7 +407,7 @@ private List> resolveValues( } if (tree instanceof CSharpBinaryExpressionTree binary) { - return resolveBinaryExpression(clazz, binary, resolving); + return resolveBinaryExpression(clazz, binary, valueFactory, resolving); } if (tree instanceof CSharpMethodInvocationTree invocation @@ -511,7 +525,20 @@ private List> resolveArrayCreationSize( private List> resolveBinaryExpression( @Nonnull Class clazz, @Nonnull CSharpBinaryExpressionTree binary, + @Nullable IValueFactory valueFactory, @Nonnull Set resolving) { + if ("|".equals(binary.getOperator())) { + // A flags enum written as `A | B` is a set, not a sum: `SslProtocols.Tls12 | + // SslProtocols.Tls13` offers both versions and has to arrive as both values. The + // arithmetic below cannot say that -- it folds two numbers into one -- and `|` was + // never among the operators it folds, so nothing that resolved before changes. + final List> members = new ArrayList<>(); + members.addAll(resolveValues(clazz, binary.getLeft(), valueFactory, resolving)); + members.addAll(resolveValues(clazz, binary.getRight(), valueFactory, resolving)); + if (!members.isEmpty()) { + return members; + } + } Optional left = resolveIntOperand(binary.getLeft(), resolving); Optional right = resolveIntOperand(binary.getRight(), resolving); if (left.isEmpty() || right.isEmpty()) { diff --git a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java index da49824ed..1be88f342 100644 --- a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java +++ b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java @@ -1167,6 +1167,22 @@ private static boolean isStatementWorthy(@Nullable CSharpTree tree) { return null; // the lambda itself never contributes a value (see class javadoc) } + if (unwrapped instanceof CSharpParser.Inclusive_or_expressionContext or + && or.exclusive_or_expression().size() >= 2) { + // A flags enum written as `A | B`, which is how .NET states a set of TLS versions: + // `SslProtocols.Tls12 | SslProtocols.Tls13`. Folded right to left so a longer chain + // keeps every member; the engine resolves such an expression to all of its members + // rather than to one number, because that is what a flags value means. + CSharpTree combined = + convertArithmeticOrPrimary( + or.exclusive_or_expression(or.exclusive_or_expression().size() - 1), + scope); + for (int i = or.exclusive_or_expression().size() - 2; i >= 0; i--) { + CSharpTree left = convertArithmeticOrPrimary(or.exclusive_or_expression(i), scope); + combined = combineBinary(or, left, "|", combined, scope); + } + return combined; + } if (unwrapped instanceof CSharpParser.Primary_expressionContext primary) { if (isCollectionExpression(primary)) { // The grammar makes a collection expression a primary expression, so it never @@ -1213,9 +1229,10 @@ private static ParseTree unwrapSingleChildChain(@Nonnull ParseTree node) { /** * Recognizes a flat two-operand {@code +}/{@code -}/{@code *}/{@code /}/{@code %} expression - * (e.g. {@code 256 / 8}); anything more complex (three-or-more-term chains, other operators) - * falls through to a best-effort search for a single primary expression anywhere inside, same - * as the pre-refactor fallback behaviour. + * (e.g. {@code 256 / 8}) and a {@code |} chain, which is how a flags enum is written; anything + * more complex (three-or-more-term arithmetic chains, other operators) falls through to a + * best-effort search for a single primary expression anywhere inside, same as the pre-refactor + * fallback behaviour. */ @Nullable private CSharpTree convertArithmeticOrPrimary( @Nonnull ParseTree node, @Nonnull CSharpScope scope) { @@ -1297,12 +1314,48 @@ private void processLambdaBody( return; } if (body.throwable_expression() != null) { + // An expression-bodied lambda whose body is an assignment is still a body that + // assigns: `options => options.SslProtocols = SslProtocols.Tls13` sets a property, + // which is how a one-line Kestrel or HttpClient configuration callback is written. + // The synthetic `set_` invocation only exists when the assignment is + // visited as a statement, so it is collected like one instead of being reduced to + // the call sites it contains -- an assignment of a constant contains none at all, + // which is why this used to produce nothing. + CSharpParser.ExpressionContext expression = body.throwable_expression().expression(); + if (expression != null + && unwrapSingleChildChain(expression) + instanceof CSharpParser.AssignmentContext assignment) { + collectAsBody(assignment, bodyScope); + return; + } emitCallSitesWithoutValue(body.throwable_expression(), bodyScope); } else { emitCallSitesWithoutValue(body.non_assignment_expression(), bodyScope); } } + /** + * Visits one parse tree as if it were the whole of a body, so that the statement-level rewrites + * -- the synthetic property setters above all -- apply to it. + */ + private void collectAsBody( + @Nonnull org.antlr.v4.runtime.ParserRuleContext context, @Nonnull CSharpScope scope) { + StatementCollector collector = new StatementCollector(scope); + List previousSink = chainCallSink; + chainCallSink = collector.mutableStatements(); + try { + collector.visit(context); + } finally { + chainCallSink = previousSink; + } + bodies.add( + new CSharpBlockTree( + context.getStart().getLine(), + context.getStart().getCharPositionInLine(), + collector.getStatements(), + scope)); + } + /** * Whether a primary expression is a collection expression ({@code [a, b]}, {@code [.. a, .. * b]}), which the grammar models as a primary expression although it has no single value. @@ -1754,7 +1807,13 @@ private void walkInitializerOfUntypedCreation( ? objCreationCtx.object_or_collection_initializer() : (objExpr != null ? objExpr.object_or_collection_initializer() : null); if (initializer != null) { - emitObjectInitializerSetters(assignedIdentifier, initializer, scope); + // Without a variable the type itself is the receiver. `new + // SslClientAuthenticationOptions + // { EnabledSslProtocols = SslProtocols.None }` handed straight to a handshake is the + // normal way these options are written -- Npgsql does it exactly so -- and dropping + // the setters there would lose the only place the versions are named. + emitObjectInitializerSetters( + assignedIdentifier != null ? assignedIdentifier : typeName, initializer, scope); } return creation; @@ -1767,9 +1826,9 @@ private void walkInitializerOfUntypedCreation( *

Only simple {@code Property = value} entries are emitted. A nested initializer ({@code * Inner = { ... }}) or an indexed entry ({@code [key] = value}) carries no property name that a * rule could match, and a collection initializer has no property names at all, so both are - * skipped. Nothing is emitted when the creation is not assigned to a variable (e.g. it is - * passed straight into another call), since there would be no receiver name to attach the - * setters to. + * skipped. A creation that is not assigned to a variable -- passed straight into another call, + * or returned -- uses its own type name as the receiver, which is what a rule matching on the + * type would expect anyway. */ private void emitObjectInitializerSetters( @Nullable String receiver, diff --git a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTypeInference.java b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTypeInference.java index 714e58db2..48290d206 100644 --- a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTypeInference.java +++ b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTypeInference.java @@ -77,6 +77,36 @@ final class CSharpTypeInference { private static final Set KNOWN_NAMED_TYPES = Set.of( "HashAlgorithmName", + // fo-dicom's TLS handlers. They are named here so that a string argument + // cannot bind to the tlsInitiator parameter of + // DicomClientFactory.Create(host, port, ITlsInitiator, callingAe, calledAe): + // its sibling overload puts a bool in that position and the two AE titles + // after it, so without this the first AE title binds to the initiator and the + // connection reads as TLS when it is not. + // The concrete classes are named beside the interfaces because the + // overload is told apart by position: the bool of the sibling overload sits + // where the initiator does, and only a name in this set makes the binder + // refuse a DefaultTlsInitiator for a bool. A consumer's own implementation + // need not be listed -- it binds at the initiator's own position, and the + // only thing this set has to rule out is the bool going there instead. + "ITlsInitiator", + "ITlsAcceptor", + "DefaultTlsInitiator", + "DefaultTlsAcceptor", + // .NET's TLS version enums. Named here so that the strict side of the type + // check can refuse a string or a number for a parameter declared as one: + // properties called Protocols or Version exist on all sorts of classes, and + // without this a `config.Version = "1.0"` would be reported as a protocol. + "SslProtocols", + "SecurityProtocolType", + // The library-level transport-security switches. MailKit decides TLS with a + // SecureSocketOptions argument to Connect and the database drivers with an + // SslMode property, and both rules match on an ANY receiver because the client + // is held in a variable. Naming the types here is what makes the binder refuse + // the bool of MailKit's obsolete Connect overload, and refuse a string for the + // SslMode of some unrelated class. + "SecureSocketOptions", + "SslMode", "ECCurve", "ECParameters", "RSAParameters", @@ -111,6 +141,29 @@ final class CSharpTypeInference { "ECDsa", "ECDiffieHellman"); + /** + * The concrete classes whose interface is known, for the one place that needs a + * positive answer about a non-primitive type. + * + *

{@link #isDefinitelyAssignable} otherwise only ever says yes to a type name equal to the + * declared one or to a primitive of the same category, because there is no semantic model to + * ask about subtyping. That is enough wherever a parameter binds at its own position, but not + * where {@link CSharpNamedArgumentBinder} has to search the call for the argument that supplies + * a parameter -- and the fo-dicom server factory is exactly that case. Its three {@code Create} + * overloads put {@code ITlsAcceptor} at three different positions, so the acceptor can only be + * found by type, and the argument written there is a {@code DefaultTlsAcceptor}. + * + *

Only relations that hold by declaration in the library belong here, and only for + * interfaces a rule declares as a parameter type, so this can widen the search for nothing + * else. It cannot cause a wrong binding: the search still requires the match to be unique among + * the call's arguments. + */ + @Nonnull + private static final Map KNOWN_IMPLEMENTATIONS = + Map.of( + "DefaultTlsInitiator", "ITlsInitiator", + "DefaultTlsAcceptor", "ITlsAcceptor"); + private CSharpTypeInference() { // utility } @@ -169,6 +222,9 @@ static boolean isDefinitelyAssignable(@Nonnull String actual, @Nonnull String ex if (a.equals(e)) { return true; } + if (e.equals(KNOWN_IMPLEMENTATIONS.get(a))) { + return true; + } return sameCategory(a, e); } @@ -210,7 +266,14 @@ private static boolean sameCategory(@Nonnull String a, @Nonnull String e) { if (tree instanceof CSharpMemberAccessTree memberAccess) { return memberAccess.getRootType(); } - if (tree instanceof CSharpBinaryExpressionTree) { + if (tree instanceof CSharpBinaryExpressionTree binary) { + if ("|".equals(binary.getOperator())) { + // A flags combination has the type of its members, not of the number behind them: + // `SslProtocols.Tls12 | SslProtocols.Tls13` is an SslProtocols. Reporting int here + // would make the strict check refuse the very form these enums are written in. + String left = infer(binary.getLeft()); + return left != null ? left : infer(binary.getRight()); + } return "int"; // only integer arithmetic is folded today } if (tree instanceof CSharpIdentifierTree identifier) { diff --git a/engine/src/main/java/com/ibm/engine/model/factory/IValueFactory.java b/engine/src/main/java/com/ibm/engine/model/factory/IValueFactory.java index fb12718e9..99d901f3d 100644 --- a/engine/src/main/java/com/ibm/engine/model/factory/IValueFactory.java +++ b/engine/src/main/java/com/ibm/engine/model/factory/IValueFactory.java @@ -24,4 +24,26 @@ import java.util.Optional; import java.util.function.Function; -public interface IValueFactory extends Function, Optional>> {} +public interface IValueFactory extends Function, Optional>> { + + /** + * Whether this factory states something by the argument merely being there, without reading its + * value. + * + *

Almost every factory answers no: it turns a resolved constant into a value, and an + * argument whose value cannot be resolved has to stay unreported, because inventing one is the + * failure mode this engine is built to avoid. A few APIs put the statement in the parameter + * itself -- fo-dicom's {@code DicomClientFactory.Create(host, port, ITlsInitiator, ...)} + * documents TLS as off exactly when the initiator is null, so an initiator in that slot is the + * statement whatever object it happens to be. Such a factory ignores its input and returns a + * constant, and the engine then calls it once for the expression that was bound, instead of + * never calling it at all. + * + *

A factory may only answer yes when its answer does not depend on the input, and only where + * the parameter is typed tightly enough that nothing else can bind there: the argument binder's + * type check is all that stands between this and a value attached to the wrong parameter. + */ + default boolean isPresenceBased() { + return false; + } +} diff --git a/engine/src/main/java/com/ibm/engine/model/factory/ProtocolFactory.java b/engine/src/main/java/com/ibm/engine/model/factory/ProtocolFactory.java index 4e3c78b0b..ec1a5b6de 100644 --- a/engine/src/main/java/com/ibm/engine/model/factory/ProtocolFactory.java +++ b/engine/src/main/java/com/ibm/engine/model/factory/ProtocolFactory.java @@ -38,6 +38,16 @@ public ProtocolFactory(@Nonnull String constant) { this.constant = constant; } + /** + * A constant protocol says the same thing for every input, so it can be stated from the + * argument's presence alone. Without a constant the factory reads the resolved string and has + * nothing to say about an argument it cannot resolve. + */ + @Override + public boolean isPresenceBased() { + return constant != null; + } + @Override @Nonnull public Optional> apply(ResolvedValue objectTResolvedValue) { diff --git a/java/pom.xml b/java/pom.xml index 404423534..f8c4af10b 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -15,6 +15,11 @@ 17 17 UTF-8 + + 5.35.2 + https://maven.dcm4che.org @@ -67,4 +72,46 @@ test + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.2.0 + + + fetch-dcm4che-test-jars + generate-test-resources + + run + + + + + + + + + + + + + \ No newline at end of file diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/JavaDetectionRules.java b/java/src/main/java/com/ibm/plugin/rules/detection/JavaDetectionRules.java index e7a33717a..414736a03 100644 --- a/java/src/main/java/com/ibm/plugin/rules/detection/JavaDetectionRules.java +++ b/java/src/main/java/com/ibm/plugin/rules/detection/JavaDetectionRules.java @@ -23,6 +23,8 @@ import com.ibm.engine.rule.IDetectionRule; import com.ibm.engine.rule.RuleSets; import com.ibm.plugin.rules.detection.bc.BouncyCastleDetectionRules; +import com.ibm.plugin.rules.detection.dcm4che.Dcm4cheConnectionTls; +import com.ibm.plugin.rules.detection.dcm4che.Dcm4cheDicom; import com.ibm.plugin.rules.detection.jca.JcaDetectionRules; import com.ibm.plugin.rules.detection.ssl.SSLDetectionRules; import java.util.List; @@ -38,7 +40,9 @@ protected List> buildRules() { return Stream.of( RuleSets.rulesOf(JcaDetectionRules.class).stream(), RuleSets.rulesOf(BouncyCastleDetectionRules.class).stream(), - RuleSets.rulesOf(SSLDetectionRules.class).stream()) + RuleSets.rulesOf(SSLDetectionRules.class).stream(), + RuleSets.rulesOf(Dcm4cheConnectionTls.class).stream(), + RuleSets.rulesOf(Dcm4cheDicom.class).stream()) .flatMap(i -> i) .toList(); } diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTls.java b/java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTls.java new file mode 100644 index 000000000..d110ec736 --- /dev/null +++ b/java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTls.java @@ -0,0 +1,121 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dcm4che; + +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.CipherSuiteFactory; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * The TLS settings of a dcm4che {@code Connection}. + * + *

These are the calls that carry cryptography: {@code setTlsCipherSuites} names the suites a + * DICOM association will offer and {@code setTlsProtocols} the versions it will accept. Both are + * read exactly as the SSL rule set reads {@code SSLSocket.setEnabledCipherSuites} and {@code + * SSLParameters.setProtocols}, so a suite resolves through the same table and arrives as the + * algorithms it is made of. + * + *

The suites that appear are worth the rules on their own. {@code Connection} offers constants + * for three, and the two that dcm4che's own tools pass as literals are {@code + * SSL_RSA_WITH_NULL_SHA}, which encrypts nothing, and {@code SSL_RSA_WITH_3DES_EDE_CBC_SHA}. A + * medical-imaging product that offers those is exactly what a bill of materials is read for. + * + *

Both setters are varargs, and roughly a fifth of the calls in dcm4che pass a literal; the rest + * hand over a command-line option or copy another connection's settings, and nothing is reported + * for those. + */ +@SuppressWarnings("java:S1192") +public final class Dcm4cheConnectionTls extends DetectionRuleSet { + + private static final String CONNECTION = "org.dcm4che3.net.Connection"; + private static final String STRING = "java.lang.String"; + private static final String STRING_ARRAY = "java.lang.String[]"; + private static final String TLS = "TLS"; + + /** + * One rule per argument count, because both setters are varargs. + * + *

The Java frontend matches a rule against the types of the arguments actually written at + * the call site, not against the declared signature, so {@code setTlsCipherSuites("A", "B")} + * presents two {@code String}s and never a {@code String[]}. That is the form real code uses: + * dcm4che's own tools pass bare literals, and only a copy from another connection arrives as an + * array. Declaring the array alone matched the array form and missed every literal -- including + * the two suites the tools name, which are the null cipher and 3DES. + * + *

Counts beyond three are not covered. Nothing in dcm4che passes more, and a rule per count + * cannot be open-ended; a longer list is simply not reported. + */ + @Nonnull + private static IDetectionRule cipherSuites(int count) { + var rule = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(CONNECTION) + .forMethods("setTlsCipherSuites") + .withMethodParameter(count == 0 ? STRING_ARRAY : STRING) + .shouldBeDetectedAs(new CipherSuiteFactory<>()); + for (int i = 1; i < count; i++) { + rule = rule.withMethodParameter(STRING).shouldBeDetectedAs(new CipherSuiteFactory<>()); + } + return rule.buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> "Dcm4che") + .withoutDependingDetectionRules(); + } + + @Nonnull + private static IDetectionRule protocols(int count) { + var rule = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(CONNECTION) + .forMethods("setTlsProtocols") + .withMethodParameter(count == 0 ? STRING_ARRAY : STRING) + .shouldBeDetectedAs(new ProtocolFactory<>()); + for (int i = 1; i < count; i++) { + rule = rule.withMethodParameter(STRING).shouldBeDetectedAs(new ProtocolFactory<>()); + } + return rule.buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> "Dcm4che") + .withoutDependingDetectionRules(); + } + + @Nonnull + @Override + protected List> buildRules() { + return List.of( + cipherSuites(0), + cipherSuites(1), + cipherSuites(2), + cipherSuites(3), + protocols(0), + protocols(1), + protocols(2), + protocols(3), + protocols(4), + protocols(5)); + } +} diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicom.java b/java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicom.java new file mode 100644 index 000000000..e9aa2715e --- /dev/null +++ b/java/src/main/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicom.java @@ -0,0 +1,155 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dcm4che; + +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * The protocol a dcm4che connection speaks. + * + *

{@link Dcm4cheConnectionTls} reads the TLS settings of a {@code Connection} -- its cipher + * suites and its versions -- but says nothing about what runs on top of them, so a dcm4che bill of + * materials used to read "TLS with these suites" and never name the protocol. That is half a + * statement for a medical-imaging product, and the C# side does not have the problem: fo-dicom's + * factories let {@code FoDicom} emit a {@code DICOM} component directly. These rules close the gap + * on the Java side. + * + *

dcm4che offers no factory to key on. It models the DICOM network stack as objects -- {@code + * Device} holds {@code Connection}s and {@code ApplicationEntity}s, and an association is opened + * from an AE -- so the statement has to come from the objects themselves. Three of them say it + * without a guess: + * + *

    + *
  • {@code new ApplicationEntity(aet)}. An Application Entity Title is a DICOM concept and + * nothing else; the class exists only to be one. It is created by both sides, so this is the + * one anchor that covers a pure SCP as well as an SCU, neither of which need call anything + * else on this list. + *
  • {@code ae.connect(...)}. The call that opens the association, and the closest dcm4che has + * to fo-dicom's {@code DicomClientFactory.Create}. All three overloads are read by one rule + * keyed on the receiver type, so no overload can be counted twice. + *
  • {@code connection.setProtocol(...)}. The only call that can say something other than DICOM, + * and the reason it is read: {@code Connection.Protocol} is a closed set of six members, and + * {@code HL7}, {@code HL7_MLLP2}, {@code SYSLOG_TLS} and {@code SYSLOG_UDP} are protocols a + * bill of materials should name as themselves rather than fold into DICOM. {@code SYSLOG_TLS} + * additionally states transport security, so it is reported with TLS beneath it. + *
+ * + *

{@code Device.bindConnections()} is deliberately not read, although it is what starts a + * listener. A {@code Device} may hold DICOM, HL7 and syslog connections at once, so binding them + * says that something starts listening and not which protocol -- and the connections it binds are + * each already covered by {@code setProtocol} or by the DICOM default. + * + *

The protocol of a {@code Connection} that never has {@code setProtocol} called on it is DICOM + * by dcm4che's own default, so silence there is not an absence of information. This rule set does + * not invent a component for it: the {@code ApplicationEntity} that connection belongs to is what + * reports DICOM. + */ +public final class Dcm4cheDicom extends DetectionRuleSet { + + private static final String APPLICATION_ENTITY = "org.dcm4che3.net.ApplicationEntity"; + private static final String CONNECTION = "org.dcm4che3.net.Connection"; + private static final String PROTOCOL_ENUM = "org.dcm4che3.net.Connection$Protocol"; + private static final String STRING = "java.lang.String"; + private static final String DICOM = "DICOM"; + private static final String BUNDLE = "Dcm4che"; + + @Nonnull + private static ProtocolContext context() { + return new ProtocolContext(Map.of("kind", DICOM)); + } + + /** {@code new ApplicationEntity()} -- the AE that a device presents to the DICOM network. */ + private static final IDetectionRule AE_DEFAULT_CONSTRUCTOR = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(APPLICATION_ENTITY) + .forConstructor() + .shouldBeDetectedAs(new ValueActionFactory<>(DICOM)) + .withoutParameters() + .buildForContext(context()) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + + /** {@code new ApplicationEntity("STORESCU")} -- the same, with the AE title given. */ + private static final IDetectionRule AE_CONSTRUCTOR = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(APPLICATION_ENTITY) + .forConstructor() + .shouldBeDetectedAs(new ValueActionFactory<>(DICOM)) + .withMethodParameter(STRING) + .buildForContext(context()) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + + /** + * {@code ae.connect(remote, rq)}, {@code ae.connect(local, remote, rq)} and {@code + * ae.connect(remoteAe, rq)}. + * + *

One rule for all three overloads. The arity is left open and the receiver type carries the + * match: every {@code connect} on an {@code ApplicationEntity} returns an {@code Association}, + * so there is nothing to tell apart and nothing to read out of the arguments. Three rules, one + * per overload, would be the usual shape here -- but they would each have to name the argument + * types, and the only thing that would buy is the risk of two of them matching one call. + */ + private static final IDetectionRule ASSOCIATION = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(APPLICATION_ENTITY) + .forMethods("connect") + .shouldBeDetectedAs(new ValueActionFactory<>(DICOM)) + .withAnyParameters() + .buildForContext(context()) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + + /** + * {@code connection.setProtocol(Connection.Protocol.HL7)}. + * + *

The member name is read and mapped against the enum's six members in {@code + * JavaProtocolContextTranslator}; anything else -- a variable, a {@code valueOf(...)}, a + * protocol read from LDAP -- resolves to no member and reports nothing. + */ + private static final IDetectionRule SET_PROTOCOL = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(CONNECTION) + .forMethods("setProtocol") + .withMethodParameter(PROTOCOL_ENUM) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(context()) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(AE_DEFAULT_CONSTRUCTOR, AE_CONSTRUCTOR, ASSOCIATION, SET_PROTOCOL); + } +} diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLDetectionRules.java b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLDetectionRules.java index 512de9cf8..f824c2bbb 100644 --- a/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLDetectionRules.java +++ b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLDetectionRules.java @@ -37,7 +37,8 @@ protected List> buildRules() { RuleSets.rulesOf(SSLServerSocketSetEnabledProtocols.class).stream(), RuleSets.rulesOf(SSLSetParameters.class).stream(), RuleSets.rulesOf(SSLContext.class).stream(), - RuleSets.rulesOf(SSLServerSocketSetEnabledCipherSuites.class).stream()) + RuleSets.rulesOf(SSLServerSocketSetEnabledCipherSuites.class).stream(), + RuleSets.rulesOf(SSLEnabledSettings.class).stream()) .flatMap(i -> i) .toList(); } diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettings.java b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettings.java new file mode 100644 index 000000000..cdbc656a9 --- /dev/null +++ b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettings.java @@ -0,0 +1,127 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.ssl; + +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.CipherSuiteFactory; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * The TLS versions and cipher suites of the two JSSE endpoints that were not covered: {@code + * SSLSocket} and {@code SSLEngine}. + * + *

{@code setEnabledProtocols} and {@code setEnabledCipherSuites} are declared on {@code + * SSLSocket}, {@code SSLServerSocket} and {@code SSLEngine} alike, but only the server socket had + * rules. Because the Java frontend matches on the exact declared type, a rule for {@code + * SSLServerSocket} does not match a sibling class, so the client socket -- which is what + * application code usually holds -- and the {@code SSLEngine} that every NIO and Netty-style stack + * uses were both invisible. These are the most common JSSE hardening calls there are, so this was + * the largest gap in the Java TLS coverage. + * + *

The {@code SSLParameters} constructors are read here as well. They take the suites and the + * versions directly, which makes them a complete statement on their own -- unlike {@code + * setProtocols} and {@code setCipherSuites}, which are read only once the parameters object reaches + * {@code setSSLParameters} (see {@link SSLSetParameters}). + * + *

Both setters take a real {@code String[]} rather than varargs, so one rule covers every call + * -- no rule per argument count, as dcm4che's varargs setters need. + */ +public final class SSLEnabledSettings extends DetectionRuleSet { + + private static final String SSL_SOCKET = "javax.net.ssl.SSLSocket"; + private static final String SSL_ENGINE = "javax.net.ssl.SSLEngine"; + private static final String SSL_PARAMETERS = "javax.net.ssl.SSLParameters"; + private static final String STRING_ARRAY = "java.lang.String[]"; + private static final String TLS = "TLS"; + private static final String BUNDLE = "SSL"; + + @Nonnull + private static IDetectionRule protocols(@Nonnull String objectType) { + return new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(objectType) + .forMethods("setEnabledProtocols") + .withMethodParameter(STRING_ARRAY) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + } + + @Nonnull + private static IDetectionRule cipherSuites(@Nonnull String objectType) { + return new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(objectType) + .forMethods("setEnabledCipherSuites") + .withMethodParameter(STRING_ARRAY) + .shouldBeDetectedAs(new CipherSuiteFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + } + + /** {@code new SSLParameters(cipherSuites)}. */ + private static final IDetectionRule PARAMETERS_SUITES = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(SSL_PARAMETERS) + .forConstructor() + .withMethodParameter(STRING_ARRAY) + .shouldBeDetectedAs(new CipherSuiteFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + + /** {@code new SSLParameters(cipherSuites, protocols)}. */ + private static final IDetectionRule PARAMETERS_SUITES_AND_PROTOCOLS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes(SSL_PARAMETERS) + .forConstructor() + .withMethodParameter(STRING_ARRAY) + .shouldBeDetectedAs(new CipherSuiteFactory<>()) + .withMethodParameter(STRING_ARRAY) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", TLS))) + .inBundle(() -> BUNDLE) + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + final List> rules = new ArrayList<>(); + for (String objectType : List.of(SSL_SOCKET, SSL_ENGINE)) { + rules.add(protocols(objectType)); + rules.add(cipherSuites(objectType)); + } + rules.add(PARAMETERS_SUITES); + rules.add(PARAMETERS_SUITES_AND_PROTOCOLS); + return List.copyOf(rules); + } +} diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLParametersSetCipherSuites.java b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLParametersSetCipherSuites.java new file mode 100644 index 000000000..a0fcf2fb3 --- /dev/null +++ b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLParametersSetCipherSuites.java @@ -0,0 +1,59 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.ssl; + +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.CipherSuiteFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * {@code sslParameters.setCipherSuites(suites)}, the counterpart of {@link + * SSLParametersSetProtocols}. + * + *

A depending rule like its sibling, reached when the parameters object is handed to {@code + * setSSLParameters}. The two are always written together -- the point of an {@code SSLParameters} + * is to carry both -- so covering only the versions meant a hardened endpoint reported which TLS + * versions it accepted and never which suites. + */ +public final class SSLParametersSetCipherSuites extends DetectionRuleSet { + + private static final IDetectionRule SSL_SET_CIPHER_SUITES = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectExactTypes("javax.net.ssl.SSLParameters") + .forMethods("setCipherSuites") + .withMethodParameter("java.lang.String[]") + .shouldBeDetectedAs(new CipherSuiteFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", "TLS"))) + .inBundle(() -> "SSL") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(SSL_SET_CIPHER_SUITES); + } +} diff --git a/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLSetParameters.java b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLSetParameters.java index 81a16a749..0dedeef0b 100644 --- a/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLSetParameters.java +++ b/java/src/main/java/com/ibm/plugin/rules/detection/ssl/SSLSetParameters.java @@ -26,6 +26,7 @@ import com.ibm.engine.rule.builder.DetectionRuleBuilder; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import javax.annotation.Nonnull; import org.sonar.plugins.java.api.tree.Tree; @@ -34,10 +35,23 @@ public final class SSLSetParameters extends DetectionRuleSet { private static final IDetectionRule SSL_PARAMETERS = new DetectionRuleBuilder() .createDetectionRule() - .forObjectExactTypes("javax.net.ssl.SSLServerSocket") + // All three JSSE endpoints declare setSSLParameters, and the Java frontend + // matches the exact declared type, so naming only the server socket left the + // client socket and the SSLEngine uncovered. + .forObjectExactTypes( + "javax.net.ssl.SSLServerSocket", + "javax.net.ssl.SSLSocket", + "javax.net.ssl.SSLEngine") .forMethods("setSSLParameters") .withMethodParameter("javax.net.ssl.SSLParameters") - .addDependingDetectionRules(RuleSets.rulesOf(SSLParametersSetProtocols.class)) + .addDependingDetectionRules( + Stream.of( + RuleSets.rulesOf(SSLParametersSetProtocols.class) + .stream(), + RuleSets.rulesOf(SSLParametersSetCipherSuites.class) + .stream()) + .flatMap(i -> i) + .toList()) .buildForContext(new ProtocolContext(Map.of("kind", "TLS"))) .inBundle(() -> "SSL") .withoutDependingDetectionRules(); diff --git a/java/src/main/java/com/ibm/plugin/translation/translator/contexts/JavaProtocolContextTranslator.java b/java/src/main/java/com/ibm/plugin/translation/translator/contexts/JavaProtocolContextTranslator.java index 8e2710c9a..959938f7e 100644 --- a/java/src/main/java/com/ibm/plugin/translation/translator/contexts/JavaProtocolContextTranslator.java +++ b/java/src/main/java/com/ibm/plugin/translation/translator/contexts/JavaProtocolContextTranslator.java @@ -21,6 +21,7 @@ import com.ibm.engine.model.CipherSuite; import com.ibm.engine.model.IValue; +import com.ibm.engine.model.ValueAction; import com.ibm.engine.model.context.DetectionContext; import com.ibm.engine.model.context.ProtocolContext; import com.ibm.engine.rule.IBundle; @@ -45,11 +46,25 @@ public Optional translate( @Nonnull IValue value, @Nonnull DetectionContext detectionContext, @Nonnull DetectionLocation detectionLocation) { - if (!bundleIdentifier.getIdentifier().equals("SSL")) { + // Dcm4che joins SSL here because a DICOM association is TLS underneath: its cipher suites + // and protocol versions are the same strings the JSSE setters take, and are read by the + // same mappers. The "kind" of the context tells the two apart below. + if (!"SSL".equals(bundleIdentifier.getIdentifier()) + && !"Dcm4che".equals(bundleIdentifier.getIdentifier())) { return Optional.empty(); } final String kind = ((ProtocolContext) detectionContext).get("kind").orElse(""); + if (value instanceof ValueAction valueAction) { + // The DICOM rules state the protocol from the call itself: an ApplicationEntity or an + // association is DICOM, with no argument to read. + return "DICOM".equals(kind) + ? Optional.of(new Protocol(valueAction.asString(), detectionLocation)) + : Optional.empty(); + } + if (value instanceof com.ibm.engine.model.Protocol protocol && "DICOM".equals(kind)) { + return dcm4cheProtocol(protocol.asString(), detectionLocation); + } if (value instanceof com.ibm.engine.model.Protocol protocol) { return switch (kind) { case "TLS" -> @@ -84,4 +99,34 @@ public Optional translate( return Optional.of(new Unknown(detectionLocation)); } + + /** + * The six members of {@code org.dcm4che3.net.Connection.Protocol}, and nothing else. + * + *

{@code setProtocol} is the only dcm4che call that can name a protocol other than DICOM, so + * it is read by name -- and a name is only accepted when it is a member of that enum. + * Everything else a call site can write there (a variable, {@code Protocol.valueOf(...)}, a + * value read from LDAP) resolves to a name this does not know, and reports nothing rather than + * a protocol component called after a field. + * + *

{@code SYSLOG_TLS} is the one member that carries more than a name: it is syslog over TLS, + * so the TLS is reported beneath it. {@code HL7_MLLP2} is HL7 over MLLP release 2 -- a framing + * of the same protocol, so it is reported as HL7. + */ + @Nonnull + private static Optional dcm4cheProtocol( + @Nonnull String member, @Nonnull DetectionLocation detectionLocation) { + return switch (member) { + case "DICOM" -> Optional.of(new Protocol("DICOM", detectionLocation)); + case "HL7", "HL7_MLLP2" -> Optional.of(new Protocol("HL7", detectionLocation)); + case "SYSLOG_UDP" -> Optional.of(new Protocol("Syslog", detectionLocation)); + case "SYSLOG_TLS" -> { + final Protocol syslog = new Protocol("Syslog", detectionLocation); + syslog.put(new TLS(detectionLocation)); + yield Optional.of(syslog); + } + case "HTTP" -> Optional.of(new Protocol("HTTP", detectionLocation)); + default -> Optional.empty(); + }; + } } diff --git a/java/src/test/files/rules/detection/dcm4che/Dcm4cheConnectionTlsTestFile.java b/java/src/test/files/rules/detection/dcm4che/Dcm4cheConnectionTlsTestFile.java new file mode 100644 index 000000000..251c66687 --- /dev/null +++ b/java/src/test/files/rules/detection/dcm4che/Dcm4cheConnectionTlsTestFile.java @@ -0,0 +1,51 @@ +import org.dcm4che3.net.Connection; + +/* + * The shapes dcm4che's own tools write. Both setters are varargs and the tools pass bare literals, + * so those are the forms that matter; an array turns up when one connection copies another's + * settings. Several suites in one call are the normal case, and each one has to arrive. + */ +public class Dcm4cheConnectionTlsTestFile { + + /* The suite dcm4che names in its own code, and it encrypts nothing. */ + public void nullCipher(Connection conn) { + conn.setTlsCipherSuites("SSL_RSA_WITH_NULL_SHA"); // Noncompliant {{(TLS) TLS}} + } + + public void twoBareSuites(Connection conn) { + conn.setTlsCipherSuites("SSL_RSA_WITH_3DES_EDE_CBC_SHA", "TLS_RSA_WITH_AES_128_CBC_SHA"); // Noncompliant {{(TLS) TLS}} + } + + /* + * The array form, as produced by setTlsCipherSuites(from.getTlsCipherSuites()). + * + * A call that offers several suites raises one issue, not one per suite: the issue names the + * protocol, and the reporter keeps one issue per distinct node. Every suite still reaches the + * bill of materials, which is what the assertions below check. + */ + public void arrayOfTwoSuites(Connection conn) { + conn.setTlsCipherSuites(new String[] {"TLS_RSA_WITH_AES_256_CBC_SHA", "TLS_RSA_WITH_AES_128_GCM_SHA256"}); // Noncompliant {{(TLS) TLS}} + } + + public void oneProtocol(Connection conn) { + conn.setTlsProtocols("TLSv1.2"); // Noncompliant {{(TLS) TLSv1.2}} + } + + public void manyProtocols(Connection conn) { + conn.setTlsProtocols("SSLv3", "TLSv1", "TLSv1.1", "TLSv1.2"); // Noncompliant {{(TLS) TLS}} {{(TLS) TLSv1.0}} {{(TLS) TLSv1.1}} {{(TLS) TLSv1.2}} + } + + /* + * The form a consumer writes: Connection publishes constants for the three suites it names. + * Their values are the old JSSE spellings, so TLS_RSA_WITH_NULL_SHA is really + * "SSL_RSA_WITH_NULL_SHA" and only resolves through the alias. + */ + public void namedConstant(Connection conn) { + conn.setTlsCipherSuites(Connection.TLS_RSA_WITH_3DES_EDE_CBC_SHA); // Noncompliant {{(TLS) TLS}} + } + + /* Counter-check: the suites come from outside, so nothing may be reported. */ + public void fromVariable(Connection conn, String[] ciphers) { + conn.setTlsCipherSuites(ciphers); + } +} diff --git a/java/src/test/files/rules/detection/dcm4che/Dcm4cheDicomTestFile.java b/java/src/test/files/rules/detection/dcm4che/Dcm4cheDicomTestFile.java new file mode 100644 index 000000000..322b8508d --- /dev/null +++ b/java/src/test/files/rules/detection/dcm4che/Dcm4cheDicomTestFile.java @@ -0,0 +1,81 @@ +import org.dcm4che3.net.ApplicationEntity; +import org.dcm4che3.net.Association; +import org.dcm4che3.net.Connection; +import org.dcm4che3.net.pdu.AAssociateRQ; + +/* + * The shapes dcm4che code actually writes. Modelled on dcm4che's own tools (storescu, findscu, + * syslog, hl7snd), where the AE is constructed by hand, the association is opened from it, and + * setProtocol is the only call that ever names something other than DICOM. + */ +public class Dcm4cheDicomUsage { + + /* An SCU: the AE title is the DICOM identity this code presents. */ + public ApplicationEntity scu() { + return new ApplicationEntity("STORESCU"); // Noncompliant {{(Protocol) DICOM}} + } + + /* The no-argument form, used where the title is set later from configuration. */ + public ApplicationEntity blankAe() { + return new ApplicationEntity(); // Noncompliant {{(Protocol) DICOM}} + } + + /* Opening the association: the two-argument overload dcm4che's tools use. */ + public Association openAssociation(ApplicationEntity ae, Connection remote, AAssociateRQ rq) + throws Exception { + return ae.connect(remote, rq); // Noncompliant {{(Protocol) DICOM}} + } + + /* The three-argument overload, with the local connection given explicitly. */ + public Association openAssociationFromLocal( + ApplicationEntity ae, Connection local, Connection remote, AAssociateRQ rq) + throws Exception { + return ae.connect(local, remote, rq); // Noncompliant {{(Protocol) DICOM}} + } + + /* The overload that takes the remote AE rather than its connection. */ + public Association openAssociationToAe( + ApplicationEntity ae, ApplicationEntity remoteAe, AAssociateRQ rq) throws Exception { + return ae.connect(remoteAe, rq); // Noncompliant {{(Protocol) DICOM}} + } + + /* A DICOM connection, stated rather than left to the default. */ + public void dicomConnection(Connection conn) { + conn.setProtocol(Connection.Protocol.DICOM); // Noncompliant {{(Protocol) DICOM}} + } + + /* HL7 over MLLP: the same device, a different protocol. dcm4che's hl7snd does this. */ + public void hl7Connection(Connection conn) { + conn.setProtocol(Connection.Protocol.HL7); // Noncompliant {{(Protocol) HL7}} + } + + /* MLLP release 2 is a framing of HL7, so it is the same protocol. */ + public void hl7Mllp2Connection(Connection conn) { + conn.setProtocol(Connection.Protocol.HL7_MLLP2); // Noncompliant {{(Protocol) HL7}} + } + + /* Syslog over TLS: the one member that states transport security as well. */ + public void syslogTlsConnection(Connection conn) { + conn.setProtocol(Connection.Protocol.SYSLOG_TLS); // Noncompliant {{(Protocol) Syslog}} + } + + /* Plain syslog over UDP, with nothing protecting it. */ + public void syslogUdpConnection(Connection conn) { + conn.setProtocol(Connection.Protocol.SYSLOG_UDP); // Noncompliant {{(Protocol) Syslog}} + } + + /* The web protocol, for a device that also serves DICOMweb. */ + public void httpConnection(Connection conn) { + conn.setProtocol(Connection.Protocol.HTTP); // Noncompliant {{(Protocol) HTTP}} + } + + /* Counter-check: the protocol comes from a parameter, so none may be invented. */ + public void protocolFromParameter(Connection conn, Connection.Protocol protocol) { + conn.setProtocol(protocol); + } + + /* Counter-check: dcm4che's LDAP and JSON config read the protocol by name at runtime. */ + public void protocolFromString(Connection conn, String configured) { + conn.setProtocol(Connection.Protocol.valueOf(configured)); + } +} diff --git a/java/src/test/files/rules/detection/ssl/SSLEnabledSettingsTestFile.java b/java/src/test/files/rules/detection/ssl/SSLEnabledSettingsTestFile.java new file mode 100644 index 000000000..bf0bb53f7 --- /dev/null +++ b/java/src/test/files/rules/detection/ssl/SSLEnabledSettingsTestFile.java @@ -0,0 +1,69 @@ +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; + +/* + * The JSSE endpoints an application actually holds. SSLServerSocket already had rules; the client + * socket and the SSLEngine are the shapes that were invisible, and they are the ones ordinary code + * and every NIO stack use. + */ +public class SSLEnabledSettingsTestFile { + + /* Hardening a client socket: the single most common JSSE call there is. */ + void clientSocketProtocols() throws IOException { + try (SSLSocket socket = (SSLSocket) SSLSocketFactory.getDefault().createSocket()) { + // Noncompliant@+2 {{(TLS) TLSv1.2}} + // Noncompliant@+1 {{(TLS) TLSv1.3}} + socket.setEnabledProtocols(new String[] { "TLSv1.2", "TLSv1.3" }); + } + } + + /* The suites a client offers. */ + void clientSocketCipherSuites() throws IOException { + try (SSLSocket socket = (SSLSocket) SSLSocketFactory.getDefault().createSocket()) { + // Noncompliant@+1 {{(TLS) TLS}} + socket.setEnabledCipherSuites(new String[] { "TLS_RSA_WITH_AES_128_CBC_SHA" }); + } + } + + /* The engine every non-blocking stack drives TLS with. */ + void engineProtocols() throws Exception { + SSLEngine engine = SSLContext.getDefault().createSSLEngine(); + // Noncompliant@+1 {{(TLS) TLSv1.3}} + engine.setEnabledProtocols(new String[] { "TLSv1.3" }); + } + + /* A suite that encrypts nothing, offered by an engine. */ + void engineCipherSuites() throws Exception { + SSLEngine engine = SSLContext.getDefault().createSSLEngine(); + // Noncompliant@+1 {{(TLS) TLS}} + engine.setEnabledCipherSuites(new String[] { "TLS_RSA_WITH_NULL_SHA" }); + } + + /* The constructor states the suites directly. */ + SSLParameters parametersWithSuites() { + // Noncompliant@+1 {{(TLS) TLS}} + return new SSLParameters(new String[] { "TLS_RSA_WITH_3DES_EDE_CBC_SHA" }); + } + + /* And the two-argument form states the suites and the versions at once. */ + SSLParameters parametersWithSuitesAndProtocols() { + // Noncompliant@+2 {{(TLS) TLS}} + // Noncompliant@+1 {{(TLS) TLSv1.2}} + return new SSLParameters( + new String[] { "TLS_RSA_WITH_AES_256_CBC_SHA" }, new String[] { "TLSv1.2" }); + } + + /* Counter-check: the versions come from configuration, so none may be invented. */ + void protocolsFromConfiguration(SSLSocket socket, String[] configured) { + socket.setEnabledProtocols(configured); + } + + /* Counter-check: an SSLServerSocket is covered by its own rules and must not double up here. */ + void plainSocketIsNotTls(java.net.Socket socket) throws IOException { + socket.setKeepAlive(true); + } +} diff --git a/java/src/test/files/rules/detection/ssl/SSLSetParametersEndpointsTestFile.java b/java/src/test/files/rules/detection/ssl/SSLSetParametersEndpointsTestFile.java new file mode 100644 index 000000000..48b4a1d1b --- /dev/null +++ b/java/src/test/files/rules/detection/ssl/SSLSetParametersEndpointsTestFile.java @@ -0,0 +1,33 @@ +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; + +/* + * setSSLParameters is declared on all three JSSE endpoints. The server socket was already read; + * these are the client socket and the engine, together with the suites that used to be dropped. + */ +public class SSLSetParametersEndpointsTestFile { + + /* A client socket configured through a parameters object. */ + void clientSocket(SSLSocket socket) { + SSLParameters parameters = new SSLParameters(); + // Noncompliant@+1 {{(TLS) TLSv1.3}} + parameters.setProtocols(new String[] { "TLSv1.3" }); + // Noncompliant@+1 {{(TLS) TLS}} + parameters.setCipherSuites(new String[] { "TLS_RSA_WITH_AES_128_CBC_SHA" }); + socket.setSSLParameters(parameters); + } + + /* The same for an engine. */ + void engine(SSLEngine engine) { + SSLParameters parameters = new SSLParameters(); + // Noncompliant@+1 {{(TLS) TLSv1.2}} + parameters.setProtocols(new String[] { "TLSv1.2" }); + engine.setSSLParameters(parameters); + } + + /* Counter-check: the parameters come from elsewhere, so nothing about them is knowable. */ + void fromElsewhere(SSLSocket socket, SSLParameters configured) { + socket.setSSLParameters(configured); + } +} diff --git a/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTlsTest.java b/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTlsTest.java new file mode 100644 index 000000000..cffdddcb8 --- /dev/null +++ b/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheConnectionTlsTest.java @@ -0,0 +1,112 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dcm4che; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.rule.RuleSets; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; +import org.sonar.plugins.java.api.JavaCheck; +import org.sonar.plugins.java.api.JavaFileScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * The TLS settings of a DICOM connection are read like any other JSSE setting. + * + *

A suite arrives as the algorithms it names, so a product that offers {@code + * SSL_RSA_WITH_NULL_SHA} reads as a DICOM association with no encryption rather than as an opaque + * string. The last case is the counter-check: dcm4che's tools usually pass the suites straight from + * the command line, and nothing is reported for those. + */ +class Dcm4cheConnectionTlsTest extends TestBase { + + private final List found = new ArrayList<>(); + + protected Dcm4cheConnectionTlsTest() { + super(RuleSets.rulesOf(Dcm4cheConnectionTls.class)); + } + + @Test + void test() { + // The dcm4che jars are fetched, not checked in, so a build without network access has + // none and this test is skipped rather than asserting against an empty classpath. + assumeTrue(Dcm4cheJars.available(), Dcm4cheJars.unavailableReason()); + CheckVerifier.newVerifier() + .onFile("src/test/files/rules/detection/dcm4che/Dcm4cheConnectionTlsTestFile.java") + .withChecks(this) + .withClassPath(Dcm4cheJars.latestJars) + .verifyIssues(); + assertThat(found) + .as("every suite and version the connection offers, each on its own protocol") + .containsExactly( + // The JSSE spelling resolves to the IANA name, and the suite names no + // encryption algorithm at all -- that is the finding. + "TLS=TLS[CipherSuite=TLS_RSA_WITH_NULL_SHA[AssetCollection=[RSA, RSA]," + + " IdentifierCollection=[0x00, 0x02]]]", + "TLS=TLS[CipherSuite=TLS_RSA_WITH_3DES_EDE_CBC_SHA[AssetCollection=[RSA," + + " 3DES-CBC, RSA], IdentifierCollection=[0x00, 0x0A]]]", + "TLS=TLS[CipherSuite=TLS_RSA_WITH_AES_128_CBC_SHA[AssetCollection=[RSA," + + " AES-128-CBC, RSA], IdentifierCollection=[0x00, 0x2F]]]", + "TLS=TLS[CipherSuite=TLS_RSA_WITH_AES_256_CBC_SHA[AssetCollection=[RSA," + + " AES-256-CBC, RSA], IdentifierCollection=[0x00, 0x35]]]", + "TLS=TLS[CipherSuite=TLS_RSA_WITH_AES_128_GCM_SHA256[AssetCollection=[RSA," + + " AES-128-GCM, RSA], IdentifierCollection=[0x00, 0x9C]]]", + "TLS=TLSv1.2[Version=1.2]", + // SSLv3 is offered, but the version mapper does not name it. The protocol + // is recorded without a version rather than with an invented one. + "TLS=TLS", + "TLS=TLSv1.0[Version=1.0]", + "TLS=TLSv1.1[Version=1.1]", + "TLS=TLSv1.2[Version=1.2]", + // Connection.TLS_RSA_WITH_3DES_EDE_CBC_SHA: the constant resolves through + // the field to its JSSE value and from there through the alias. + "TLS=TLS[CipherSuite=TLS_RSA_WITH_3DES_EDE_CBC_SHA[AssetCollection=[RSA," + + " 3DES-CBC, RSA], IdentifierCollection=[0x00, 0x0A]]]"); + } + + /** Renders a node and its children as {@code Kind=value[children]}, children sorted. */ + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream() + .map(Dcm4cheConnectionTlsTest::render) + .sorted() + .toList(); + return children.isEmpty() ? self : self + children; + } + + /** Collects the cipher suite or protocol version each finding resolves to. */ + @Override + public void asserts( + int findingId, + @Nonnull DetectionStore detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(render(node))); + } +} diff --git a/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicomTest.java b/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicomTest.java new file mode 100644 index 000000000..6dc24588f --- /dev/null +++ b/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheDicomTest.java @@ -0,0 +1,103 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dcm4che; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.rule.RuleSets; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; +import org.sonar.plugins.java.api.JavaCheck; +import org.sonar.plugins.java.api.JavaFileScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * What protocol a dcm4che connection speaks, which the TLS rules alone could not say. + * + *

The AE and the association report DICOM from the call itself. {@code setProtocol} reports the + * member it is given, mapped against the enum's six members, and the last two cases are the + * counter-checks that keep it to those: a protocol taken from a parameter and one read by name at + * runtime are both unknowable, and neither may produce a component named after whatever the + * expression happens to mention. + */ +class Dcm4cheDicomTest extends TestBase { + + private final List found = new ArrayList<>(); + + protected Dcm4cheDicomTest() { + super(RuleSets.rulesOf(Dcm4cheDicom.class)); + } + + @Test + void test() { + // The dcm4che jars are fetched, not checked in, so a build without network access has + // none and this test is skipped rather than asserting against an empty classpath. + assumeTrue(Dcm4cheJars.available(), Dcm4cheJars.unavailableReason()); + CheckVerifier.newVerifier() + .onFile("src/test/files/rules/detection/dcm4che/Dcm4cheDicomTestFile.java") + .withChecks(this) + .withClassPath(Dcm4cheJars.latestJars) + .verifyIssues(); + assertThat(found) + .as("one protocol per call, and only the enum's own members") + .containsExactly( + "Protocol=DICOM", // new ApplicationEntity("STORESCU") + "Protocol=DICOM", // new ApplicationEntity() + "Protocol=DICOM", // ae.connect(remote, rq) + "Protocol=DICOM", // ae.connect(local, remote, rq) + "Protocol=DICOM", // ae.connect(remoteAe, rq) + "Protocol=DICOM", // setProtocol(Protocol.DICOM) + "Protocol=HL7", // setProtocol(Protocol.HL7) + "Protocol=HL7", // HL7_MLLP2 is a framing of HL7, not a protocol of its own + // The only member that states transport security as well. The nested TLS + // does not raise an issue of its own -- a node's children are keyed by kind + // -- but it is what reaches the bill of materials. + "Protocol=Syslog[TLS=TLS]", + "Protocol=Syslog", // SYSLOG_UDP: nothing protects it + "Protocol=HTTP"); + } + + /** Renders a node and its children as {@code Kind=value[children]}, children sorted. */ + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream() + .map(Dcm4cheDicomTest::render) + .sorted() + .toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull DetectionStore detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(render(node))); + } +} diff --git a/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheJars.java b/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheJars.java new file mode 100644 index 000000000..2a256f564 --- /dev/null +++ b/java/src/test/java/com/ibm/plugin/rules/detection/dcm4che/Dcm4cheJars.java @@ -0,0 +1,119 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dcm4che; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * The dcm4che jars the verifier needs on the classpath, and whether this build has them. + * + *

Unlike {@code BouncyCastleJars}, these are not checked in. Bouncy Castle is + * MIT-style, so carrying its jar costs nothing; dcm4che is MPL 1.1 / GPL 2.0 / LGPL 2.1, and a copy + * in this Apache-2.0 repository would be redistribution of an MPL-covered binary, which carries a + * source-availability obligation. {@code java/pom.xml} fetches them from {@code maven.dcm4che.org} + * into {@code target/test-jars} instead, so the developer and CI obtain them from dcm4che and this + * project redistributes nothing. + * + *

The fetch is deliberately allowed to fail. An offline or network-restricted build skips the + * download, {@link #available()} then returns {@code false}, and the two dcm4che test classes + * report as skipped rather than failing -- a missing optional fixture must not turn a green build + * red. + * + *

Which also means the jars have to be pinned by content rather than by URL, because a build can + * no longer be trusted to have obtained anything in particular: a truncated download, a changed + * upstream artifact or a substituted one would otherwise be used as if it were the pinned version. + * A file whose digest does not match is treated exactly like a missing one. + */ +public class Dcm4cheJars { + + private Dcm4cheJars() { + // utility + } + + private static final String VERSION = "5.35.2"; + + /** From https://maven.dcm4che.org/org/dcm4che/, SHA-256 of the published artifacts. */ + @Nonnull + private static final Map DIGESTS = + Map.of( + "dcm4che-net-" + VERSION + ".jar", + "40d17b06339e08a2341b2d9785014fdc047b79bb04d25f8f7267df58cff85bd6", + "dcm4che-core-" + VERSION + ".jar", + "f660d2aa8dbcabda1d0051431d9c39ff39453de9e7541b227de8aa1a900441a8"); + + /** {@code dcm4che-core} comes along because {@code Connection} refers to it. */ + public static final Collection dcm4che5352Jars = + List.of( + new File("target/test-jars/dcm4che-net-" + VERSION + ".jar"), + new File("target/test-jars/dcm4che-core-" + VERSION + ".jar")); + + public static final Collection latestJars = dcm4che5352Jars; + + /** + * Whether every jar is present and is the pinned artifact. + * + *

Used from an {@code assumeTrue} so that a build without them skips the dcm4che tests. A + * type the semantic model cannot complete is a type the rules cannot match, so running them + * against a missing classpath would assert the absence of findings that the rules do in fact + * produce -- a false green, which is worse than a skip. + */ + public static boolean available() { + return latestJars.stream().allMatch(Dcm4cheJars::isPinnedArtifact); + } + + /** The message an {@code assumeTrue} prints when the jars are not there. */ + @Nonnull + public static String unavailableReason() { + return "the dcm4che test jars are not in target/test-jars; run `mvn -pl java" + + " generate-test-resources` with network access to fetch them"; + } + + private static boolean isPinnedArtifact(@Nonnull File jar) { + if (!jar.isFile()) { + return false; + } + final String expected = DIGESTS.get(jar.getName()); + return expected != null && expected.equals(sha256(jar)); + } + + @Nonnull + private static String sha256(@Nonnull File file) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(Files.readAllBytes(file.toPath())); + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (IOException | NoSuchAlgorithmException e) { + return ""; + } + } +} diff --git a/java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettingsTest.java b/java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettingsTest.java new file mode 100644 index 000000000..f548814ce --- /dev/null +++ b/java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLEnabledSettingsTest.java @@ -0,0 +1,100 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.ssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.rule.RuleSets; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; +import org.sonar.plugins.java.api.JavaCheck; +import org.sonar.plugins.java.api.JavaFileScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * The JSSE endpoints that had no rules: {@code SSLSocket}, {@code SSLEngine} and the {@code + * SSLParameters} constructors. + * + *

The last two cases are the counter-checks. Versions taken from configuration are unknowable + * and report nothing, and a plain {@code java.net.Socket} is not a TLS endpoint at all -- the rules + * key on the exact JSSE type, which is what keeps them off every socket in a code base. + */ +class SSLEnabledSettingsTest extends TestBase { + + private final List found = new ArrayList<>(); + + protected SSLEnabledSettingsTest() { + super(RuleSets.rulesOf(SSLEnabledSettings.class)); + } + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile("src/test/files/rules/detection/ssl/SSLEnabledSettingsTestFile.java") + .withChecks(this) + .withClassPath(List.of()) + .verifyIssues(); + assertThat(found) + .as("every version and suite the endpoint offers, each on its own protocol") + .containsExactly( + "TLS=TLSv1.2[Version=1.2]", // SSLSocket.setEnabledProtocols + "TLS=TLSv1.3[Version=1.3]", + // SSLSocket.setEnabledCipherSuites + "TLS=TLS[CipherSuite=TLS_RSA_WITH_AES_128_CBC_SHA[AssetCollection=[RSA," + + " AES-128-CBC, RSA], IdentifierCollection=[0x00, 0x2F]]]", + "TLS=TLSv1.3[Version=1.3]", // SSLEngine.setEnabledProtocols + // SSLEngine.setEnabledCipherSuites -- a suite that encrypts nothing + "TLS=TLS[CipherSuite=TLS_RSA_WITH_NULL_SHA[AssetCollection=[RSA, RSA]," + + " IdentifierCollection=[0x00, 0x02]]]", + // new SSLParameters(cipherSuites) + "TLS=TLS[CipherSuite=TLS_RSA_WITH_3DES_EDE_CBC_SHA[AssetCollection=[RSA," + + " 3DES-CBC, RSA], IdentifierCollection=[0x00, 0x0A]]]", + // new SSLParameters(cipherSuites, protocols) -- both in one call + "TLS=TLS[CipherSuite=TLS_RSA_WITH_AES_256_CBC_SHA[AssetCollection=[RSA," + + " AES-256-CBC, RSA], IdentifierCollection=[0x00, 0x35]]]", + "TLS=TLSv1.2[Version=1.2]"); + } + + /** Renders a node and its children as {@code Kind=value[children]}, children sorted. */ + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream() + .map(SSLEnabledSettingsTest::render) + .sorted() + .toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull DetectionStore detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(render(node))); + } +} diff --git a/java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLSetParametersEndpointsTest.java b/java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLSetParametersEndpointsTest.java new file mode 100644 index 000000000..fe2adfa96 --- /dev/null +++ b/java/src/test/java/com/ibm/plugin/rules/detection/ssl/SSLSetParametersEndpointsTest.java @@ -0,0 +1,89 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.ssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.rule.RuleSets; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; +import org.sonar.plugins.java.api.JavaCheck; +import org.sonar.plugins.java.api.JavaFileScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * {@code setSSLParameters} on the two endpoints it was not read on, and the cipher suites that were + * dropped along the way. + * + *

The versions and the suites are reached by following the parameters object back to the calls + * that filled it. The last case is the counter-check: a parameters object handed in from outside + * carries nothing this analysis can see, and reports nothing. + */ +class SSLSetParametersEndpointsTest extends TestBase { + + private final List found = new ArrayList<>(); + + protected SSLSetParametersEndpointsTest() { + super(RuleSets.rulesOf(SSLSetParameters.class)); + } + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile("src/test/files/rules/detection/ssl/SSLSetParametersEndpointsTestFile.java") + .withChecks(this) + .withClassPath(List.of()) + .verifyIssues(); + assertThat(found) + .as("the versions and suites the parameters object was filled with") + .containsExactly( + "TLS=TLSv1.3[Version=1.3]", // SSLSocket: parameters.setProtocols + // SSLSocket: parameters.setCipherSuites -- dropped before this rule existed + "TLS=TLS[CipherSuite=TLS_RSA_WITH_AES_128_CBC_SHA[AssetCollection=[RSA," + + " AES-128-CBC, RSA], IdentifierCollection=[0x00, 0x2F]]]", + "TLS=TLSv1.2[Version=1.2]"); // SSLEngine: parameters.setProtocols + } + + /** Renders a node and its children as {@code Kind=value[children]}, children sorted. */ + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream() + .map(SSLSetParametersEndpointsTest::render) + .sorted() + .toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull DetectionStore detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(render(node))); + } +} diff --git a/mapper/src/main/java/com/ibm/mapper/mapper/ssl/CipherSuiteMapper.java b/mapper/src/main/java/com/ibm/mapper/mapper/ssl/CipherSuiteMapper.java index 31fb5620c..34b1c06ca 100644 --- a/mapper/src/main/java/com/ibm/mapper/mapper/ssl/CipherSuiteMapper.java +++ b/mapper/src/main/java/com/ibm/mapper/mapper/ssl/CipherSuiteMapper.java @@ -122,9 +122,33 @@ public Optional parse( return Optional.of(cipherSuite); } + /** + * The prefix JSSE keeps for the suites it inherited from SSLv3. + * + *

Java names those with {@code SSL_} where IANA names them {@code TLS_}, and the rest of the + * name is identical: {@code SSL_RSA_WITH_NULL_SHA} is {@code TLS_RSA_WITH_NULL_SHA}. The + * spelling matters because it is the one that appears in source -- dcm4che declares its three + * {@code Connection} constants with it, and the two its own tools pass as literals are the null + * cipher and 3DES, which are exactly the suites an inventory is read for. + */ + private static final String JSSE_LEGACY_PREFIX = "SSL_"; + + private static final String IANA_PREFIX = "TLS_"; + + @Nonnull + private static Optional findByJsseLegacyName(@Nonnull String identifier) { + if (!identifier.startsWith(JSSE_LEGACY_PREFIX)) { + return Optional.empty(); + } + return Optional.ofNullable( + JsonCipherSuites.CIPHER_SUITES.get( + IANA_PREFIX + identifier.substring(JSSE_LEGACY_PREFIX.length()))); + } + @Nonnull public static Optional findCipherSuite(@Nonnull final String identifier) { return Optional.ofNullable(JsonCipherSuites.CIPHER_SUITES.get(identifier)) + .or(() -> findByJsseLegacyName(identifier)) .or( () -> { Collection suites = diff --git a/mapper/src/main/java/com/ibm/mapper/reorganizer/rules/CipherSuiteReorganizer.java b/mapper/src/main/java/com/ibm/mapper/reorganizer/rules/CipherSuiteReorganizer.java index f58f83002..9964f7c87 100644 --- a/mapper/src/main/java/com/ibm/mapper/reorganizer/rules/CipherSuiteReorganizer.java +++ b/mapper/src/main/java/com/ibm/mapper/reorganizer/rules/CipherSuiteReorganizer.java @@ -25,7 +25,6 @@ import com.ibm.mapper.reorganizer.IReorganizerRule; import com.ibm.mapper.reorganizer.builder.ReorganizerRuleBuilder; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; import javax.annotation.Nonnull; @@ -36,6 +35,14 @@ private CipherSuiteReorganizer() { // nothing } + /** + * A suite on its own becomes the protocol that offers it. + * + *

The returned list replaces the roots of the whole tree, so only this suite's root may be + * exchanged. Returning the new protocol alone would drop every other root, and one call offers + * several suites: {@code setEnabledCipherSuites} and dcm4che's {@code setTlsCipherSuites} both + * take a list, and each entry arrives here as its own root. + */ @Nonnull public static final IReorganizerRule ADD_TLS_PROTOCOL_AS_PARENT_NODE = new ReorganizerRuleBuilder() @@ -47,11 +54,33 @@ private CipherSuiteReorganizer() { if (node instanceof CipherSuite cipherSuite) { final TLS tls = new TLS(cipherSuite.getDetectionContext()); tls.put(cipherSuite); - return new ArrayList<>(Collections.singleton(tls)); + final List newRoots = new ArrayList<>(roots); + final int index = indexOfSameNode(newRoots, node); + if (index < 0) { + newRoots.add(tls); + } else { + newRoots.set(index, tls); + } + return newRoots; } return roots; }); + /** + * The position of {@code node} itself, compared by identity. + * + *

Two calls can name the same suite, and equal suites would otherwise collapse onto one + * position and leave the other root untouched forever. + */ + private static int indexOfSameNode(@Nonnull List nodes, @Nonnull INode node) { + for (int i = 0; i < nodes.size(); i++) { + if (nodes.get(i) == node) { + return i; + } + } + return -1; + } + @Nonnull public static final IReorganizerRule REPLACE_TLS_WITH_VERSIONED_CHILD = new ReorganizerRuleBuilder() From 513c1c909c378f0b4c86dd740e0c6a394a8e1837 Mon Sep 17 00:00:00 2001 From: Fynn Thierling Date: Wed, 11 Nov 2026 12:02:33 +0100 Subject: [PATCH 2/2] detection rules for openssl (+schannel to a limited degree) Signed-off-by: Fynn Thierling --- .../rules/detection/CSharpDetectionRules.java | 8 +- .../detection/dotnet/DotNetCipherSuites.java | 90 +++ .../rules/detection/openssl/OpenSslApi.java | 731 ++++++++++++++++++ .../detection/openssl/OpenSslCipher.java | 181 +++++ .../openssl/OpenSslDetectionRules.java | 66 ++ .../detection/openssl/OpenSslDigest.java | 225 ++++++ .../rules/detection/openssl/OpenSslKey.java | 496 ++++++++++++ .../detection/openssl/OpenSslRandom.java | 106 +++ .../rules/detection/openssl/OpenSslTls.java | 250 ++++++ .../rules/detection/schannel/Schannel.java | 255 ++++++ .../translator/CSharpTranslator.java | 12 + .../CSharpOpenSslContextTranslator.java | 665 ++++++++++++++++ .../CSharpProtocolContextTranslator.java | 127 +++ .../dotnet/DotNetCipherSuitesTestFile.cs | 70 ++ .../dotnet/DotNetHexLiteralTestFile.cs | 45 ++ .../openssl/OpenSslCipherTestFile.cs | 90 +++ .../openssl/OpenSslDigestTestFile.cs | 120 +++ .../detection/openssl/OpenSslKeyTestFile.cs | 184 +++++ .../openssl/OpenSslRandomTestFile.cs | 47 ++ .../detection/openssl/OpenSslTlsTestFile.cs | 131 ++++ .../detection/schannel/SchannelTestFile.cs | 403 ++++++++++ .../dotnet/DotNetCipherSuitesTest.java | 105 +++ .../dotnet/DotNetHexLiteralTest.java | 92 +++ .../detection/openssl/OpenSslCipherTest.java | 104 +++ .../detection/openssl/OpenSslDigestTest.java | 105 +++ .../detection/openssl/OpenSslKeyTest.java | 162 ++++ .../detection/openssl/OpenSslRandomTest.java | 71 ++ .../detection/openssl/OpenSslTlsTest.java | 141 ++++ .../detection/schannel/SchannelTest.java | 164 ++++ .../csharp/CSharpDetectionEngine.java | 28 +- .../language/csharp/CSharpTreeConverter.java | 130 +++- .../csharp/tree/CSharpArrayCreationTree.java | 37 + .../ibm/output/cyclondx/builder/Utils.java | 10 +- 33 files changed, 5437 insertions(+), 14 deletions(-) create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuites.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslApi.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipher.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDetectionRules.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigest.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslKey.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandom.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslTls.java create mode 100644 csharp/src/main/java/com/ibm/plugin/rules/detection/schannel/Schannel.java create mode 100644 csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpOpenSslContextTranslator.java create mode 100644 csharp/src/test/files/rules/detection/dotnet/DotNetCipherSuitesTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/dotnet/DotNetHexLiteralTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/openssl/OpenSslCipherTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/openssl/OpenSslDigestTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/openssl/OpenSslKeyTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/openssl/OpenSslRandomTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/openssl/OpenSslTlsTestFile.cs create mode 100644 csharp/src/test/files/rules/detection/schannel/SchannelTestFile.cs create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuitesTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetHexLiteralTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipherTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigestTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslKeyTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandomTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslTlsTest.java create mode 100644 csharp/src/test/java/com/ibm/plugin/rules/detection/schannel/SchannelTest.java diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java index 1feae384a..5548be252 100755 --- a/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/CSharpDetectionRules.java @@ -27,6 +27,7 @@ import com.ibm.plugin.rules.detection.dotnet.DotNetAlgorithmFactory; import com.ibm.plugin.rules.detection.dotnet.DotNetCertificateKeys; import com.ibm.plugin.rules.detection.dotnet.DotNetChaCha20Poly1305; +import com.ibm.plugin.rules.detection.dotnet.DotNetCipherSuites; import com.ibm.plugin.rules.detection.dotnet.DotNetCngKey; import com.ibm.plugin.rules.detection.dotnet.DotNetDES; import com.ibm.plugin.rules.detection.dotnet.DotNetDSA; @@ -53,6 +54,8 @@ import com.ibm.plugin.rules.detection.fodicom.FoDicom; import com.ibm.plugin.rules.detection.mailkit.MailKit; import com.ibm.plugin.rules.detection.npgsql.Npgsql; +import com.ibm.plugin.rules.detection.openssl.OpenSslDetectionRules; +import com.ibm.plugin.rules.detection.schannel.Schannel; import java.util.List; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -91,9 +94,12 @@ protected List> buildRules() { RuleSets.rulesOf(DotNetIncrementalHash.class).stream(), RuleSets.rulesOf(DotNetCngKey.class).stream(), RuleSets.rulesOf(DotNetTls.class).stream(), + RuleSets.rulesOf(DotNetCipherSuites.class).stream(), RuleSets.rulesOf(MailKit.class).stream(), RuleSets.rulesOf(Npgsql.class).stream(), - RuleSets.rulesOf(FoDicom.class).stream()) + RuleSets.rulesOf(Schannel.class).stream(), + RuleSets.rulesOf(FoDicom.class).stream(), + RuleSets.rulesOf(OpenSslDetectionRules.class).stream()) .flatMap(i -> i) .toList(); } diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuites.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuites.java new file mode 100644 index 000000000..a603f93d4 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuites.java @@ -0,0 +1,90 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dotnet; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.CipherSuiteFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * The cipher suites a TLS connection is allowed to negotiate. + * + *

{@code CipherSuitesPolicy} is the only place in .NET where an application names cipher suites + * directly, and it names them as themselves: the members of {@code TlsCipherSuite} are spelled + * exactly like the IANA registry entries, so {@code TlsCipherSuite.TLS_AES_256_GCM_SHA384} resolves + * through the project's existing {@code CipherSuiteMapper} into the key exchange, the cipher and + * the digest it is made of, with its two-byte identifier. Everywhere else .NET speaks only of + * protocol versions and leaves the suite choice to the platform. + * + *

The suites arrive as the elements of the collection passed to the constructor. Reading them + * needed a change in the C# engine: a braced initializer used to resolve to its element *count* for + * every asking factory, so this list could only ever have produced the number 2. The engine now + * branches the way the Java engine always has — a {@code SizeFactory} gets the length, everything + * else gets the values. + * + *

The finding is a {@code TLS} protocol with the suites beneath it, rather than bare suites at + * the top level. A cipher suite is not a thing on its own; it is what a TLS handshake agreed on, + * and the C# reorganizer -- unlike the Java one -- does not wrap a loose suite in its protocol. + * + *

Platform note worth knowing when reading a CBOM that contains these: the class is annotated + * {@code [UnsupportedOSPlatform("windows")]} and {@code ("android")}. It configures the + * OpenSSL-backed TLS stack and throws a {@code PlatformNotSupportedException} on the + * Schannel-backed one, so a suite list detected here is in force on Linux and macOS and inert on + * Windows. That is a property of the code, not of the detection, and the right place to say it is + * here rather than in the inventory. + */ +public final class DotNetCipherSuites extends DetectionRuleSet { + + /** + * {@code new CipherSuitesPolicy(new[] { TlsCipherSuite.TLS_AES_256_GCM_SHA384, ... })}. + * + *

One constructor, one parameter, so there is no overload to tell apart. The parameter is + * declared {@code ANY} because the argument may be an array, a collection expression, a {@code + * List}, or anything else {@code IEnumerable} accepts; what makes the rule + * precise is not the parameter type but that only a name the IANA table knows becomes a suite. + * A member of some other enum, or a suite name that does not exist, resolves to nothing. + */ + private static final IDetectionRule CIPHER_SUITES_POLICY = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes("CipherSuitesPolicy") + .forMethods("") + .shouldBeDetectedAs(new ValueActionFactory<>("TLS")) + .withMethodParameter(MethodMatcher.ANY) + .shouldBeDetectedAs(new CipherSuiteFactory<>()) + .asChildOfParameterWithId(-1) + .buildForContext(new ProtocolContext(Map.of("kind", "TLS"))) + .inBundle(() -> "DotNetTls") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(CIPHER_SUITES_POLICY); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslApi.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslApi.java new file mode 100644 index 000000000..a96f5b375 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslApi.java @@ -0,0 +1,731 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * The OpenSSL 3.x API surface these rules read, as one table per closed constant set. + * + *

Every entry was taken from the OpenSSL project's own man pages and public headers at tag + * {@code openssl-3.5} -- the sources behind {@code https://docs.openssl.org/3.5/} -- not from + * memory: + * + *

    + *
  • {@code include/openssl/evp.h} for the complete list of {@code const EVP_CIPHER + * *EVP_(void)} and {@code const EVP_MD *EVP_(void)} functions. + *
  • {@code include/openssl/prov_ssl.h} for the protocol version constants and their wire + * values, {@code doc/man3/SSL_CTX_set_min_proto_version.pod} for which of them are supported. + *
  • {@code doc/man3/SSL_CTX_new.pod} for the {@code *_method} family. + *
  • {@code include/openssl/rsa.h} and {@code doc/man3/EVP_PKEY_CTX_ctrl.pod} for the RSA + * padding constants and their values. + *
  • {@code include/openssl/obj_mac.h} for the curve NIDs, their numeric values and the short + * name each one stands for. + *
  • {@code include/openssl/core_names.h.in} ({@code OSSL_KDF_NAME_*}) for the KDF algorithm + * name strings. + *
+ * + *

The tables serve two purposes at once, and that is the point of putting them here rather than + * in the translator: the rule sets take their {@code forMethods} names from the key sets, and + * {@code CSharpOpenSslContextTranslator} resolves a detection by looking the same key up again. A + * name that is not in the table is therefore never matched and never translated, so the + * closed-set guard cannot drift away from the rule. + * + *

Deliberate omissions are recorded beside the table they belong to. The rule throughout is that + * a detail OpenSSL leaves to run time is not written down: {@code EVP_rc4()} and {@code + * EVP_bf_cbc()} take a key length set later through {@code EVP_CIPHER_CTX_set_key_length}, so + * neither carries one here, while {@code EVP_rc4_40()} and {@code EVP_rc2_40_cbc()} name theirs and + * do. + */ +public final class OpenSslApi { + + private OpenSslApi() { + // utility + } + + /** Key length unknown at the call site, because OpenSSL lets it be set later. */ + public static final int KEY_LENGTH_UNSPECIFIED = 0; + + /** + * A symmetric cipher as one {@code EVP_*} function names it: the algorithm, the key length in + * bits where the name states one, and the mode token where the name states one. + */ + public record SymmetricSpec(@Nonnull String algorithm, int keyBits, @Nullable String mode) {} + + /** A message digest as one {@code EVP_*} function names it. */ + public record DigestSpec(@Nonnull String algorithm, int digestSize) {} + + /** What an {@code SSL_METHOD}-returning function says about the protocol it speaks. */ + public record MethodSpec(@Nonnull String protocol, @Nullable String version) {} + + // ===================================================================== + // Symmetric ciphers: the EVP_() functions + // ===================================================================== + + public static final String AES = "AES"; + public static final String ARIA = "ARIA"; + public static final String CAMELLIA = "CAMELLIA"; + public static final String CHACHA20 = "CHACHA20"; + public static final String CHACHA20_POLY1305 = "CHACHA20-POLY1305"; + public static final String DES = "DES"; + public static final String DES_EDE = "DES-EDE"; + public static final String DES_EDE3 = "DES-EDE3"; + public static final String RC2 = "RC2"; + public static final String RC4 = "RC4"; + public static final String RC5 = "RC5"; + public static final String BLOWFISH = "BLOWFISH"; + public static final String CAST5 = "CAST5"; + public static final String IDEA = "IDEA"; + public static final String SEED = "SEED"; + public static final String SM4 = "SM4"; + + private static final String CBC = "CBC"; + private static final String ECB = "ECB"; + private static final String OFB = "OFB"; + private static final String CTR = "CTR"; + private static final String GCM = "GCM"; + private static final String CCM = "CCM"; + private static final String OCB = "OCB"; + private static final String XTS = "XTS"; + private static final String KW = "KW"; + private static final String KWP = "KWP"; + private static final String CFB1 = "CFB1"; + private static final String CFB8 = "CFB8"; + private static final String CFB64 = "CFB64"; + private static final String CFB128 = "CFB128"; + + @Nonnull private static final Map CIPHERS = buildCiphers(); + + @Nonnull + private static Map buildCiphers() { + final Map map = new LinkedHashMap<>(); + + // AES: three key lengths across every mode evp.h declares, plus XTS, which exists for + // 128 and 256 only and whose name states the length of the *pair* of keys. + for (int bits : new int[] {128, 192, 256}) { + map.put("EVP_aes_" + bits + "_cbc", new SymmetricSpec(AES, bits, CBC)); + map.put("EVP_aes_" + bits + "_ecb", new SymmetricSpec(AES, bits, ECB)); + map.put("EVP_aes_" + bits + "_ofb", new SymmetricSpec(AES, bits, OFB)); + map.put("EVP_aes_" + bits + "_ctr", new SymmetricSpec(AES, bits, CTR)); + map.put("EVP_aes_" + bits + "_gcm", new SymmetricSpec(AES, bits, GCM)); + map.put("EVP_aes_" + bits + "_ccm", new SymmetricSpec(AES, bits, CCM)); + map.put("EVP_aes_" + bits + "_ocb", new SymmetricSpec(AES, bits, OCB)); + map.put("EVP_aes_" + bits + "_cfb1", new SymmetricSpec(AES, bits, CFB1)); + map.put("EVP_aes_" + bits + "_cfb8", new SymmetricSpec(AES, bits, CFB8)); + map.put("EVP_aes_" + bits + "_cfb128", new SymmetricSpec(AES, bits, CFB128)); + map.put("EVP_aes_" + bits + "_wrap", new SymmetricSpec(AES, bits, KW)); + map.put("EVP_aes_" + bits + "_wrap_pad", new SymmetricSpec(AES, bits, KWP)); + } + map.put("EVP_aes_128_xts", new SymmetricSpec(AES, 128, XTS)); + map.put("EVP_aes_256_xts", new SymmetricSpec(AES, 256, XTS)); + + // ARIA and Camellia: the same shape, from the same header. + for (int bits : new int[] {128, 192, 256}) { + map.put("EVP_aria_" + bits + "_cbc", new SymmetricSpec(ARIA, bits, CBC)); + map.put("EVP_aria_" + bits + "_ecb", new SymmetricSpec(ARIA, bits, ECB)); + map.put("EVP_aria_" + bits + "_ofb", new SymmetricSpec(ARIA, bits, OFB)); + map.put("EVP_aria_" + bits + "_ctr", new SymmetricSpec(ARIA, bits, CTR)); + map.put("EVP_aria_" + bits + "_gcm", new SymmetricSpec(ARIA, bits, GCM)); + map.put("EVP_aria_" + bits + "_ccm", new SymmetricSpec(ARIA, bits, CCM)); + map.put("EVP_aria_" + bits + "_cfb1", new SymmetricSpec(ARIA, bits, CFB1)); + map.put("EVP_aria_" + bits + "_cfb8", new SymmetricSpec(ARIA, bits, CFB8)); + map.put("EVP_aria_" + bits + "_cfb128", new SymmetricSpec(ARIA, bits, CFB128)); + + map.put("EVP_camellia_" + bits + "_cbc", new SymmetricSpec(CAMELLIA, bits, CBC)); + map.put("EVP_camellia_" + bits + "_ecb", new SymmetricSpec(CAMELLIA, bits, ECB)); + map.put("EVP_camellia_" + bits + "_ofb", new SymmetricSpec(CAMELLIA, bits, OFB)); + map.put("EVP_camellia_" + bits + "_ctr", new SymmetricSpec(CAMELLIA, bits, CTR)); + map.put("EVP_camellia_" + bits + "_cfb1", new SymmetricSpec(CAMELLIA, bits, CFB1)); + map.put("EVP_camellia_" + bits + "_cfb8", new SymmetricSpec(CAMELLIA, bits, CFB8)); + map.put("EVP_camellia_" + bits + "_cfb128", new SymmetricSpec(CAMELLIA, bits, CFB128)); + } + + // ChaCha20's key is 256 bits by construction; the Poly1305 variant is the AEAD and has no + // mode of operation to name. + map.put("EVP_chacha20", new SymmetricSpec(CHACHA20, 256, null)); + map.put("EVP_chacha20_poly1305", new SymmetricSpec(CHACHA20_POLY1305, 256, null)); + + // DES. No key length is recorded: the mapper's own DES node carries 56 bits, and the + // function name states nothing beyond the algorithm and the mode. + map.put("EVP_des_cbc", new SymmetricSpec(DES, KEY_LENGTH_UNSPECIFIED, CBC)); + map.put("EVP_des_ecb", new SymmetricSpec(DES, KEY_LENGTH_UNSPECIFIED, ECB)); + map.put("EVP_des_ofb", new SymmetricSpec(DES, KEY_LENGTH_UNSPECIFIED, OFB)); + map.put("EVP_des_cfb1", new SymmetricSpec(DES, KEY_LENGTH_UNSPECIFIED, CFB1)); + map.put("EVP_des_cfb8", new SymmetricSpec(DES, KEY_LENGTH_UNSPECIFIED, CFB8)); + map.put("EVP_des_cfb64", new SymmetricSpec(DES, KEY_LENGTH_UNSPECIFIED, CFB64)); + + // Two-key and three-key Triple DES. The distinction is in the function name -- `ede` is + // K1,K2,K1 and `ede3` is K1,K2,K3 -- and it is the whole point of recording the key + // length here: 112 and 168 bits of keying material respectively, which is what the + // standards and CycloneDX count. The raw key buffers are 128 and 192 bits, parity + // included, and are not what a bill of materials is asked for. + map.put("EVP_des_ede", new SymmetricSpec(DES_EDE, 112, null)); + map.put("EVP_des_ede_cbc", new SymmetricSpec(DES_EDE, 112, CBC)); + map.put("EVP_des_ede_ecb", new SymmetricSpec(DES_EDE, 112, ECB)); + map.put("EVP_des_ede_ofb", new SymmetricSpec(DES_EDE, 112, OFB)); + map.put("EVP_des_ede_cfb64", new SymmetricSpec(DES_EDE, 112, CFB64)); + map.put("EVP_des_ede3", new SymmetricSpec(DES_EDE3, 168, null)); + map.put("EVP_des_ede3_cbc", new SymmetricSpec(DES_EDE3, 168, CBC)); + map.put("EVP_des_ede3_ecb", new SymmetricSpec(DES_EDE3, 168, ECB)); + map.put("EVP_des_ede3_ofb", new SymmetricSpec(DES_EDE3, 168, OFB)); + map.put("EVP_des_ede3_cfb1", new SymmetricSpec(DES_EDE3, 168, CFB1)); + map.put("EVP_des_ede3_cfb8", new SymmetricSpec(DES_EDE3, 168, CFB8)); + map.put("EVP_des_ede3_cfb64", new SymmetricSpec(DES_EDE3, 168, CFB64)); + map.put("EVP_des_ede3_wrap", new SymmetricSpec(DES_EDE3, 168, KW)); + + // RC2. Only the two names that state a key length carry one; plain `EVP_rc2_*` defaults + // to 128 bits but can be changed with EVP_CIPHER_CTX_set_key_length, so it is left open. + map.put("EVP_rc2_cbc", new SymmetricSpec(RC2, KEY_LENGTH_UNSPECIFIED, CBC)); + map.put("EVP_rc2_ecb", new SymmetricSpec(RC2, KEY_LENGTH_UNSPECIFIED, ECB)); + map.put("EVP_rc2_ofb", new SymmetricSpec(RC2, KEY_LENGTH_UNSPECIFIED, OFB)); + map.put("EVP_rc2_cfb64", new SymmetricSpec(RC2, KEY_LENGTH_UNSPECIFIED, CFB64)); + map.put("EVP_rc2_40_cbc", new SymmetricSpec(RC2, 40, CBC)); + map.put("EVP_rc2_64_cbc", new SymmetricSpec(RC2, 64, CBC)); + + // RC4 is a stream cipher, so there is no mode. `EVP_rc4_40` is the export-grade variant. + map.put("EVP_rc4", new SymmetricSpec(RC4, KEY_LENGTH_UNSPECIFIED, null)); + map.put("EVP_rc4_40", new SymmetricSpec(RC4, 40, null)); + + // RC5: the name fixes a 32-bit word, 12 rounds and a 16-byte (128-bit) key. + map.put("EVP_rc5_32_12_16_cbc", new SymmetricSpec(RC5, 128, CBC)); + map.put("EVP_rc5_32_12_16_ecb", new SymmetricSpec(RC5, 128, ECB)); + map.put("EVP_rc5_32_12_16_ofb", new SymmetricSpec(RC5, 128, OFB)); + map.put("EVP_rc5_32_12_16_cfb64", new SymmetricSpec(RC5, 128, CFB64)); + + // Blowfish and CAST5 both take a variable-length key, so none is recorded. + map.put("EVP_bf_cbc", new SymmetricSpec(BLOWFISH, KEY_LENGTH_UNSPECIFIED, CBC)); + map.put("EVP_bf_ecb", new SymmetricSpec(BLOWFISH, KEY_LENGTH_UNSPECIFIED, ECB)); + map.put("EVP_bf_ofb", new SymmetricSpec(BLOWFISH, KEY_LENGTH_UNSPECIFIED, OFB)); + map.put("EVP_bf_cfb64", new SymmetricSpec(BLOWFISH, KEY_LENGTH_UNSPECIFIED, CFB64)); + map.put("EVP_cast5_cbc", new SymmetricSpec(CAST5, KEY_LENGTH_UNSPECIFIED, CBC)); + map.put("EVP_cast5_ecb", new SymmetricSpec(CAST5, KEY_LENGTH_UNSPECIFIED, ECB)); + map.put("EVP_cast5_ofb", new SymmetricSpec(CAST5, KEY_LENGTH_UNSPECIFIED, OFB)); + map.put("EVP_cast5_cfb64", new SymmetricSpec(CAST5, KEY_LENGTH_UNSPECIFIED, CFB64)); + + // IDEA, SEED and SM4 all have a fixed 128-bit key. + map.put("EVP_idea_cbc", new SymmetricSpec(IDEA, 128, CBC)); + map.put("EVP_idea_ecb", new SymmetricSpec(IDEA, 128, ECB)); + map.put("EVP_idea_ofb", new SymmetricSpec(IDEA, 128, OFB)); + map.put("EVP_idea_cfb64", new SymmetricSpec(IDEA, 128, CFB64)); + map.put("EVP_seed_cbc", new SymmetricSpec(SEED, 128, CBC)); + map.put("EVP_seed_ecb", new SymmetricSpec(SEED, 128, ECB)); + map.put("EVP_seed_ofb", new SymmetricSpec(SEED, 128, OFB)); + map.put("EVP_seed_cfb128", new SymmetricSpec(SEED, 128, CFB128)); + map.put("EVP_sm4_cbc", new SymmetricSpec(SM4, 128, CBC)); + map.put("EVP_sm4_ecb", new SymmetricSpec(SM4, 128, ECB)); + map.put("EVP_sm4_ofb", new SymmetricSpec(SM4, 128, OFB)); + map.put("EVP_sm4_ctr", new SymmetricSpec(SM4, 128, CTR)); + map.put("EVP_sm4_cfb128", new SymmetricSpec(SM4, 128, CFB128)); + + // Deliberately absent, although evp.h declares them: + // - EVP_enc_null, the identity "cipher". The mapper has no node for "no encryption", and + // a component named NULL would read as an algorithm rather than as its absence. + // - EVP_desx_cbc (DES-X). No mapper model, and reporting it as DES would understate the + // key length while reporting it as DESede would overstate the construction. + // - EVP_aes_{128,256}_cbc_hmac_sha{1,256}, the TLS 1.0 record-layer stitched ciphers. + // They are AES-CBC with an integrated HMAC, which is two algorithms in one node, and + // the mapper cannot say that; reporting only the AES half would drop the MAC. + // - EVP_rc4_hmac_md5, for the same reason. + // - The CCM/OCB/GCM variants of ARIA and Camellia that evp.h does declare are included + // above; the ones it does not are simply not there. + return Collections.unmodifiableMap(map); + } + + /** + * The OpenSSL algorithm-name strings accepted by {@code EVP_CIPHER_fetch} and {@code + * EVP_get_cipherbyname}, mapped onto the same specifications. + * + *

Derived from the function table rather than written out again: OpenSSL's provider name for + * a cipher is the function's own suffix in upper case with {@code -} between the parts, which + * is exactly what the keys below are built from. Only the names that can be produced that way + * are accepted, so a fetch of something this table does not know yields nothing. + */ + @Nonnull + private static final Map CIPHER_ALGORITHM_NAMES = + buildCipherAlgorithmNames(); + + @Nonnull + private static Map buildCipherAlgorithmNames() { + final Map map = new LinkedHashMap<>(); + for (Map.Entry entry : CIPHERS.entrySet()) { + final String fetchName = + entry.getKey() + .substring("EVP_".length()) + .replace("_", "-") + .toUpperCase(java.util.Locale.ROOT); + map.putIfAbsent(fetchName, entry.getValue()); + } + // The spellings OpenSSL's providers actually register that the mechanical rule above does + // not produce. Verified against doc/man7/EVP_CIPHER-AES.pod and the provider's + // ossl_cipher_* name tables. + map.put("AES-128-CFB", new SymmetricSpec(AES, 128, CFB128)); + map.put("AES-192-CFB", new SymmetricSpec(AES, 192, CFB128)); + map.put("AES-256-CFB", new SymmetricSpec(AES, 256, CFB128)); + map.put("CHACHA20-POLY1305", new SymmetricSpec(CHACHA20_POLY1305, 256, null)); + map.put("DES-EDE3-CBC", new SymmetricSpec(DES_EDE3, 168, CBC)); + map.put("DES-EDE-CBC", new SymmetricSpec(DES_EDE, 112, CBC)); + return Collections.unmodifiableMap(map); + } + + // ===================================================================== + // Message digests: the EVP_() functions + // ===================================================================== + + public static final String SHA1 = "SHA1"; + public static final String SHA2 = "SHA2"; + public static final String SHA3 = "SHA3"; + public static final String SHAKE = "SHAKE"; + public static final String MD2 = "MD2"; + public static final String MD4 = "MD4"; + public static final String MD5 = "MD5"; + public static final String RIPEMD = "RIPEMD"; + public static final String BLAKE2B = "BLAKE2B"; + public static final String BLAKE2S = "BLAKE2S"; + public static final String SM3 = "SM3"; + public static final String WHIRLPOOL = "WHIRLPOOL"; + + @Nonnull private static final Map DIGESTS = buildDigests(); + + @Nonnull + private static Map buildDigests() { + final Map map = new LinkedHashMap<>(); + map.put("EVP_sha1", new DigestSpec(SHA1, 160)); + map.put("EVP_sha224", new DigestSpec(SHA2, 224)); + map.put("EVP_sha256", new DigestSpec(SHA2, 256)); + map.put("EVP_sha384", new DigestSpec(SHA2, 384)); + map.put("EVP_sha512", new DigestSpec(SHA2, 512)); + map.put("EVP_sha3_224", new DigestSpec(SHA3, 224)); + map.put("EVP_sha3_256", new DigestSpec(SHA3, 256)); + map.put("EVP_sha3_384", new DigestSpec(SHA3, 384)); + map.put("EVP_sha3_512", new DigestSpec(SHA3, 512)); + map.put("EVP_shake128", new DigestSpec(SHAKE, 128)); + map.put("EVP_shake256", new DigestSpec(SHAKE, 256)); + map.put("EVP_md2", new DigestSpec(MD2, 128)); + map.put("EVP_md4", new DigestSpec(MD4, 128)); + map.put("EVP_md5", new DigestSpec(MD5, 128)); + map.put("EVP_ripemd160", new DigestSpec(RIPEMD, 160)); + map.put("EVP_blake2b512", new DigestSpec(BLAKE2B, 512)); + map.put("EVP_blake2s256", new DigestSpec(BLAKE2S, 256)); + map.put("EVP_sm3", new DigestSpec(SM3, 256)); + map.put("EVP_whirlpool", new DigestSpec(WHIRLPOOL, 512)); + // SHA-512/224 and SHA-512/256 are truncated SHA-512. The mapper's SHA2 node spells a + // truncation with a pre-hash child, which is not what these are, so they are recorded by + // their own digest size under the SHA-2 family -- the name SHA2 composes is "SHA-224" and + // "SHA-256", which understates the construction. They are therefore left out rather than + // reported as something they are not. + // + // Also deliberately absent: EVP_md5_sha1 (the concatenated TLS 1.0/1.1 handshake hash, + // which has no mapper model and is not one digest), EVP_mdc2 (no mapper model) and + // EVP_md_null. + return Collections.unmodifiableMap(map); + } + + /** + * The algorithm-name strings accepted by {@code EVP_MD_fetch} / {@code EVP_get_digestbyname}. + */ + @Nonnull + private static final Map DIGEST_ALGORITHM_NAMES = + buildDigestAlgorithmNames(); + + @Nonnull + private static Map buildDigestAlgorithmNames() { + final Map map = new LinkedHashMap<>(); + map.put("SHA1", new DigestSpec(SHA1, 160)); + map.put("SHA-1", new DigestSpec(SHA1, 160)); + map.put("SHA2-224", new DigestSpec(SHA2, 224)); + map.put("SHA2-256", new DigestSpec(SHA2, 256)); + map.put("SHA2-384", new DigestSpec(SHA2, 384)); + map.put("SHA2-512", new DigestSpec(SHA2, 512)); + map.put("SHA224", new DigestSpec(SHA2, 224)); + map.put("SHA256", new DigestSpec(SHA2, 256)); + map.put("SHA384", new DigestSpec(SHA2, 384)); + map.put("SHA512", new DigestSpec(SHA2, 512)); + map.put("SHA-224", new DigestSpec(SHA2, 224)); + map.put("SHA-256", new DigestSpec(SHA2, 256)); + map.put("SHA-384", new DigestSpec(SHA2, 384)); + map.put("SHA-512", new DigestSpec(SHA2, 512)); + map.put("SHA3-224", new DigestSpec(SHA3, 224)); + map.put("SHA3-256", new DigestSpec(SHA3, 256)); + map.put("SHA3-384", new DigestSpec(SHA3, 384)); + map.put("SHA3-512", new DigestSpec(SHA3, 512)); + map.put("SHAKE-128", new DigestSpec(SHAKE, 128)); + map.put("SHAKE-256", new DigestSpec(SHAKE, 256)); + map.put("SHAKE128", new DigestSpec(SHAKE, 128)); + map.put("SHAKE256", new DigestSpec(SHAKE, 256)); + map.put("MD2", new DigestSpec(MD2, 128)); + map.put("MD4", new DigestSpec(MD4, 128)); + map.put("MD5", new DigestSpec(MD5, 128)); + map.put("RIPEMD160", new DigestSpec(RIPEMD, 160)); + map.put("RIPEMD-160", new DigestSpec(RIPEMD, 160)); + map.put("BLAKE2B-512", new DigestSpec(BLAKE2B, 512)); + map.put("BLAKE2S-256", new DigestSpec(BLAKE2S, 256)); + map.put("SM3", new DigestSpec(SM3, 256)); + map.put("WHIRLPOOL", new DigestSpec(WHIRLPOOL, 512)); + return Collections.unmodifiableMap(map); + } + + // ===================================================================== + // TLS: methods, version constants, cipher selection + // ===================================================================== + + public static final String TLS = "TLS"; + public static final String SSL = "SSL"; + public static final String DTLS = "DTLS"; + + /** + * The {@code SSL_METHOD}-returning functions of {@code doc/man3/SSL_CTX_new.pod}. + * + *

A version-flexible method ({@code TLS_method} and its {@code SSLv23_} alias, {@code + * DTLS_method}) negotiates the best version both ends support, so it states the protocol and no + * version -- which is exactly what the call site knows. The deprecated {@code TLSv1_x_method} + * and {@code SSLv3_method} families are version-locked and state both. + */ + @Nonnull private static final Map SSL_METHODS = buildSslMethods(); + + @Nonnull + private static Map buildSslMethods() { + final Map map = new LinkedHashMap<>(); + for (String suffix : new String[] {"method", "server_method", "client_method"}) { + map.put("TLS_" + suffix, new MethodSpec(TLS, null)); + map.put("SSLv23_" + suffix, new MethodSpec(TLS, null)); + map.put("TLSv1_" + suffix, new MethodSpec(TLS, "1.0")); + map.put("TLSv1_1_" + suffix, new MethodSpec(TLS, "1.1")); + map.put("TLSv1_2_" + suffix, new MethodSpec(TLS, "1.2")); + map.put("SSLv3_" + suffix, new MethodSpec(SSL, "3")); + map.put("SSLv2_" + suffix, new MethodSpec(SSL, "2")); + map.put("DTLS_" + suffix, new MethodSpec(DTLS, null)); + map.put("DTLSv1_" + suffix, new MethodSpec(DTLS, "1.0")); + map.put("DTLSv1_2_" + suffix, new MethodSpec(DTLS, "1.2")); + } + return Collections.unmodifiableMap(map); + } + + /** + * The protocol version constants of {@code include/openssl/prov_ssl.h}, by name and by wire + * value. + * + *

Both spellings are accepted because both appear in P/Invoke code. A wrapper that declares + * the constants on a class is read as a member access and reaches the translator as the name + * {@code "TLS1_2_VERSION"}; one that declares them as C# {@code const int} fields is resolved + * through the constant to the number {@code 0x0303}. The numbers are the versions as they go on + * the wire, fixed by the TLS RFCs, so reading them is not a guess. + * + *

{@code 0} is absent on purpose. {@code SSL_CTX_set_min_proto_version(ctx, 0)} means "down + * to whatever this build supports", which names no version at all. + */ + @Nonnull + private static final Map PROTOCOL_VERSIONS = buildProtocolVersions(); + + @Nonnull + private static Map buildProtocolVersions() { + final Map map = new LinkedHashMap<>(); + map.put("SSL3_VERSION", new MethodSpec(SSL, "3")); + map.put("TLS1_VERSION", new MethodSpec(TLS, "1.0")); + map.put("TLS1_1_VERSION", new MethodSpec(TLS, "1.1")); + map.put("TLS1_2_VERSION", new MethodSpec(TLS, "1.2")); + map.put("TLS1_3_VERSION", new MethodSpec(TLS, "1.3")); + map.put("DTLS1_VERSION", new MethodSpec(DTLS, "1.0")); + map.put("DTLS1_2_VERSION", new MethodSpec(DTLS, "1.2")); + map.put("768", new MethodSpec(SSL, "3")); // 0x0300 + map.put("769", new MethodSpec(TLS, "1.0")); // 0x0301 + map.put("770", new MethodSpec(TLS, "1.1")); // 0x0302 + map.put("771", new MethodSpec(TLS, "1.2")); // 0x0303 + map.put("772", new MethodSpec(TLS, "1.3")); // 0x0304 + map.put("65279", new MethodSpec(DTLS, "1.0")); // 0xFEFF + map.put("65277", new MethodSpec(DTLS, "1.2")); // 0xFEFD + return Collections.unmodifiableMap(map); + } + + // ===================================================================== + // RSA padding + // ===================================================================== + + /** + * The RSA padding constants of {@code include/openssl/rsa.h}, by name and by value. + * + *

The values are part of OpenSSL's ABI and have not moved since 0.9.x, so the numeric form a + * C# {@code const int} resolves to is read as well as the name. {@code + * RSA_PKCS1_NO_IMPLICIT_REJECT_PADDING} (8) is absent: it is a decryption behaviour flag on + * PKCS#1 v1.5, not a padding of its own, and reporting it as one would overstate it. {@code + * RSA_PKCS1_PADDING_SIZE} is not a padding constant at all -- it is the 11-byte overhead -- and + * is excluded for that reason. + */ + @Nonnull private static final Map RSA_PADDINGS = buildRsaPaddings(); + + @Nonnull + private static Map buildRsaPaddings() { + final Map map = new LinkedHashMap<>(); + map.put("RSA_PKCS1_PADDING", "PKCS1"); + map.put("RSA_NO_PADDING", "NONE"); + map.put("RSA_PKCS1_OAEP_PADDING", "OAEP"); + map.put("RSA_X931_PADDING", "X9.31"); + map.put("RSA_PKCS1_PSS_PADDING", "PSS"); + // PKCS#1 v1.5 with the TLS-specific version check on decryption: the padding is PKCS#1. + map.put("RSA_PKCS1_WITH_TLS_PADDING", "PKCS1"); + map.put("1", "PKCS1"); + map.put("3", "NONE"); + map.put("4", "OAEP"); + map.put("5", "X9.31"); + map.put("6", "PSS"); + map.put("7", "PKCS1"); + return Collections.unmodifiableMap(map); + } + + // ===================================================================== + // Curve NIDs + // ===================================================================== + + /** + * The elliptic-curve NIDs of {@code include/openssl/obj_mac.h}, by name and by value, each + * mapped to the curve's OpenSSL short name ({@code SN_*}). + * + *

{@code NID_X9_62_prime256v1} is the NIST P-256 curve and {@code NID_X9_62_prime192v1} is + * P-192; they are recorded under the short name OpenSSL itself prints, {@code prime256v1} and + * {@code prime192v1}, rather than translated to the NIST spelling, so that what the CBOM says + * is what the source says. + */ + @Nonnull private static final Map CURVE_NIDS = buildCurveNids(); + + @Nonnull + private static Map buildCurveNids() { + final Map map = new LinkedHashMap<>(); + addCurve(map, "X9_62_prime192v1", 409, "prime192v1"); + addCurve(map, "X9_62_prime192v2", 410, "prime192v2"); + addCurve(map, "X9_62_prime192v3", 411, "prime192v3"); + addCurve(map, "X9_62_prime239v1", 412, "prime239v1"); + addCurve(map, "X9_62_prime239v2", 413, "prime239v2"); + addCurve(map, "X9_62_prime239v3", 414, "prime239v3"); + addCurve(map, "X9_62_prime256v1", 415, "prime256v1"); + addCurve(map, "secp112r1", 704, "secp112r1"); + addCurve(map, "secp112r2", 705, "secp112r2"); + addCurve(map, "secp128r1", 706, "secp128r1"); + addCurve(map, "secp128r2", 707, "secp128r2"); + addCurve(map, "secp160k1", 708, "secp160k1"); + addCurve(map, "secp160r1", 709, "secp160r1"); + addCurve(map, "secp160r2", 710, "secp160r2"); + addCurve(map, "secp192k1", 711, "secp192k1"); + addCurve(map, "secp224k1", 712, "secp224k1"); + addCurve(map, "secp224r1", 713, "secp224r1"); + addCurve(map, "secp256k1", 714, "secp256k1"); + addCurve(map, "secp384r1", 715, "secp384r1"); + addCurve(map, "secp521r1", 716, "secp521r1"); + addCurve(map, "sect163k1", 721, "sect163k1"); + addCurve(map, "sect163r1", 722, "sect163r1"); + addCurve(map, "sect163r2", 723, "sect163r2"); + addCurve(map, "sect193r1", 724, "sect193r1"); + addCurve(map, "sect193r2", 725, "sect193r2"); + addCurve(map, "sect233k1", 726, "sect233k1"); + addCurve(map, "sect233r1", 727, "sect233r1"); + addCurve(map, "sect239k1", 728, "sect239k1"); + addCurve(map, "sect283k1", 729, "sect283k1"); + addCurve(map, "sect283r1", 730, "sect283r1"); + addCurve(map, "sect409k1", 731, "sect409k1"); + addCurve(map, "sect409r1", 732, "sect409r1"); + addCurve(map, "sect571k1", 733, "sect571k1"); + addCurve(map, "sect571r1", 734, "sect571r1"); + addCurve(map, "brainpoolP160r1", 921, "brainpoolP160r1"); + addCurve(map, "brainpoolP192r1", 923, "brainpoolP192r1"); + addCurve(map, "brainpoolP224r1", 925, "brainpoolP224r1"); + addCurve(map, "brainpoolP256r1", 927, "brainpoolP256r1"); + addCurve(map, "brainpoolP320r1", 929, "brainpoolP320r1"); + addCurve(map, "brainpoolP384r1", 931, "brainpoolP384r1"); + addCurve(map, "brainpoolP512r1", 933, "brainpoolP512r1"); + return Collections.unmodifiableMap(map); + } + + private static void addCurve( + @Nonnull Map map, + @Nonnull String nidSuffix, + int nid, + @Nonnull String shortName) { + map.put("NID_" + nidSuffix, shortName); + map.put(Integer.toString(nid), shortName); + } + + /** + * The curve names {@code EVP_PKEY_CTX_set_group_name} and {@code EVP_PKEY_Q_keygen("EC", ...)} + * accept, mapped to themselves plus the two spellings OpenSSL treats as aliases of P-256 and + * friends. Only a name in this set is reported, so an unresolvable group name yields nothing. + */ + @Nonnull private static final Map CURVE_NAMES = buildCurveNames(); + + @Nonnull + private static Map buildCurveNames() { + final Map map = new LinkedHashMap<>(); + for (Map.Entry entry : CURVE_NIDS.entrySet()) { + map.putIfAbsent(entry.getValue(), entry.getValue()); + } + // The NIST aliases OpenSSL accepts for the three X9.62/SECG prime curves. + map.put("P-192", "prime192v1"); + map.put("P-224", "secp224r1"); + map.put("P-256", "prime256v1"); + map.put("P-384", "secp384r1"); + map.put("P-521", "secp521r1"); + return Collections.unmodifiableMap(map); + } + + // ===================================================================== + // KDFs + // ===================================================================== + + /** + * The {@code EVP_KDF_fetch} algorithm names of {@code include/openssl/core_names.h.in}, for the + * ones the mapper has a model for. + * + *

Left out, with no model to put them in: {@code TLS1-PRF}, {@code TLS13-KDF}, {@code + * SSHKDF}, {@code ARGON2I}, {@code ARGON2ID}, {@code HMAC-DRBG-KDF} and {@code PVKKDF}. A + * generic node named after the string would add the name and nothing else, and the name is + * already in the finding's location keyword. + */ + @Nonnull private static final Map KDF_NAMES = buildKdfNames(); + + @Nonnull + private static Map buildKdfNames() { + final Map map = new LinkedHashMap<>(); + map.put("PBKDF2", "PBKDF2"); + map.put("PBKDF1", "PBKDF1"); + map.put("SCRYPT", "SCRYPT"); + map.put("ID-SCRYPT", "SCRYPT"); + map.put("HKDF", "HKDF"); + map.put("SSKDF", "SSKDF"); + map.put("X942KDF-ASN1", "X942KDF"); + map.put("X942KDF-CONCAT", "X942KDF"); + map.put("X963KDF", "X963KDF"); + map.put("KBKDF", "KBKDF"); + map.put("KRB5KDF", "KRB5KDF"); + map.put("PKCS12KDF", "PKCS12KDF"); + return Collections.unmodifiableMap(map); + } + + // ===================================================================== + // Accessors + // ===================================================================== + + @Nonnull + public static Set cipherFunctionNames() { + return CIPHERS.keySet(); + } + + @Nonnull + public static Set digestFunctionNames() { + return DIGESTS.keySet(); + } + + @Nonnull + public static Set sslMethodNames() { + return SSL_METHODS.keySet(); + } + + @Nonnull + public static Optional cipherFunction(@Nonnull String functionName) { + return Optional.ofNullable(CIPHERS.get(functionName)); + } + + @Nonnull + public static Optional cipherAlgorithmName(@Nonnull String algorithmName) { + return Optional.ofNullable( + CIPHER_ALGORITHM_NAMES.get( + algorithmName.trim().toUpperCase(java.util.Locale.ROOT))); + } + + @Nonnull + public static Optional digestFunction(@Nonnull String functionName) { + return Optional.ofNullable(DIGESTS.get(functionName)); + } + + @Nonnull + public static Optional digestAlgorithmName(@Nonnull String algorithmName) { + return Optional.ofNullable( + DIGEST_ALGORITHM_NAMES.get( + algorithmName.trim().toUpperCase(java.util.Locale.ROOT))); + } + + @Nonnull + public static Optional sslMethod(@Nonnull String functionName) { + return Optional.ofNullable(SSL_METHODS.get(functionName)); + } + + @Nonnull + public static Optional protocolVersion(@Nonnull String nameOrValue) { + return Optional.ofNullable(PROTOCOL_VERSIONS.get(nameOrValue.trim())); + } + + @Nonnull + public static Optional rsaPadding(@Nonnull String nameOrValue) { + return Optional.ofNullable(RSA_PADDINGS.get(nameOrValue.trim())); + } + + @Nonnull + public static Optional curveNid(@Nonnull String nameOrValue) { + return Optional.ofNullable(CURVE_NIDS.get(nameOrValue.trim())); + } + + @Nonnull + public static Optional curveName(@Nonnull String name) { + return Optional.ofNullable(CURVE_NAMES.get(name.trim())); + } + + /** + * The MACs {@code EVP_MAC_fetch} can be asked for, and that this project can name. + * + *

OpenSSL 3.5 ships nine MAC implementations. Five are here. The four that are not: + * + *

    + *
  • {@code GMAC} -- the mapper has GMAC only as a cipher mode, not as an algorithm + * of its own, so there is no node to report. A GMAC is AES-GCM used as a MAC, and naming + * it AES would state an encryption that is not happening. + *
  • {@code BLAKE2BMAC} and {@code BLAKE2SMAC} -- the mapper has BLAKE2b and BLAKE2s as + * message digests. A keyed MAC is not a digest, and reporting the digest node would say + * the code hashes where it authenticates. + *
+ * + *

Names are matched case-insensitively because OpenSSL's own fetch is: {@code "hmac"} and + * {@code "HMAC"} reach the same implementation. + */ + @Nonnull + private static final Map MAC_NAMES = + Map.ofEntries( + Map.entry("HMAC", "HMAC"), + Map.entry("CMAC", "CMAC"), + Map.entry("KMAC-128", "KMAC-128"), + Map.entry("KMAC128", "KMAC-128"), + Map.entry("KMAC-256", "KMAC-256"), + Map.entry("KMAC256", "KMAC-256"), + Map.entry("POLY1305", "Poly1305"), + Map.entry("SIPHASH", "SipHash")); + + @Nonnull + public static Optional macName(@Nonnull String algorithmName) { + return Optional.ofNullable( + MAC_NAMES.get(algorithmName.trim().toUpperCase(java.util.Locale.ROOT))); + } + + @Nonnull + public static Optional kdfName(@Nonnull String algorithmName) { + return Optional.ofNullable( + KDF_NAMES.get(algorithmName.trim().toUpperCase(java.util.Locale.ROOT))); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipher.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipher.java new file mode 100644 index 000000000..01776253e --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipher.java @@ -0,0 +1,181 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.CipherContext; +import com.ibm.engine.model.factory.AlgorithmFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +/** + * The symmetric half of OpenSSL's EVP interface, as C# reaches it through P/Invoke. + * + *

Where the algorithm is written

+ * + *

An OpenSSL cipher is never named in the call that encrypts. {@code EVP_EncryptInit_ex(ctx, + * type, impl, key, iv)} takes an {@code EVP_CIPHER *}, and that pointer comes from a second call + * which is where the whole identity lives -- {@code EVP_aes_256_gcm()} is the algorithm, the key + * length and the mode in one function name. This rule set therefore reads the cipher-returning + * functions and not the {@code *Init*} family: + * + *

{@code
+ * EVP_EncryptInit_ex(ctx, Native.EVP_aes_256_gcm(), IntPtr.Zero, key, iv);
+ * }
+ * + *

works because the C# frontend emits a call written as an argument as a statement in its own + * right (see {@code CSharpTreeConverter#convertArgument}), so the inner call is a detection of its + * own. The same holds for the shape managed wrappers actually use, where the handle is kept in a + * field or a static: + * + *

{@code
+ * public static Cipher AES_256_GCM = new Cipher(Native.EVP_aes_256_gcm(), false);
+ * }
+ * + *

(that line is openssl-net's, verbatim apart from the name). + * + *

{@code EVP_EncryptInit_ex} and its eleven siblings are deliberately not rules. The + * only thing such a call adds is the direction, and a direction has nowhere to go: the engine + * attaches a child to the node its own rule produced, and the algorithm belongs to a different + * call. A rule there would have to emit a bare {@code Encrypt} component with no algorithm under + * it, or re-read the cipher argument and report the algorithm a second time. Neither is worth the + * direction, so the direction is not recorded for symmetric encryption. + * + *

Why the receiver is {@code ANY}

+ * + *

These are {@code static extern} methods, so a call site is either bare -- {@code + * EVP_aes_256_gcm()}, which the C# frontend gives an empty receiver -- or qualified with whatever + * the P/Invoke holder is called: {@code Native}, {@code NativeMethods}, {@code Interop.Crypto}, a + * name this plugin cannot know. {@code forObjectTypes} matches the literal receiver text (see + * {@code CSharpLanguageTranslation#getInvokedObjectTypeString}), so no concrete type would match + * either shape. The guard is the method name instead, and these names are safe to match on their + * own: {@code EVP_aes_256_gcm} is not a word anyone uses for anything else, and a {@code DllImport} + * declaration is a declaration rather than a call, so it is never detected. + * + *

What each rule yields

+ * + *
    + *
  • {@code EVP_()} -- the algorithm, the key length where the name states one, and the + * mode where the name states one. {@code EVP_aes_256_gcm} is AES-256-GCM; {@code EVP_rc4} is + * RC4 with no key length, because that one is set later with {@code + * EVP_CIPHER_CTX_set_key_length}. + *
  • {@code EVP_CIPHER_fetch(libctx, algorithm, properties)} and {@code + * EVP_get_cipherbyname(name)} -- the same, from the algorithm name string, when the string is + * a literal and names a cipher {@link OpenSslApi} knows. A name built at run time resolves to + * nothing. + *
+ */ +public final class OpenSslCipher extends DetectionRuleSet { + + /** Marks the action of a cipher-returning {@code EVP_*} function. See {@link OpenSslApi}. */ + public static final String EVP_CIPHER_FUNCTION = "EVP_CIPHER_FUNCTION"; + + @Nonnull + private static CipherContext context() { + return new CipherContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, "CIPHER")); + } + + /** + * Every {@code const EVP_CIPHER *EVP_(void)} the table knows, in one rule. + * + *

One rule rather than one per function, because the value that tells them apart is the + * method name itself, which the finding already carries: a detection's {@code + * DetectionLocation} is built from the matched invocation and its first keyword is that + * invocation's method name (see {@code CSharpTranslator#getDetectionContextFrom}; the CBOM + * writer uses the same keyword as a finding's additional context). {@code + * CSharpOpenSslContextTranslator} looks that name up in {@link OpenSslApi}, which is also where + * this rule takes its method names from, so the matched set and the translated set cannot + * diverge. Writing out 146 near-identical rules would say nothing more and would be 146 rules + * evaluated against every statement of every C# file in a scan. + */ + /** + * One rule per function, built in a loop from the same table the translator reads -- every + * EVP_CIPHER function OpenSSL ships, each carrying its own name as the action constant. Written + * this way rather than as one rule over the whole name list so that the matched function + * arrives as a value, which is how every other rule set in this project states what it found; + * the alternative read the name back out of the detection location's keywords, a coupling + * nothing else relies on. + */ + @Nonnull + private static List> cipher_functions() { + final List> rules = new ArrayList<>(); + for (String function : OpenSslApi.cipherFunctionNames()) { + rules.add( + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods(function) + .shouldBeDetectedAs(new ValueActionFactory<>(function)) + .withoutParameters() + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules()); + } + return List.copyOf(rules); + } + + /** + * {@code EVP_CIPHER_fetch(OSSL_LIB_CTX *ctx, const char *algorithm, const char *properties)}. + * + *

The 3.0 replacement for the static cipher functions, and the only one of the two that can + * name an algorithm the table does not list. No action is declared, so the algorithm the string + * resolves to is the finding; a string that resolves to nothing leaves the call + * unreported rather than reporting a cipher named after it. + */ + private static final IDetectionRule CIPHER_FETCH = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_CIPHER_fetch") + .withMethodParameter(MethodMatcher.ANY) // libctx + .withMethodParameter("string") // algorithm + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .withMethodParameter(MethodMatcher.ANY) // properties + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_get_cipherbyname(const char *name)} -- the pre-3.0 lookup by name. */ + private static final IDetectionRule CIPHER_BY_NAME = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_get_cipherbyname") + .withMethodParameter("string") + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return Stream.concat(cipher_functions().stream(), Stream.of(CIPHER_FETCH, CIPHER_BY_NAME)) + .toList(); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDetectionRules.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDetectionRules.java new file mode 100644 index 000000000..51fdfbc82 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDetectionRules.java @@ -0,0 +1,66 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.RuleSets; +import java.util.List; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +/** + * Every OpenSSL rule set, and the one property that routes all of them to one translator. + * + *

OpenSSL reached from C# is not a library with an object model: it is a flat set of {@code + * static extern} functions whose names are the whole vocabulary. That makes its findings spread + * across five of the engine's detection contexts -- a cipher, a digest, a MAC, a key, a protocol, a + * generator -- while sharing one set of constant tables and one notion of what may be reported. + * Each rule set therefore marks its context with the property {@link #CONTEXT_KEY}, and {@code + * CSharpTranslator} sends any context carrying that property to {@code + * CSharpOpenSslContextTranslator} before it looks at the context's class. + * + *

A separate property rather than the {@code "kind"} the .NET rule sets use, so that nothing + * here can collide with a {@code kind} the .NET, MailKit, Npgsql or fo-dicom translators already + * interpret, and so that a reader of {@code CSharpTranslator} sees one line for all of OpenSSL + * instead of a case added to each of five context translators. + */ +public final class OpenSslDetectionRules extends DetectionRuleSet { + + /** + * The detection-context property that marks a finding as OpenSSL's. Its value says which rule + * set produced the finding, and {@code CSharpOpenSslContextTranslator} switches on it. + */ + public static final String CONTEXT_KEY = "openssl"; + + @Nonnull + @Override + protected List> buildRules() { + return Stream.of( + RuleSets.rulesOf(OpenSslTls.class).stream(), + RuleSets.rulesOf(OpenSslCipher.class).stream(), + RuleSets.rulesOf(OpenSslDigest.class).stream(), + RuleSets.rulesOf(OpenSslKey.class).stream(), + RuleSets.rulesOf(OpenSslRandom.class).stream()) + .flatMap(i -> i) + .toList(); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigest.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigest.java new file mode 100644 index 000000000..328e48776 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigest.java @@ -0,0 +1,225 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.DigestContext; +import com.ibm.engine.model.context.MacContext; +import com.ibm.engine.model.factory.AlgorithmFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +/** + * OpenSSL's message digests and HMAC, as C# reaches them through P/Invoke. + * + *

The digests follow the ciphers exactly (see {@link OpenSslCipher} for why the receiver is + * {@code ANY} and why one rule carries every function name): the identity is in the name of the + * function that returns the {@code EVP_MD *}, so {@code EVP_sha3_256()} is the finding and {@code + * EVP_DigestInit_ex(ctx, md, impl)} is not a rule -- it names no algorithm. + * + *

HMAC is the one place where a rule is worth having without an algorithm under it. {@code + * HMAC(evp_md, key, key_len, data, data_len, md, md_len)} states that a keyed MAC is computed, + * which is a fact about the code that the digest function alone does not carry: {@code + * EVP_sha256()} appears in signing, in PBKDF2 and in a bare hash just as much. The digest is + * not read out of the first argument, and that is deliberate. In real wrappers it is not + * resolvable anyway -- openssl-net writes {@code Native.HMAC(digest.Handle, key, key.Length, ...)}, + * a field access on an object -- and where it is written inline as {@code HMAC(EVP_sha256(), ...)} + * the inner call is already a digest finding of its own, so reading it here as well would report + * SHA-256 twice for one line. The result is an {@code HMAC} component beside the digest it uses, + * rather than one combined component or two copies of the digest. + */ +public final class OpenSslDigest extends DetectionRuleSet { + + /** Marks the action of a digest-returning {@code EVP_*} function. See {@link OpenSslApi}. */ + public static final String EVP_MD_FUNCTION = "EVP_MD_FUNCTION"; + + /** Marks a keyed-MAC call of the {@code HMAC} family. */ + public static final String HMAC = "HMAC"; + + @Nonnull + private static DigestContext digestContext() { + return new DigestContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, "DIGEST")); + } + + @Nonnull + private static MacContext macContext() { + return new MacContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, "HMAC")); + } + + @Nonnull + private static MacContext macFetchContext() { + return new MacContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, "MAC_FETCH")); + } + + /** + * {@code EVP_MAC_fetch(OSSL_LIB_CTX *libctx, const char *algorithm, const char *properties)}. + * + *

The 3.0 way of asking for a MAC, and the only place in the OpenSSL API where a MAC is + * named rather than implied by a context. It is the exact counterpart of {@code EVP_KDF_fetch}, + * which this rule set already read -- a MAC named by string was the one half of that pair that + * was missing. {@code HMAC_*} stays separate: those name the MAC in the function itself. + * + *

Which names become a node is decided in the translator against a closed set, so {@code + * EVP_MAC_fetch(ctx, "GMAC", null)} -- a MAC this project has no algorithm node for -- reports + * nothing rather than something adjacent. + */ + private static final IDetectionRule MAC_FETCH = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_MAC_fetch") + .withMethodParameter(MethodMatcher.ANY) // libctx + .withMethodParameter("string") // algorithm + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .withMethodParameter(MethodMatcher.ANY) // properties + .buildForContext(macFetchContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** Every {@code const EVP_MD *EVP_(void)} the table knows, in one rule. */ + /** + * One rule per function, built in a loop from the same table the translator reads -- every + * EVP_MD function OpenSSL ships, each carrying its own name as the action constant. Written + * this way rather than as one rule over the whole name list so that the matched function + * arrives as a value, which is how every other rule set in this project states what it found; + * the alternative read the name back out of the detection location's keywords, a coupling + * nothing else relies on. + */ + @Nonnull + private static List> digest_functions() { + final List> rules = new ArrayList<>(); + for (String function : OpenSslApi.digestFunctionNames()) { + rules.add( + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods(function) + .shouldBeDetectedAs(new ValueActionFactory<>(function)) + .withoutParameters() + .buildForContext(digestContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules()); + } + return List.copyOf(rules); + } + + /** {@code EVP_MD_fetch(OSSL_LIB_CTX *ctx, const char *algorithm, const char *properties)}. */ + private static final IDetectionRule DIGEST_FETCH = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_MD_fetch") + .withMethodParameter(MethodMatcher.ANY) // libctx + .withMethodParameter("string") // algorithm + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .withMethodParameter(MethodMatcher.ANY) // properties + .buildForContext(digestContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_get_digestbyname(const char *name)} -- the pre-3.0 lookup by name. */ + private static final IDetectionRule DIGEST_BY_NAME = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_get_digestbyname") + .withMethodParameter("string") + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .buildForContext(digestContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code HMAC(evp_md, key, key_len, data, data_len, md, md_len)} -- the one-shot form. + * + *

Seven positional parameters, so the arity alone separates this from the two {@code + * HMAC_Init} rules below even though all three would otherwise accept the same shape. + */ + private static final IDetectionRule HMAC_ONE_SHOT = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("HMAC") + .shouldBeDetectedAs(new ValueActionFactory<>(HMAC)) + .withMethodParameter(MethodMatcher.ANY) // evp_md + .withMethodParameter(MethodMatcher.ANY) // key + .withMethodParameter(MethodMatcher.ANY) // key_len + .withMethodParameter(MethodMatcher.ANY) // data + .withMethodParameter(MethodMatcher.ANY) // data_len + .withMethodParameter(MethodMatcher.ANY) // md + .withMethodParameter(MethodMatcher.ANY) // md_len + .buildForContext(macContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code HMAC_Init_ex(ctx, key, key_len, md, impl)} -- deprecated in 3.0, still common. */ + private static final IDetectionRule HMAC_INIT_EX = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("HMAC_Init_ex") + .shouldBeDetectedAs(new ValueActionFactory<>(HMAC)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter(MethodMatcher.ANY) // key + .withMethodParameter(MethodMatcher.ANY) // key_len + .withMethodParameter(MethodMatcher.ANY) // md + .withMethodParameter(MethodMatcher.ANY) // impl + .buildForContext(macContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code HMAC_Init(ctx, key, key_len, md)} -- deprecated in 1.1.0. */ + private static final IDetectionRule HMAC_INIT = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("HMAC_Init") + .shouldBeDetectedAs(new ValueActionFactory<>(HMAC)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter(MethodMatcher.ANY) // key + .withMethodParameter(MethodMatcher.ANY) // key_len + .withMethodParameter(MethodMatcher.ANY) // md + .buildForContext(macContext()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return Stream.concat( + digest_functions().stream(), + Stream.of( + DIGEST_FETCH, + DIGEST_BY_NAME, + HMAC_ONE_SHOT, + HMAC_INIT_EX, + HMAC_INIT, + MAC_FETCH)) + .toList(); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslKey.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslKey.java new file mode 100644 index 000000000..77720b8a3 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslKey.java @@ -0,0 +1,496 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.Size; +import com.ibm.engine.model.context.KeyContext; +import com.ibm.engine.model.factory.AlgorithmFactory; +import com.ibm.engine.model.factory.IterationCountFactory; +import com.ibm.engine.model.factory.KeySizeFactory; +import com.ibm.engine.model.factory.ParameterIdentifierFactory; +import com.ibm.engine.model.factory.SaltSizeFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * OpenSSL's asymmetric key generation, RSA padding and password-based key derivation. + * + *

Key generation

+ * + *

Unlike the symmetric side, these calls do name the algorithm -- in the function name for the + * RSA, DSA, DH and EC entry points, and in a string argument for {@code EVP_PKEY_Q_keygen}. The + * size or the curve sits beside it in the same call, so each finding is one component with its + * parameter attached: + * + *

    + *
  • {@code RSA_generate_key_ex(rsa, 4096, e, cb)} and {@code EVP_RSA_gen(3072)} -- RSA with its + * modulus size. {@code RSA_generate_key} (deprecated in 0.9.8) and {@code + * RSA_generate_multi_prime_key} are read the same way; the number of primes is not recorded, + * because the mapper has nothing to put it in. + *
  • {@code EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, 2048)} -- the 3.x way to say the same thing. + * The function name is what states RSA; the context it is called on is opaque here. + *
  • {@code EVP_PKEY_CTX_set_dsa_paramgen_bits} and {@code + * EVP_PKEY_CTX_set_dh_paramgen_prime_len} -- DSA and Diffie-Hellman with their prime length. + *
  • {@code EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, NID_secp384r1)} and {@code + * EVP_PKEY_CTX_set_group_name(ctx, "P-256")} -- an EC key on a named curve. Here the curve + * is the finding and no action is declared, so the component is {@code EC-secp384r1} + * rather than a bare {@code EC} with a property hanging off it. A NID resolves from the + * constant's name or from its numeric value, both of which {@link OpenSslApi} holds; a number + * that is not a curve NID resolves to nothing. + *
  • {@code EVP_PKEY_Q_keygen(libctx, propq, "RSA", 4096)} -- the 3.0 shorthand. The type string + * is the component and the one variadic argument is its parameter: the man page says a {@code + * size_t} for {@code "RSA"} and a curve-name string for {@code "EC"}, so a number becomes the + * key length and a recognised curve name becomes the curve. Anything else is dropped. + *
+ * + *

{@code EVP_PKEY_keygen(ctx, &pkey)} and {@code EVP_PKEY_generate} are deliberately not rules. + * They name neither algorithm nor size -- both were set on the {@code EVP_PKEY_CTX} earlier, and + * the {@code EVP_PKEY_CTX_set_*} rules above are what read them. A rule there could only produce a + * component with no identity. + * + *

Padding

+ * + *

{@code EVP_PKEY_CTX_set_rsa_padding} and the four {@code RSA_*_encrypt}/{@code *_decrypt} + * functions each carry one of the {@code RSA_*_PADDING} constants, by name or by value -- the + * values are part of OpenSSL's ABI and have not changed since 0.9.x. The four raw functions also + * state the direction, which is why they are four rules rather than one: {@code RSA_public_encrypt} + * encrypts, {@code RSA_private_decrypt} decrypts, and the pair {@code RSA_private_encrypt} / {@code + * RSA_public_decrypt} is the raw signature primitive. + * + *

Password-based derivation

+ * + *

{@code PKCS5_PBKDF2_HMAC(pass, passlen, salt, saltlen, iter, digest, keylen, out)} is the call + * this rule set exists for. It yields PBKDF2 with the iteration count, the salt length and the + * derived key length -- the three numbers an auditor reads a PBKDF2 call site for. The salt length + * is taken from {@code saltlen} rather than from the {@code salt} buffer, because the buffer is + * usually a parameter and the length is usually a literal, and the key length is taken as bytes and + * reported in bits, which is what {@code KeySizeFactory} is told. + * + *

The digest at index 5 is not read: it arrives as {@code EVP_sha256()}, a call rather + * than a value, and that call is already a digest finding of its own on the same line. {@code + * PKCS5_PBKDF2_HMAC_SHA1} is the exception -- its name states SHA-1, so that one does report {@code + * PBKDF2-SHA-1} as a single component. + * + *

{@code EVP_PBE_scrypt} reports scrypt with its salt and key lengths. Its {@code N}, {@code r} + * and {@code p} cost parameters are not recorded: the mapper's model has no field for them, {@code + * NumberOfIterations} is not what {@code N} is, and a work factor written into the wrong field is + * worse than one left out. + */ +public final class OpenSslKey extends DetectionRuleSet { + + public static final String RSA = "RSA"; + public static final String DSA = "DSA"; + public static final String DH = "DH"; + public static final String RSA_ENCRYPT = "RSA_ENCRYPT"; + public static final String RSA_DECRYPT = "RSA_DECRYPT"; + public static final String RSA_SIGN = "RSA_SIGN"; + public static final String RSA_VERIFY = "RSA_VERIFY"; + public static final String PBKDF2 = "PBKDF2"; + public static final String PBKDF2_SHA1 = "PBKDF2_SHA1"; + public static final String SCRYPT = "SCRYPT"; + + private static final String KEYGEN = "KEYGEN"; + private static final String PADDING = "PADDING"; + private static final String KDF = "KDF"; + + @Nonnull + private static KeyContext context(@Nonnull String kind) { + return new KeyContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, kind)); + } + + // ===================================================================== + // RSA / DSA / DH key generation + // ===================================================================== + + /** {@code RSA_generate_key_ex(RSA *rsa, int bits, BIGNUM *e, BN_GENCB *cb)}. */ + private static final IDetectionRule RSA_GENERATE_KEY_EX = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("RSA_generate_key_ex") + .shouldBeDetectedAs(new ValueActionFactory<>(RSA)) + .withMethodParameter(MethodMatcher.ANY) // rsa + .withMethodParameter("int") // bits + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // e + .withMethodParameter(MethodMatcher.ANY) // cb + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code RSA_generate_multi_prime_key(RSA *rsa, int bits, int primes, BIGNUM *e, BN_GENCB + * *cb)}. + */ + private static final IDetectionRule RSA_GENERATE_MULTI_PRIME_KEY = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("RSA_generate_multi_prime_key") + .shouldBeDetectedAs(new ValueActionFactory<>(RSA)) + .withMethodParameter(MethodMatcher.ANY) // rsa + .withMethodParameter("int") // bits + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // primes + .withMethodParameter(MethodMatcher.ANY) // e + .withMethodParameter(MethodMatcher.ANY) // cb + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code RSA_generate_key(int bits, unsigned long e, callback, void *cb_arg)}. */ + private static final IDetectionRule RSA_GENERATE_KEY = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("RSA_generate_key") + .shouldBeDetectedAs(new ValueActionFactory<>(RSA)) + .withMethodParameter("int") // bits + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // e + .withMethodParameter(MethodMatcher.ANY) // callback + .withMethodParameter(MethodMatcher.ANY) // cb_arg + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_RSA_gen(unsigned int bits)} -- the 3.0 one-liner. */ + private static final IDetectionRule EVP_RSA_GEN = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_RSA_gen") + .shouldBeDetectedAs(new ValueActionFactory<>(RSA)) + .withMethodParameter("int") // bits + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_PKEY_CTX_set_rsa_keygen_bits(EVP_PKEY_CTX *ctx, int mbits)}. */ + private static final IDetectionRule RSA_KEYGEN_BITS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_CTX_set_rsa_keygen_bits") + .shouldBeDetectedAs(new ValueActionFactory<>(RSA)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter("int") // mbits + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_PKEY_CTX_set_dsa_paramgen_bits(EVP_PKEY_CTX *ctx, int nbits)}. */ + private static final IDetectionRule DSA_PARAMGEN_BITS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_CTX_set_dsa_paramgen_bits") + .shouldBeDetectedAs(new ValueActionFactory<>(DSA)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter("int") // nbits + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_PKEY_CTX_set_dh_paramgen_prime_len(EVP_PKEY_CTX *ctx, int len)}. */ + private static final IDetectionRule DH_PARAMGEN_PRIME_LEN = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_CTX_set_dh_paramgen_prime_len") + .shouldBeDetectedAs(new ValueActionFactory<>(DH)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter("int") // len + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BIT)) + .asChildOfParameterWithId(-1) + .buildForContext(context(KEYGEN)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code EVP_PKEY_CTX_set_ec_paramgen_curve_nid(EVP_PKEY_CTX *ctx, int nid)}. + * + *

No action: the curve is the whole finding, and an unrecognised NID has to leave the call + * silent rather than produce a curveless {@code EC}. + */ + private static final IDetectionRule EC_PARAMGEN_CURVE_NID = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_CTX_set_ec_paramgen_curve_nid") + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter("int") // nid + .shouldBeDetectedAs(new ParameterIdentifierFactory<>()) + .buildForContext(context("EC_CURVE_NID")) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_PKEY_CTX_set_group_name(EVP_PKEY_CTX *ctx, const char *name)}. */ + private static final IDetectionRule EC_GROUP_NAME = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_CTX_set_group_name") + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter("string") // name + .shouldBeDetectedAs(new ParameterIdentifierFactory<>()) + .buildForContext(context("EC_CURVE_NAME")) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code EVP_PKEY_Q_keygen(OSSL_LIB_CTX *libctx, const char *propq, const char *type, ...)}. + * + *

Named parameters, so the arity band is {@code [3, 4]} -- the three fixed parameters plus + * the one variadic argument the man page describes for {@code "RSA"} and {@code "EC"}. The type + * string is the root of the finding, and the variadic argument is attached under it with {@code + * asChildOfParameterWithId(2)}, which names the type parameter's index rather than the + * usual {@code -1}: there is no action to be a child of, because the algorithm is not knowable + * from the method name. + */ + private static final IDetectionRule PKEY_Q_KEYGEN = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_Q_keygen") + .withNamedMethodParameter("libctx", MethodMatcher.ANY) + .withNamedMethodParameter("propq", MethodMatcher.ANY) + .withNamedMethodParameter("type", "string") + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .withOptionalNamedMethodParameter("param", MethodMatcher.ANY) + .shouldBeDetectedAs(new ParameterIdentifierFactory<>()) + .asChildOfParameterWithId(2) + .buildForContext(context("PKEY_Q_KEYGEN")) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + // ===================================================================== + // RSA padding + // ===================================================================== + + /** {@code EVP_PKEY_CTX_set_rsa_padding(EVP_PKEY_CTX *ctx, int pad)}. */ + private static final IDetectionRule RSA_PADDING = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PKEY_CTX_set_rsa_padding") + .shouldBeDetectedAs(new ValueActionFactory<>(RSA)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter("int") // pad + .shouldBeDetectedAs(new ParameterIdentifierFactory<>()) + .asChildOfParameterWithId(-1) + .buildForContext(context(PADDING)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + @Nonnull + private static IDetectionRule rawRsaOperation( + @Nonnull String methodName, @Nonnull String action) { + return new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods(methodName) + .shouldBeDetectedAs(new ValueActionFactory<>(action)) + .withMethodParameter(MethodMatcher.ANY) // flen + .withMethodParameter(MethodMatcher.ANY) // from + .withMethodParameter(MethodMatcher.ANY) // to + .withMethodParameter(MethodMatcher.ANY) // rsa + .withMethodParameter("int") // padding + .shouldBeDetectedAs(new ParameterIdentifierFactory<>()) + .asChildOfParameterWithId(-1) + .buildForContext(context(PADDING)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + } + + // ===================================================================== + // Password-based key derivation + // ===================================================================== + + /** + * {@code PKCS5_PBKDF2_HMAC(pass, passlen, salt, saltlen, iter, digest, keylen, out)}. + * + *

Eight positional parameters, which is also what separates this from the seven-parameter + * {@code PKCS5_PBKDF2_HMAC_SHA1} rule. + */ + private static final IDetectionRule PKCS5_PBKDF2_HMAC = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("PKCS5_PBKDF2_HMAC") + .shouldBeDetectedAs(new ValueActionFactory<>(PBKDF2)) + .withMethodParameter(MethodMatcher.ANY) // pass + .withMethodParameter(MethodMatcher.ANY) // passlen + .withMethodParameter(MethodMatcher.ANY) // salt + .withMethodParameter("int") // saltlen + .shouldBeDetectedAs(new SaltSizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter("int") // iter + .shouldBeDetectedAs(new IterationCountFactory<>()) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // digest + .withMethodParameter("int") // keylen + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // out + .buildForContext(context(KDF)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code PKCS5_PBKDF2_HMAC_SHA1(pass, passlen, salt, saltlen, iter, keylen, out)}. */ + private static final IDetectionRule PKCS5_PBKDF2_HMAC_SHA1 = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("PKCS5_PBKDF2_HMAC_SHA1") + .shouldBeDetectedAs(new ValueActionFactory<>(PBKDF2_SHA1)) + .withMethodParameter(MethodMatcher.ANY) // pass + .withMethodParameter(MethodMatcher.ANY) // passlen + .withMethodParameter(MethodMatcher.ANY) // salt + .withMethodParameter("int") // saltlen + .shouldBeDetectedAs(new SaltSizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter("int") // iter + .shouldBeDetectedAs(new IterationCountFactory<>()) + .asChildOfParameterWithId(-1) + .withMethodParameter("int") // keylen + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // out + .buildForContext(context(KDF)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code EVP_PBE_scrypt(pass, passlen, salt, saltlen, N, r, p, maxmem, key, keylen)}. + * + *

Ten positional parameters. {@code EVP_PBE_scrypt_ex} adds a library context and a property + * query and is a rule of its own for that reason. + */ + private static final IDetectionRule EVP_PBE_SCRYPT = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PBE_scrypt") + .shouldBeDetectedAs(new ValueActionFactory<>(SCRYPT)) + .withMethodParameter(MethodMatcher.ANY) // pass + .withMethodParameter(MethodMatcher.ANY) // passlen + .withMethodParameter(MethodMatcher.ANY) // salt + .withMethodParameter("int") // saltlen + .shouldBeDetectedAs(new SaltSizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // N + .withMethodParameter(MethodMatcher.ANY) // r + .withMethodParameter(MethodMatcher.ANY) // p + .withMethodParameter(MethodMatcher.ANY) // maxmem + .withMethodParameter(MethodMatcher.ANY) // key + .withMethodParameter("int") // keylen + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .buildForContext(context(KDF)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code EVP_PBE_scrypt_ex(pass, passlen, salt, saltlen, N, r, p, maxmem, key, keylen, ctx, + * propq)} -- the same with a library context and a property query, so twelve parameters. + */ + private static final IDetectionRule EVP_PBE_SCRYPT_EX = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_PBE_scrypt_ex") + .shouldBeDetectedAs(new ValueActionFactory<>(SCRYPT)) + .withMethodParameter(MethodMatcher.ANY) // pass + .withMethodParameter(MethodMatcher.ANY) // passlen + .withMethodParameter(MethodMatcher.ANY) // salt + .withMethodParameter("int") // saltlen + .shouldBeDetectedAs(new SaltSizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // N + .withMethodParameter(MethodMatcher.ANY) // r + .withMethodParameter(MethodMatcher.ANY) // p + .withMethodParameter(MethodMatcher.ANY) // maxmem + .withMethodParameter(MethodMatcher.ANY) // key + .withMethodParameter("int") // keylen + .shouldBeDetectedAs(new KeySizeFactory<>(Size.UnitType.BYTE)) + .asChildOfParameterWithId(-1) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter(MethodMatcher.ANY) // propq + .buildForContext(context(KDF)) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** {@code EVP_KDF_fetch(OSSL_LIB_CTX *libctx, const char *algorithm, const char *props)}. */ + private static final IDetectionRule EVP_KDF_FETCH = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("EVP_KDF_fetch") + .withMethodParameter(MethodMatcher.ANY) // libctx + .withMethodParameter("string") // algorithm + .shouldBeDetectedAs(new AlgorithmFactory<>()) + .withMethodParameter(MethodMatcher.ANY) // properties + .buildForContext(context("KDF_FETCH")) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of( + RSA_GENERATE_KEY_EX, + RSA_GENERATE_MULTI_PRIME_KEY, + RSA_GENERATE_KEY, + EVP_RSA_GEN, + RSA_KEYGEN_BITS, + DSA_PARAMGEN_BITS, + DH_PARAMGEN_PRIME_LEN, + EC_PARAMGEN_CURVE_NID, + EC_GROUP_NAME, + PKEY_Q_KEYGEN, + RSA_PADDING, + rawRsaOperation("RSA_public_encrypt", RSA_ENCRYPT), + rawRsaOperation("RSA_private_decrypt", RSA_DECRYPT), + rawRsaOperation("RSA_private_encrypt", RSA_SIGN), + rawRsaOperation("RSA_public_decrypt", RSA_VERIFY), + PKCS5_PBKDF2_HMAC, + PKCS5_PBKDF2_HMAC_SHA1, + EVP_PBE_SCRYPT, + EVP_PBE_SCRYPT_EX, + EVP_KDF_FETCH); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandom.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandom.java new file mode 100644 index 000000000..3ece9dd03 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandom.java @@ -0,0 +1,106 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.PRNGContext; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * OpenSSL's random byte source. + * + *

{@code RAND_bytes} and {@code RAND_priv_bytes} draw from OpenSSL's own DRBG, not from the + * operating system's generator directly, and they let the caller select nothing: there is no + * algorithm argument and no provider name in the call. The finding is therefore a generic {@code + * DRBG} node typed as a pseudorandom number generator, which follows the precedent set by {@code + * NATIVEPRNG} for .NET's and Go's platform generators and by {@code DPAPI} for Windows data + * protection -- an API that performs cryptography without exposing which algorithm. + * + *

It is deliberately not reported as the concrete construction. OpenSSL's default public DRBG is + * CTR-DRBG over AES-256, but that is a configuration default: it can be changed at build time, in + * {@code openssl.cnf}, or at run time with {@code RAND_set_DRBG_type}, so naming it from a {@code + * RAND_bytes} call site would be a guess about the deployment rather than a reading of the source. + * {@code RAND_set_DRBG_type} is not read either -- it takes the name as a string, but a CBOM entry + * for a DRBG the code merely configures, with no call that uses it, says less than the call sites + * that do. + * + *

The number of bytes requested is not captured. It is the length of one output, not a key + * length or a seed length, and {@code SeedSizeFactory} would file it as the latter. + */ +public final class OpenSslRandom extends DetectionRuleSet { + + /** Marks a call that draws from OpenSSL's DRBG. */ + public static final String DRBG = "DRBG"; + + @Nonnull + private static PRNGContext context() { + return new PRNGContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, "RAND")); + } + + /** + * {@code RAND_bytes(buf, num)}, {@code RAND_priv_bytes(buf, num)} and the deprecated {@code + * RAND_pseudo_bytes(buf, num)}. + */ + private static final IDetectionRule RAND_BYTES = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("RAND_bytes", "RAND_priv_bytes", "RAND_pseudo_bytes") + .shouldBeDetectedAs(new ValueActionFactory<>(DRBG)) + .withMethodParameter(MethodMatcher.ANY) // buf + .withMethodParameter(MethodMatcher.ANY) // num + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code RAND_bytes_ex(ctx, buf, num, strength)} and {@code RAND_priv_bytes_ex(ctx, buf, num, + * strength)}. + * + *

A rule of its own rather than an arity-open one, so that the four-argument form cannot + * also be claimed by the two-argument rule above. + */ + private static final IDetectionRule RAND_BYTES_EX = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("RAND_bytes_ex", "RAND_priv_bytes_ex") + .shouldBeDetectedAs(new ValueActionFactory<>(DRBG)) + .withMethodParameter(MethodMatcher.ANY) // ctx + .withMethodParameter(MethodMatcher.ANY) // buf + .withMethodParameter(MethodMatcher.ANY) // num + .withMethodParameter(MethodMatcher.ANY) // strength + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(RAND_BYTES, RAND_BYTES_EX); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslTls.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslTls.java new file mode 100644 index 000000000..a4471cd7a --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/openssl/OpenSslTls.java @@ -0,0 +1,250 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.CipherSuiteFactory; +import com.ibm.engine.model.factory.ParameterIdentifierFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import javax.annotation.Nonnull; + +/** + * OpenSSL's TLS configuration, as C# reaches it through P/Invoke. + * + *

Four calls carry the detail a bill of materials wants, and they are usually all four in the + * same handful of lines: + * + *

{@code
+ * var ctx = Native.SSL_CTX_new(Native.TLS_client_method());
+ * Native.SSL_CTX_set_min_proto_version(ctx, Ssl.TLS1_2_VERSION);
+ * Native.SSL_CTX_set_cipher_list(ctx, "ECDHE-RSA-AES256-GCM-SHA384");
+ * Native.SSL_CTX_set_ciphersuites(ctx, "TLS_AES_256_GCM_SHA384");
+ * }
+ * + *

The version

+ * + *

Three of those lines can state a version and they do not agree about how. + * + *

{@code SSL_CTX_new} takes an {@code SSL_METHOD *}. The modern methods ({@code TLS_method} and + * its {@code SSLv23_} alias) are version-flexible and name no version at all; the deprecated {@code + * TLSv1_2_method} family and {@code SSLv3_method} are version-locked and name one. Both are read + * from the method function rather than from {@code SSL_CTX_new}, because the method is where the + * version is written -- and because the argument is as often a handle held in a variable ({@code + * SSL_CTX_new(sslMethod.Handle)}, which is openssl-net's own line) as it is an inline call. {@code + * SSL_CTX_new} itself still reports an unversioned TLS, for the same reason {@code DotNetTls}'s + * {@code UseHttps} rule does: the call is proof that this code terminates or opens a TLS + * connection, and the version, when it is stated anywhere, arrives as a finding of its own on the + * line that states it. + * + *

{@code SSL_CTX_set_min_proto_version} and its three siblings state a version outright, in + * either of two spellings that both occur in P/Invoke code. A wrapper that keeps the constants as + * members ({@code Ssl.TLS1_2_VERSION}) resolves to the member's name; one that declares them as C# + * {@code const int} fields resolves through the constant to {@code 0x0303}. {@link OpenSslApi} + * holds both, because the numbers are the TLS wire versions and are fixed by the RFCs. A {@code 0}, + * an unknown constant, or a version that arrives in a variable or a method parameter resolves to + * nothing. + * + *

Why {@code SSL_CTX_set_options} states no version

+ * + *

{@code SSL_CTX_set_options(ctx, SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1)} is the older way to pin + * a version, and it is read here only as "a TLS context is being configured". The names are + * not turned into versions, and the reason is that they say the opposite of what they look + * like: {@code SSL_OP_NO_TLSv1} means TLS 1.0 is refused. Reporting TLS 1.0 for that line would + * invert the finding, which is the worst outcome this rule set can produce. + * + *

Inferring the enabled version from the disabled ones is not sound either. The set {@code + * {NO_SSLv3, NO_TLSv1, NO_TLSv1_1}} leaves TLS 1.2 and TLS 1.3 enabled on an OpenSSL 1.1.1 or 3.x + * build, TLS 1.2 alone on a 1.0.2 build, and nothing at all on a build compiled without TLS 1.2 -- + * the answer depends on the library the binary links against, which is not in the source. The + * numeric form is refused for a second reason as well: the {@code SSL_OP_*} bit values were + * reassigned when the options word became 64 bits in 1.1.0, so a C# {@code const} carrying {@code + * 0x04000000} means {@code SSL_OP_NO_TLSv1} against one header and something else against another. + * + *

The cipher selection

+ * + *

{@code SSL_CTX_set_cipher_list} takes an OpenSSL cipher-list expression for TLS 1.2 and below + * and {@code SSL_CTX_set_ciphersuites} takes a colon-separated list of TLS 1.3 suite names. One + * rule reads both: the translator splits on {@code :} and keeps the tokens that are suites, which + * is the only guard that makes this safe. A list expression is not a list of suites -- {@code + * "HIGH:!aNULL:!MD5"} and {@code "DEFAULT:@STRENGTH"} are selectors, {@code "-RC4"} and {@code + * "+SHA"} are operators -- and the shared {@code CipherSuiteMapper} answers an unknown name with a + * {@code CipherSuite} node named after the string rather than with nothing, so passing the string + * through unchecked would put a cipher suite called {@code HIGH} in the CBOM. The translator + * therefore consults {@code CipherSuiteMapper.findCipherSuite} first and drops every token that is + * not a real suite. + * + *

No alias had to be added to the mapper for this: its suite table already carries OpenSSL's own + * names ({@code openssl_name} in {@code mapper/ciphersuites.json}, 161 of them) and {@code + * findCipherSuite} already tries them after the IANA name and the JSSE {@code SSL_} legacy + * spelling. {@code "ECDHE-RSA-AES256-GCM-SHA384"} resolves to {@code + * TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384} through that path, and the TLS 1.3 suites OpenSSL names + * ({@code TLS_AES_256_GCM_SHA384} and friends) are IANA names already. + */ +public final class OpenSslTls extends DetectionRuleSet { + + /** Marks a call that proves a TLS endpoint without naming a version. */ + public static final String TLS = "TLS"; + + /** Marks the action of an {@code SSL_METHOD}-returning function. See {@link OpenSslApi}. */ + public static final String SSL_METHOD = "SSL_METHOD"; + + @Nonnull + private static ProtocolContext context() { + return new ProtocolContext(Map.of(OpenSslDetectionRules.CONTEXT_KEY, "TLS")); + } + + /** + * {@code SSL_CTX_new(method)} and {@code SSL_CTX_new_ex(libctx, propq, method)}. + * + *

The arity is left open because the two differ in it and both mean the same thing, and + * nothing is read out of the arguments -- the method function is its own finding. + */ + private static final IDetectionRule SSL_CTX_NEW = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("SSL_CTX_new", "SSL_CTX_new_ex") + .shouldBeDetectedAs(new ValueActionFactory<>(TLS)) + .withAnyParameters() + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * Every {@code const SSL_METHOD *(void)} of {@code SSL_CTX_new(3)}, in one rule. + * + *

One rule rather than thirty, for the reason given on {@code OpenSslCipher}'s + * cipher-function rule: the method name is the value, and the finding already carries it as its + * location keyword. + */ + /** + * One rule per function, built in a loop from the same table the translator reads -- every + * SSL_METHOD constructor OpenSSL ships, each carrying its own name as the action constant. + * Written this way rather than as one rule over the whole name list so that the matched + * function arrives as a value, which is how every other rule set in this project states what it + * found; the alternative read the name back out of the detection location's keywords, a + * coupling nothing else relies on. + */ + @Nonnull + private static List> ssl_methods() { + final List> rules = new ArrayList<>(); + for (String function : OpenSslApi.sslMethodNames()) { + rules.add( + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods(function) + .shouldBeDetectedAs(new ValueActionFactory<>(function)) + .withoutParameters() + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules()); + } + return List.copyOf(rules); + } + + /** + * {@code SSL_CTX_set_min_proto_version(ctx, version)} and its three siblings. + * + *

No action is declared, so the version the argument resolves to is the finding and + * a call whose version cannot be resolved contributes nothing at all. Both the minimum and the + * maximum are read the same way: each names a version that this endpoint speaks, which is what + * the component records, and neither is a bound the CBOM model can express. + * + *

{@code ParameterIdentifierFactory} is the factory here because it is the only one that + * carries a value through whether it resolved to a name or to a number, which is precisely the + * two spellings these constants have in C#. The interpretation is the translator's, against the + * closed set in {@link OpenSslApi}. + */ + private static final IDetectionRule PROTO_VERSION = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods( + "SSL_CTX_set_min_proto_version", + "SSL_CTX_set_max_proto_version", + "SSL_set_min_proto_version", + "SSL_set_max_proto_version") + .withMethodParameter(MethodMatcher.ANY) // ctx / ssl + .withMethodParameter("int") // version + .shouldBeDetectedAs(new ParameterIdentifierFactory<>()) + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code SSL_CTX_set_cipher_list}, {@code SSL_CTX_set_ciphersuites} and their {@code SSL_*} + * counterparts. + * + *

All four in one rule because the string is handled identically: split on {@code :}, keep + * the tokens that are cipher suites. No action, so a string that names no suite -- a list + * expression, an alias, or anything built at run time -- leaves the call unreported. + */ + private static final IDetectionRule CIPHER_SELECTION = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods( + "SSL_CTX_set_cipher_list", + "SSL_set_cipher_list", + "SSL_CTX_set_ciphersuites", + "SSL_set_ciphersuites") + .withMethodParameter(MethodMatcher.ANY) // ctx / ssl + .withMethodParameter("string") // str + .shouldBeDetectedAs(new CipherSuiteFactory<>()) + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + /** + * {@code SSL_CTX_set_options(ctx, options)} and {@code SSL_set_options(ssl, options)}. + * + *

Reports an unversioned TLS and reads nothing from the bitmask. See the class comment for + * why the {@code SSL_OP_NO_*} flags are not turned into versions. + */ + private static final IDetectionRule SET_OPTIONS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("SSL_CTX_set_options", "SSL_set_options") + .shouldBeDetectedAs(new ValueActionFactory<>(TLS)) + .withAnyParameters() + .buildForContext(context()) + .inBundle(() -> "OpenSsl") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return Stream.concat( + ssl_methods().stream(), + Stream.of(SSL_CTX_NEW, PROTO_VERSION, CIPHER_SELECTION, SET_OPTIONS)) + .toList(); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/rules/detection/schannel/Schannel.java b/csharp/src/main/java/com/ibm/plugin/rules/detection/schannel/Schannel.java new file mode 100644 index 000000000..43e201097 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/rules/detection/schannel/Schannel.java @@ -0,0 +1,255 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.schannel; + +import com.ibm.engine.detection.MethodMatcher; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.context.ProtocolContext; +import com.ibm.engine.model.factory.ProtocolFactory; +import com.ibm.engine.model.factory.ValueActionFactory; +import com.ibm.engine.rule.DetectionRuleSet; +import com.ibm.engine.rule.IDetectionRule; +import com.ibm.engine.rule.builder.DetectionRuleBuilder; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * Schannel driven directly through SSPI from C#, i.e. hand-written P/Invoke against {@code + * secur32.dll} rather than {@code SslStream}. + * + *

What "Schannel in C#" means, and which reading these rules cover

+ * + *

Three different things go by that name, and only one of them is unclaimed: + * + *

    + *
  • {@code SslStream} and {@code SslClientAuthenticationOptions} are Schannel on + * Windows, but every place they state a version is already read by {@code DotNetTls} -- + * {@code AuthenticateAsClient}/{@code Server}, {@code set_EnabledSslProtocols}, {@code + * set_SslProtocols}, {@code set_SecurityProtocol}, {@code UseHttps}. A second rule on any of + * those methods would match the same call and report it twice, so none is touched here. + *
  • {@code CipherSuitesPolicy} is declared {@code [UnsupportedOSPlatform("windows")]}: it + * configures the OpenSSL-backed stack and throws on the Schannel-backed one. Whatever it is, + * it is not Schannel, so it has no place in this rule set. + *
  • Direct SSPI/Schannel P/Invoke -- {@code AcquireCredentialsHandle} with {@code + * SCHANNEL_CRED} or {@code SCH_CREDENTIALS}. That is what these rules read. + *
+ * + *

What the Schannel API actually lets a caller state

+ * + *

Much less than its field list suggests, and this is the governing fact for the whole rule set. + * Of everything reachable from a credential structure, exactly one field is an allow-list: + * + *

    + *
  • {@code SCHANNEL_CRED.grbitEnabledProtocols} -- "a bit string that represents the protocols + * supported by connections made with credentials acquired by using this structure". + * Read here. + *
  • {@code TLS_PARAMETERS.grbitDisabledProtocols} -- the same flag vocabulary with the opposite + * meaning. Not read; see below. + *
  • {@code TLS_PARAMETERS.pDisabledCrypto}, an array of {@code CRYPTO_SETTINGS}. The + * structure's own documentation page is titled "Indicates disabled cryptographic + * settings" and its usage enum is documented as "Specifies the algorithm being used to + * disable cryptographic settings". Not read; see below. + *
+ * + *

The modern {@code SCH_CREDENTIALS} structure has no {@code grbitEnabledProtocols}, no {@code + * dwMinimumCipherStrength} and no {@code palgSupportedAlgs} at all: it carries nothing positive + * about cryptography, only the deny-lists above. So on the newer API there is no algorithm, key + * size, cipher suite or chaining mode that a caller can request -- only ones it can + * forbid. + * + *

Why the deny-lists produce nothing

+ * + *

{@code grbitDisabledProtocols} has no rule, and neither have {@code eAlgorithmUsage}, {@code + * strCngAlgId}, {@code rgstrChainingModes}, {@code dwMinBitLength} and {@code dwMaxBitLength}. A + * disabled protocol is not a protocol in use and a disabled algorithm is not an algorithm in use; + * CycloneDX has no component for "deliberately not used", and emitting the ordinary one would state + * the exact opposite of what the source says. The inverse reading -- "AES was disabled below 256 + * bits, therefore AES-256 is in force" -- needs the platform's full default suite list and + * certainty that nothing else narrows it, which is a guess rather than a reading. + * + *

The canonical implementation makes the point concrete. {@code SslStreamPal.Windows.cs} writes + * {@code tlsParameters.grbitDisabledProtocols = (uint)protocolFlags ^ uint.MaxValue;} -- a computed + * complement, with no flag name in it to read even if one wanted to -- and fills {@code + * pDisabledCrypto} with {@code TlsParametersCngAlgUsageCertSig} entries naming {@code + * "SCH_RSA_PKCS_PAD"} and {@code "SCH_RSA_PSS_PAD"}, whose whole purpose is to switch RSA + * certificate-signature padding off. A rule reading those as "RSA PKCS#1 is used here" + * would invert the meaning of the code it is looking at. + * + *

Why these three rules, and what each guards on

+ * + *

{@code dwMinimumCipherStrength} and {@code dwMaximumCipherStrength} are genuinely positive -- + * a floor and a ceiling on bulk cipher key length -- but they name no algorithm, and a bit length + * with nothing to attach it to is not a component. They are left unread rather than hung off an + * invented algorithm or off a bare {@code TLS}, where a policy floor would read as an observed key + * length. {@code palgSupportedAlgs} is positive too and names {@code ALG_ID} values, but it is a + * pointer to a pinned array: the assignment holds an address expression, never the algorithm names, + * so there is nothing at that call site to read. + * + *

{@code InitializeSecurityContext}, {@code AcceptSecurityContext}, {@code EncryptMessage}, + * {@code DecryptMessage}, {@code ApplyControlToken} and {@code QueryContextAttributes} take a + * handle and carry no package name. Nothing at those call sites distinguishes a TLS context from a + * Kerberos or NTLM one, so none of them is matched -- an SSPI {@code EncryptMessage} is not + * evidence of TLS. + */ +@SuppressWarnings("java:S1192") +public final class Schannel extends DetectionRuleSet { + + /** The closed set of Schannel package names; see {@code CSharpProtocolContextTranslator}. */ + private static final String KIND_PACKAGE = "SCHANNEL_PACKAGE"; + + /** The closed set of {@code SP_PROT_*} flag names. */ + private static final String KIND_PROTOCOLS = "SCHANNEL_PROTOCOLS"; + + private static final String TLS = "TLS"; + + /** + * {@code AcquireCredentialsHandle(null, UNISP_NAME, SECPKG_CRED_OUTBOUND, ...)} -- the one call + * in the SSPI sequence that names the security package. + * + *

SSPI is not TLS. The same nine-parameter function acquires Kerberos, NTLM, Negotiate and + * CredSSP credentials, and {@code pszPackage} at index 1 is the only thing that says which. So + * the rule has no method-level detection: the package name is the sole value read, and the + * translator reports TLS only for a member of the closed set {@code {UNISP_NAME, + * SCHANNEL_NAME}} and their {@code _A}/{@code _W} spellings and literal string values. {@code + * "Negotiate"} resolves cleanly and yields nothing, which is the point. + * + *

All nine parameters are declared although only one is read, because the binder accepts a + * call only at an arity within {@code [required, declared]} and this function is always called + * with nine arguments. The Win32 names are used, but they are not what makes the binding work: + * hand-written P/Invoke names these parameters whatever the author likes -- .NET's own + * declaration calls them {@code principal, moduleName, usage, ...} -- so index 1 is what + * carries it. Declaring {@code pszPackage} as {@code string} is what stops the package name + * being taken from somewhere else: the three other reference parameters at lower indices are + * pointers and buffers. + * + *

No version is read. A credential's protocols live in the structure passed as {@code + * pAuthData}, which is a pointer expression here and is read instead at the field assignments + * below. A bare {@code TLS} is the honest statement for the call itself, and it is the same one + * {@code DotNetTls} makes for {@code UseHttps} and for {@code SslProtocols.None}. + */ + private static final IDetectionRule ACQUIRE_CREDENTIALS_HANDLE = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods( + "AcquireCredentialsHandle", + "AcquireCredentialsHandleA", + "AcquireCredentialsHandleW") + .withNamedMethodParameter("pszPrincipal", MethodMatcher.ANY) + .withNamedMethodParameter("pszPackage", "string") + .shouldBeDetectedAs(new ProtocolFactory<>()) + .withNamedMethodParameter("fCredentialUse", MethodMatcher.ANY) + .withNamedMethodParameter("pvLogonId", MethodMatcher.ANY) + .withNamedMethodParameter("pAuthData", MethodMatcher.ANY) + .withNamedMethodParameter("pGetKeyFn", MethodMatcher.ANY) + .withNamedMethodParameter("pvGetKeyArgument", MethodMatcher.ANY) + .withNamedMethodParameter("phCredential", MethodMatcher.ANY) + .withNamedMethodParameter("ptsExpiry", MethodMatcher.ANY) + .buildForContext(new ProtocolContext(Map.of("kind", KIND_PACKAGE))) + .inBundle(() -> "Schannel") + .withoutDependingDetectionRules(); + + /** + * {@code new SCHANNEL_CRED { ... }} and {@code new SCH_CREDENTIALS { ... }}. + * + *

These two structures exist for one purpose. Unlike the fields read below, the type name + * here really is the receiver text a rule can match on -- a creation carries its own type -- + * and a type spelled {@code SCHANNEL_CRED} cannot plausibly be anything but the Schannel + * credential, whatever namespace a consumer declares it in. Both spellings are the ones Windows + * uses, which is what every published P/Invoke declaration copies, including .NET's own. + * + *

The creation is worth a rule of its own because the documentation recommends leaving the + * protocols alone: "applications should set {@code grbitEnabledProtocols} to zero and use the + * protocol versions enabled on the system by default". A credential built that way states that + * Schannel TLS is configured here and says nothing about versions, and this is what records it. + * + *

A credential written {@code SCH_CREDENTIALS cred = default;}, which is how .NET itself + * writes it, is not a creation and is not matched. Such code reaches {@code + * AcquireCredentialsHandle} to be of any use, and that rule covers it. + */ + private static final IDetectionRule CREDENTIAL_STRUCTURE = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes("SCHANNEL_CRED", "SCH_CREDENTIALS") + .forMethods("") + .shouldBeDetectedAs(new ValueActionFactory<>(TLS)) + .withoutParameters() + .buildForContext(new ProtocolContext(Map.of("kind", KIND_PACKAGE))) + .inBundle(() -> "Schannel") + .withoutDependingDetectionRules(); + + /** + * {@code cred.grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT | SP_PROT_TLS1_3_CLIENT;} -- the + * only allow-list in the Schannel API, and the one place a version is stated. + * + *

The receiver is {@code ANY} and it has to be. A field assignment and an object-initializer + * entry both arrive as a synthetic {@code set_} call on the variable the + * structure is held in, not on its type, and the C# frontend has no semantic model to recover a + * variable's declared type. So the field name is the entire guard -- and it is a good one: + * {@code grbitEnabledProtocols} is a Win32 Hungarian-notation name that belongs to exactly one + * structure in the world. This is also why the rules below key on the field name rather than on + * the structure's C# name, which a consumer is free to spell as they like. + * + *

A flags combination resolves to each of its members rather than to a number, so {@code A | + * B} states two protocols; the same machinery that makes {@code SslProtocols.Tls12 | + * SslProtocols.Tls13} report both versions handles it unchanged. {@code SP_PROT_NONE} (zero) + * means Schannel chooses, which is reported as TLS without a version -- exactly what is known. + * + *

Known limitation, and why it is not closed here. A flag arrives as a name only when + * it is written as a member access ({@code Schannel.SP_PROT_TLS1_2_CLIENT}), which is how + * .NET's own code and every multi-file P/Invoke writes it. A bare reference to a {@code const + * int} declared in the same file resolves through that constant to its value instead, and a + * value is not a name, so such an assignment reports nothing rather than guessing. That is a + * recall gap, not a precision one. + * + *

Decoding the number would be sound in principle -- the field is a bit string over a + * closed, documented set of values -- but it is not done, and one of the two reasons for that + * has since changed. It used to be that the spelling decided whether a number arrived at all: + * {@code CSharpTreeConverter} recognized only {@code INTEGER_LITERAL}, so {@code 2048} resolved + * to an {@code Integer} while {@code 0x800} fell through to the string fallback and reached the + * translator as the raw source text {@code "0x00000800"}. Both spellings now classify as {@code + * INTEGER}, so the asymmetry is gone; the outcome is unchanged, because a protocol factory + * refuses an {@code Integer} either way, and the {@code NumericFlag} counter-check in {@code + * SchannelTest} pins that. + * + *

What still rules it out is the second reason: a translator maps one value to one node, so + * a field carrying several bits could not be represented even where the bits are unambiguous. + * Decoding would also have to reproduce the header's aliasing -- {@code 0x80} is both {@code + * SP_PROT_TLS1_CLIENT} and {@code SP_PROT_TLS1_0_CLIENT} -- which the names above express + * directly. A uniform gap is better than a partial decode. + */ + private static final IDetectionRule ENABLED_PROTOCOLS = + new DetectionRuleBuilder() + .createDetectionRule() + .forObjectTypes(MethodMatcher.ANY) + .forMethods("set_grbitEnabledProtocols") + .withMethodParameter(MethodMatcher.ANY) + .shouldBeDetectedAs(new ProtocolFactory<>()) + .buildForContext(new ProtocolContext(Map.of("kind", KIND_PROTOCOLS))) + .inBundle(() -> "Schannel") + .withoutDependingDetectionRules(); + + @Nonnull + @Override + protected List> buildRules() { + return List.of(ACQUIRE_CREDENTIALS_HANDLE, CREDENTIAL_STRUCTURE, ENABLED_PROTOCOLS); + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java b/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java index d1b94efb6..d95468116 100755 --- a/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java +++ b/csharp/src/main/java/com/ibm/plugin/translation/translator/CSharpTranslator.java @@ -46,6 +46,7 @@ import com.ibm.plugin.translation.translator.contexts.CSharpDigestContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpKeyContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpMacContextTranslator; +import com.ibm.plugin.translation.translator.contexts.CSharpOpenSslContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpPRNGContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpProtocolContextTranslator; import com.ibm.plugin.translation.translator.contexts.CSharpSignatureContextTranslator; @@ -75,6 +76,17 @@ public Optional translate( return Optional.empty(); } + // OpenSSL reached through P/Invoke is a flat set of static extern functions, so its rules + // span five of the contexts below while sharing one vocabulary and one set of constant + // tables. They are routed by the context property their rule sets all carry rather than by + // the context class, which keeps all of OpenSSL in one translator instead of adding a case + // to each of five. See OpenSslDetectionRules#CONTEXT_KEY. + if (detectionValueContext.contains( + com.ibm.plugin.rules.detection.openssl.OpenSslDetectionRules.CONTEXT_KEY)) { + return new CSharpOpenSslContextTranslator() + .translate(bundleIdentifier, value, detectionValueContext, detectionLocation); + } + if (detectionValueContext.is(CipherContext.class)) { return new CSharpCipherContextTranslator() .translate(bundleIdentifier, value, detectionValueContext, detectionLocation); diff --git a/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpOpenSslContextTranslator.java b/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpOpenSslContextTranslator.java new file mode 100644 index 000000000..2c9a43ee6 --- /dev/null +++ b/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpOpenSslContextTranslator.java @@ -0,0 +1,665 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.translation.translator.contexts; + +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.Algorithm; +import com.ibm.engine.model.CipherSuite; +import com.ibm.engine.model.IValue; +import com.ibm.engine.model.IterationCount; +import com.ibm.engine.model.KeySize; +import com.ibm.engine.model.ParameterIdentifier; +import com.ibm.engine.model.SaltSize; +import com.ibm.engine.model.ValueAction; +import com.ibm.engine.model.context.DetectionContext; +import com.ibm.engine.rule.IBundle; +import com.ibm.mapper.IContextTranslation; +import com.ibm.mapper.mapper.ssl.CipherSuiteMapper; +import com.ibm.mapper.model.EllipticCurve; +import com.ibm.mapper.model.EllipticCurveAlgorithm; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.KeyLength; +import com.ibm.mapper.model.MessageDigest; +import com.ibm.mapper.model.Mode; +import com.ibm.mapper.model.NumberOfIterations; +import com.ibm.mapper.model.Padding; +import com.ibm.mapper.model.Protocol; +import com.ibm.mapper.model.PseudorandomNumberGenerator; +import com.ibm.mapper.model.SaltLength; +import com.ibm.mapper.model.Version; +import com.ibm.mapper.model.algorithms.AES; +import com.ibm.mapper.model.algorithms.ANSIX942; +import com.ibm.mapper.model.algorithms.ANSIX963; +import com.ibm.mapper.model.algorithms.Aria; +import com.ibm.mapper.model.algorithms.Blowfish; +import com.ibm.mapper.model.algorithms.CMAC; +import com.ibm.mapper.model.algorithms.Camellia; +import com.ibm.mapper.model.algorithms.ChaCha20; +import com.ibm.mapper.model.algorithms.ChaCha20Poly1305; +import com.ibm.mapper.model.algorithms.ConcatenationKDF; +import com.ibm.mapper.model.algorithms.DES; +import com.ibm.mapper.model.algorithms.DESede; +import com.ibm.mapper.model.algorithms.DH; +import com.ibm.mapper.model.algorithms.DSA; +import com.ibm.mapper.model.algorithms.Ed25519; +import com.ibm.mapper.model.algorithms.Ed448; +import com.ibm.mapper.model.algorithms.HKDF; +import com.ibm.mapper.model.algorithms.HMAC; +import com.ibm.mapper.model.algorithms.IDEA; +import com.ibm.mapper.model.algorithms.KDFCounter; +import com.ibm.mapper.model.algorithms.KMAC; +import com.ibm.mapper.model.algorithms.Kerberos; +import com.ibm.mapper.model.algorithms.MD2; +import com.ibm.mapper.model.algorithms.MD4; +import com.ibm.mapper.model.algorithms.MD5; +import com.ibm.mapper.model.algorithms.MLDSA; +import com.ibm.mapper.model.algorithms.MLKEM; +import com.ibm.mapper.model.algorithms.PBKDF1; +import com.ibm.mapper.model.algorithms.PBKDF2; +import com.ibm.mapper.model.algorithms.PKCS12PBE; +import com.ibm.mapper.model.algorithms.Poly1305; +import com.ibm.mapper.model.algorithms.RC2; +import com.ibm.mapper.model.algorithms.RC4; +import com.ibm.mapper.model.algorithms.RC5; +import com.ibm.mapper.model.algorithms.RIPEMD; +import com.ibm.mapper.model.algorithms.RSA; +import com.ibm.mapper.model.algorithms.RSAssaPSS; +import com.ibm.mapper.model.algorithms.SEED; +import com.ibm.mapper.model.algorithms.SHA; +import com.ibm.mapper.model.algorithms.SHA2; +import com.ibm.mapper.model.algorithms.SHA3; +import com.ibm.mapper.model.algorithms.SM2; +import com.ibm.mapper.model.algorithms.SM3; +import com.ibm.mapper.model.algorithms.SM4; +import com.ibm.mapper.model.algorithms.Scrypt; +import com.ibm.mapper.model.algorithms.SipHash; +import com.ibm.mapper.model.algorithms.Whirlpool; +import com.ibm.mapper.model.algorithms.X25519; +import com.ibm.mapper.model.algorithms.X448; +import com.ibm.mapper.model.algorithms.blake.BLAKE2b; +import com.ibm.mapper.model.algorithms.blake.BLAKE2s; +import com.ibm.mapper.model.algorithms.cast.CAST128; +import com.ibm.mapper.model.algorithms.shake.SHAKE; +import com.ibm.mapper.model.collections.CipherSuiteCollection; +import com.ibm.mapper.model.functionality.Decrypt; +import com.ibm.mapper.model.functionality.Encrypt; +import com.ibm.mapper.model.functionality.Sign; +import com.ibm.mapper.model.functionality.Verify; +import com.ibm.mapper.model.mode.CBC; +import com.ibm.mapper.model.mode.CCM; +import com.ibm.mapper.model.mode.CFB; +import com.ibm.mapper.model.mode.CTR; +import com.ibm.mapper.model.mode.ECB; +import com.ibm.mapper.model.mode.GCM; +import com.ibm.mapper.model.mode.KW; +import com.ibm.mapper.model.mode.KWP; +import com.ibm.mapper.model.mode.OCB; +import com.ibm.mapper.model.mode.OFB; +import com.ibm.mapper.model.mode.XTS; +import com.ibm.mapper.model.padding.OAEP; +import com.ibm.mapper.model.padding.PKCS1; +import com.ibm.mapper.model.protocol.TLS; +import com.ibm.mapper.utils.DetectionLocation; +import com.ibm.plugin.rules.detection.openssl.OpenSslApi; +import com.ibm.plugin.rules.detection.openssl.OpenSslDetectionRules; +import com.ibm.plugin.rules.detection.openssl.OpenSslDigest; +import com.ibm.plugin.rules.detection.openssl.OpenSslKey; +import com.ibm.plugin.rules.detection.openssl.OpenSslRandom; +import com.ibm.plugin.rules.detection.openssl.OpenSslTls; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Turns the OpenSSL detections into mapper nodes. + * + *

One translator for all of OpenSSL, reached from {@code CSharpTranslator} by the {@code + * openssl} context property rather than by the context class, because the rule sets span five + * contexts and share one vocabulary (see {@code OpenSslDetectionRules}). + * + *

Every value goes through a closed set

+ * + *

All OpenSSL rules match on an {@code ANY} receiver, since a {@code static extern} call has no + * type to match on, so the guard that keeps them honest is not in the rule but here: a name that is + * not in {@link OpenSslApi}'s tables, or a cipher-suite token that {@code + * CipherSuiteMapper.findCipherSuite} does not know, yields {@code Optional.empty()} and the + * detection contributes nothing. That is the same mechanism {@code + * CSharpProtocolContextTranslator#dotNetProtocol} uses for the .NET protocol enums, and it is what + * makes it safe to read a value out of a call that could belong to anybody. + * + *

Two shapes are refused on purpose rather than by accident, and they are worth naming because + * they look like losses: + * + *

    + *
  • A key size, iteration count or version that arrives in a variable or a method parameter. + * The engine resolves an identifier only through a constant or a single-assignment local (see + * {@code CSharpDetectionEngine}'s guards G1 and G4) and never falls back to the identifier's + * spelling, so such a call is reported without that child -- not with a guess. + *
  • A constant this plugin does not recognise. {@code SSL_CTX_set_min_proto_version(ctx, + * SomeWrapper.TLS1_4_VERSION)} resolves to the name {@code TLS1_4_VERSION}, which is in no + * table, so nothing is reported for that line at all. + *
+ * + *

Where the function name comes from

+ * + *

What distinguishes the members of the three big families -- the {@code EVP_()} + * functions, the {@code EVP_()} functions and the {@code SSL_METHOD} functions -- is the + * function name itself. Each has one rule per function, generated in a loop from the same table + * this translator reads, and each rule carries its own name as its action constant. So the name + * arrives here as an ordinary detected value and is looked up in that table, exactly as every other + * rule set in this project states what it found. + * + *

It was first written as three rules over the whole name list, with the name read back out of + * {@code DetectionLocation#keywords()}. That worked, but it was a coupling nothing else in the + * project relies on, and the keyword is filled for a different purpose (the CBOM writer uses it as + * a finding's additional context). One rule per function costs 189 more rules in a scan's + * per-statement loop -- 255 to 441 for C# -- and buys an architecture a reader can follow from the + * rule to the node without knowing what a keyword is. + */ +@SuppressWarnings({"java:S1200", "java:S6539"}) +public final class CSharpOpenSslContextTranslator implements IContextTranslation { + + @Override + public @Nonnull Optional translate( + @Nonnull IBundle bundleIdentifier, + @Nonnull IValue value, + @Nonnull DetectionContext detectionContext, + @Nonnull DetectionLocation detectionLocation) { + final String kind = detectionContext.get(OpenSslDetectionRules.CONTEXT_KEY).orElse(""); + + if (value instanceof ValueAction action) { + return translateAction(action.asString(), detectionLocation); + } + if (value instanceof Algorithm algorithm) { + return translateAlgorithmName(kind, algorithm.asString(), detectionLocation); + } + if (value instanceof ParameterIdentifier parameter) { + return translateParameter(kind, parameter.asString(), detectionLocation); + } + if (value instanceof CipherSuite cipherSuite) { + return cipherSuites(cipherSuite.asString(), detectionLocation); + } + if (value instanceof IterationCount iterationCount) { + return Optional.of( + new NumberOfIterations(iterationCount.getValue(), detectionLocation)); + } + if (value instanceof SaltSize saltSize) { + return Optional.of(new SaltLength(saltSize.getValue(), detectionLocation)); + } + if (value instanceof KeySize keySize) { + return Optional.of(new KeyLength(keySize.getValue(), detectionLocation)); + } + return Optional.empty(); + } + + // ===================================================================== + // Actions + // ===================================================================== + + /** + * The node a rule's action constant stands for. + * + *

The three families -- the {@code EVP_CIPHER} functions, the {@code EVP_MD} functions and + * the {@code SSL_METHOD} functions -- have one rule each per function, and each rule carries + * its own function name as the action constant. So the name arrives here as the value, the way + * every other rule set in this project delivers one, and is looked up in the same table the + * rules took their names from. Nothing is read back out of the detection location. + */ + @Nonnull + private Optional translateAction( + @Nonnull String action, @Nonnull DetectionLocation detectionLocation) { + final Optional fromFunctionName = functionName(action, detectionLocation); + if (fromFunctionName.isPresent()) { + return fromFunctionName; + } + return switch (action) { + case OpenSslTls.TLS -> Optional.of(new TLS(detectionLocation)); + case OpenSslDigest.HMAC -> Optional.of(new HMAC(detectionLocation)); + case OpenSslKey.RSA -> Optional.of(new RSA(detectionLocation)); + case OpenSslKey.DSA -> Optional.of(new DSA(detectionLocation)); + case OpenSslKey.DH -> Optional.of(new DH(detectionLocation)); + case OpenSslKey.RSA_ENCRYPT -> + Optional.of(rsaWith(new Encrypt(detectionLocation), detectionLocation)); + case OpenSslKey.RSA_DECRYPT -> + Optional.of(rsaWith(new Decrypt(detectionLocation), detectionLocation)); + case OpenSslKey.RSA_SIGN -> + Optional.of(rsaWith(new Sign(detectionLocation), detectionLocation)); + case OpenSslKey.RSA_VERIFY -> + Optional.of(rsaWith(new Verify(detectionLocation), detectionLocation)); + case OpenSslKey.PBKDF2 -> Optional.of(new PBKDF2(detectionLocation)); + // PKCS5_PBKDF2_HMAC_SHA1 names its pseudo-random function in the function name, so + // this one call site yields the complete PBKDF2-SHA-1 identity. + case OpenSslKey.PBKDF2_SHA1 -> Optional.of(new PBKDF2(new SHA(detectionLocation))); + case OpenSslKey.SCRYPT -> Optional.of(new Scrypt(detectionLocation)); + case OpenSslRandom.DRBG -> + Optional.of( + new com.ibm.mapper.model.Algorithm( + OpenSslRandom.DRBG, + PseudorandomNumberGenerator.class, + detectionLocation)); + default -> Optional.empty(); + }; + } + + @Nonnull + private static INode rsaWith( + @Nonnull INode functionality, @Nonnull DetectionLocation detectionLocation) { + final RSA rsa = new RSA(detectionLocation); + rsa.put(functionality); + return rsa; + } + + /** + * The node an OpenSSL function name stands for, when the action constant is one. + * + *

The three tables are disjoint -- a name is a cipher, a digest or an {@code SSL_METHOD}, + * never two of them -- so trying each in turn cannot be ambiguous. An action that is not a + * function name at all (the fixed constants below) falls through to the switch. + */ + @Nonnull + private Optional functionName( + @Nonnull String action, @Nonnull DetectionLocation detectionLocation) { + final Optional cipher = + OpenSslApi.cipherFunction(action).flatMap(s -> symmetric(s, detectionLocation)); + if (cipher.isPresent()) { + return cipher; + } + final Optional md = + OpenSslApi.digestFunction(action).flatMap(s -> digest(s, detectionLocation)); + if (md.isPresent()) { + return md; + } + return OpenSslApi.sslMethod(action).flatMap(s -> protocol(s, detectionLocation)); + } + + // ===================================================================== + // Symmetric ciphers + // ===================================================================== + + /** + * The node an {@code EVP_CIPHER} specification stands for: the algorithm, with its key length + * and mode attached when the function name states them. + */ + @Nonnull + private static Optional symmetric( + @Nonnull OpenSslApi.SymmetricSpec spec, @Nonnull DetectionLocation detectionLocation) { + final com.ibm.mapper.model.Algorithm algorithm = + switch (spec.algorithm()) { + case OpenSslApi.AES -> new AES(detectionLocation); + case OpenSslApi.ARIA -> new Aria(detectionLocation); + case OpenSslApi.CAMELLIA -> new Camellia(detectionLocation); + case OpenSslApi.CHACHA20 -> new ChaCha20(detectionLocation); + case OpenSslApi.CHACHA20_POLY1305 -> new ChaCha20Poly1305(detectionLocation); + case OpenSslApi.DES -> new DES(detectionLocation); + // Two-key and three-key Triple DES are the same mapper node; the key length + // below is what tells them apart, which is also how CycloneDX spells it. + case OpenSslApi.DES_EDE, OpenSslApi.DES_EDE3 -> new DESede(detectionLocation); + case OpenSslApi.RC2 -> new RC2(detectionLocation); + case OpenSslApi.RC4 -> new RC4(detectionLocation); + case OpenSslApi.RC5 -> new RC5(detectionLocation); + case OpenSslApi.BLOWFISH -> new Blowfish(detectionLocation); + case OpenSslApi.CAST5 -> new CAST128(detectionLocation); + case OpenSslApi.IDEA -> new IDEA(detectionLocation); + case OpenSslApi.SEED -> new SEED(detectionLocation); + case OpenSslApi.SM4 -> new SM4(detectionLocation); + default -> null; + }; + if (algorithm == null) { + // Unreachable while the table and this switch agree, and an empty result rather than + // an exception or a generic node if a family is ever added to only one of the two: + // nothing is better than a cipher reported under the wrong name. + return Optional.empty(); + } + if (spec.keyBits() != OpenSslApi.KEY_LENGTH_UNSPECIFIED) { + algorithm.put(new KeyLength(spec.keyBits(), detectionLocation)); + } + mode(spec.mode(), detectionLocation).ifPresent(algorithm::put); + return Optional.of(algorithm); + } + + /** + * The mode node a function name's suffix stands for. + * + *

{@code cfb1}, {@code cfb8}, {@code cfb64} and {@code cfb128} are all CFB and differ only + * in how many bits are fed back, which the {@code CFB(int, ...)} constructor records as the + * mode's block size. {@code wrap} and {@code wrap_pad} are AES key wrap with and without + * padding. + */ + @Nonnull + private static Optional mode( + @Nullable String mode, @Nonnull DetectionLocation detectionLocation) { + if (mode == null) { + return Optional.empty(); + } + return switch (mode) { + case "CBC" -> Optional.of(new CBC(detectionLocation)); + case "ECB" -> Optional.of(new ECB(detectionLocation)); + case "OFB" -> Optional.of(new OFB(detectionLocation)); + case "CTR" -> Optional.of(new CTR(detectionLocation)); + case "GCM" -> Optional.of(new GCM(detectionLocation)); + case "CCM" -> Optional.of(new CCM(detectionLocation)); + case "OCB" -> Optional.of(new OCB(detectionLocation)); + case "XTS" -> Optional.of(new XTS(detectionLocation)); + case "KW" -> Optional.of(new KW(detectionLocation)); + case "KWP" -> Optional.of(new KWP(detectionLocation)); + case "CFB1" -> Optional.of(new CFB(1, detectionLocation)); + case "CFB8" -> Optional.of(new CFB(8, detectionLocation)); + case "CFB64" -> Optional.of(new CFB(64, detectionLocation)); + case "CFB128" -> Optional.of(new CFB(128, detectionLocation)); + default -> Optional.empty(); + }; + } + + // ===================================================================== + // Digests + // ===================================================================== + + @Nonnull + private static Optional digest( + @Nonnull OpenSslApi.DigestSpec spec, @Nonnull DetectionLocation detectionLocation) { + final MessageDigest messageDigest = + switch (spec.algorithm()) { + case OpenSslApi.SHA1 -> new SHA(detectionLocation); + case OpenSslApi.SHA2 -> new SHA2(spec.digestSize(), detectionLocation); + case OpenSslApi.SHA3 -> new SHA3(spec.digestSize(), detectionLocation); + case OpenSslApi.SHAKE -> new SHAKE(spec.digestSize(), detectionLocation); + case OpenSslApi.MD2 -> new MD2(detectionLocation); + case OpenSslApi.MD4 -> new MD4(detectionLocation); + case OpenSslApi.MD5 -> new MD5(detectionLocation); + case OpenSslApi.RIPEMD -> new RIPEMD(spec.digestSize(), detectionLocation); + case OpenSslApi.BLAKE2B -> new BLAKE2b(detectionLocation); + case OpenSslApi.BLAKE2S -> new BLAKE2s(detectionLocation); + case OpenSslApi.SM3 -> new SM3(detectionLocation); + case OpenSslApi.WHIRLPOOL -> new Whirlpool(detectionLocation); + // No default digest: see the comment in symmetric(). A family present in the + // table but missing here reports nothing rather than the wrong hash. + default -> null; + }; + return Optional.ofNullable(messageDigest); + } + + // ===================================================================== + // Protocols + // ===================================================================== + + /** + * The protocol an {@code SSL_METHOD} function or a version constant stands for. + * + *

A TLS version becomes a versioned {@code TLS} node. SSL and DTLS get a plain {@code + * Protocol} node under their own name, following the way {@code + * CSharpProtocolContextTranslator} already reports {@code SslProtocols.Ssl3}: SSL is not TLS, + * and the mapper has no DTLS model, so calling either one a {@code TLS} would misreport it. + */ + @Nonnull + private static Optional protocol( + @Nonnull OpenSslApi.MethodSpec spec, @Nonnull DetectionLocation detectionLocation) { + if (OpenSslApi.TLS.equals(spec.protocol())) { + return spec.version() == null + ? Optional.of(new TLS(detectionLocation)) + : Optional.of(new TLS(new Version(spec.version(), detectionLocation))); + } + final String name = + spec.version() == null ? spec.protocol() : spec.protocol() + "v" + spec.version(); + return Optional.of(new Protocol(name, detectionLocation)); + } + + /** + * The suites a cipher-selection string names. + * + *

Split on {@code :}, every token checked against the shared suite table, and only the ones + * that are real suites kept -- the table already carries OpenSSL's own spellings, so {@code + * ECDHE-RSA-AES256-GCM-SHA384} resolves through it. The result is a {@code TLS} node carrying a + * {@code CipherSuiteCollection}, which is the shape the CBOM writer expects for a protocol's + * suites. A string with no recognisable suite in it -- a list expression such as {@code + * "HIGH:!aNULL"}, or an alias such as {@code "DEFAULT"} -- yields nothing. + */ + @Nonnull + private static Optional cipherSuites( + @Nonnull String selection, @Nonnull DetectionLocation detectionLocation) { + final List suites = new ArrayList<>(); + for (String token : selection.split(":")) { + final String name = token.trim(); + if (name.isEmpty() || CipherSuiteMapper.findCipherSuite(name).isEmpty()) { + continue; + } + new CipherSuiteMapper() + .parse(name, detectionLocation) + .filter(com.ibm.mapper.model.CipherSuite.class::isInstance) + .map(com.ibm.mapper.model.CipherSuite.class::cast) + .ifPresent(suites::add); + } + if (suites.isEmpty()) { + return Optional.empty(); + } + final TLS tls = new TLS(detectionLocation); + tls.put(new CipherSuiteCollection(suites)); + return Optional.of(tls); + } + + // ===================================================================== + // Algorithm-name strings + // ===================================================================== + + /** A name passed to one of the {@code *_fetch} / {@code *byname} / {@code Q_keygen} calls. */ + @Nonnull + private Optional translateAlgorithmName( + @Nonnull String kind, + @Nonnull String name, + @Nonnull DetectionLocation detectionLocation) { + return switch (kind) { + case "CIPHER" -> + OpenSslApi.cipherAlgorithmName(name) + .flatMap(spec -> symmetric(spec, detectionLocation)); + case "DIGEST" -> + OpenSslApi.digestAlgorithmName(name) + .flatMap(spec -> digest(spec, detectionLocation)); + case "KDF_FETCH" -> OpenSslApi.kdfName(name).flatMap(k -> kdf(k, detectionLocation)); + case "MAC_FETCH" -> OpenSslApi.macName(name).flatMap(m -> mac(m, detectionLocation)); + case "PKEY_Q_KEYGEN" -> keyType(name, detectionLocation); + default -> Optional.empty(); + }; + } + + /** + * The MAC an {@code EVP_MAC_fetch} name stands for. + * + *

No digest or cipher is attached. A MAC fetched by name carries neither: {@code + * EVP_MAC_CTX_set_params} supplies the {@code "digest"} or {@code "cipher"} parameter later, in + * a separate call whose own argument is already reported by the digest and cipher rules. An + * HMAC node with a guessed SHA-256 beneath it would be an invention. + */ + @Nonnull + private static Optional mac( + @Nonnull String macName, @Nonnull DetectionLocation detectionLocation) { + return switch (macName) { + case "HMAC" -> Optional.of(new HMAC(detectionLocation)); + case "CMAC" -> Optional.of(new CMAC(detectionLocation)); + case "KMAC-128" -> Optional.of(new KMAC(128, detectionLocation)); + case "KMAC-256" -> Optional.of(new KMAC(256, detectionLocation)); + case "Poly1305" -> Optional.of(new Poly1305(detectionLocation)); + case "SipHash" -> Optional.of(new SipHash(detectionLocation)); + default -> Optional.empty(); + }; + } + + /** The KDF an {@code EVP_KDF_fetch} name stands for. */ + @Nonnull + private static Optional kdf( + @Nonnull String kdfName, @Nonnull DetectionLocation detectionLocation) { + return switch (kdfName) { + case "PBKDF2" -> Optional.of(new PBKDF2(detectionLocation)); + case "PBKDF1" -> Optional.of(new PBKDF1(detectionLocation)); + case "SCRYPT" -> Optional.of(new Scrypt(detectionLocation)); + case "HKDF" -> Optional.of(new HKDF(detectionLocation)); + case "SSKDF" -> Optional.of(new ConcatenationKDF(detectionLocation)); + case "X942KDF" -> Optional.of(new ANSIX942(detectionLocation)); + case "X963KDF" -> Optional.of(new ANSIX963(detectionLocation)); + case "KBKDF" -> Optional.of(new KDFCounter(detectionLocation)); + case "KRB5KDF" -> Optional.of(new Kerberos(detectionLocation)); + case "PKCS12KDF" -> Optional.of(new PKCS12PBE(detectionLocation)); + default -> Optional.empty(); + }; + } + + /** + * The algorithm an {@code EVP_PKEY_Q_keygen} type string stands for. + * + *

Only the types the man page lists are accepted. {@code "EC"} yields a curveless {@code EC} + * node, and the curve arrives separately as that node's child from the variadic argument. + */ + @Nonnull + private static Optional keyType( + @Nonnull String type, @Nonnull DetectionLocation detectionLocation) { + return switch (type.trim().toUpperCase(java.util.Locale.ROOT)) { + case "RSA" -> Optional.of(new RSA(detectionLocation)); + case "RSA-PSS" -> Optional.of(new RSAssaPSS(detectionLocation)); + case "EC" -> Optional.of(new EllipticCurveAlgorithm(detectionLocation)); + case "ED25519" -> Optional.of(new Ed25519(detectionLocation)); + case "ED448" -> Optional.of(new Ed448(detectionLocation)); + case "X25519" -> Optional.of(new X25519(detectionLocation)); + case "X448" -> Optional.of(new X448(detectionLocation)); + case "SM2" -> Optional.of(new SM2(detectionLocation)); + case "DSA" -> Optional.of(new DSA(detectionLocation)); + case "DH" -> Optional.of(new DH(detectionLocation)); + case "ML-DSA-44" -> Optional.of(new MLDSA(44, detectionLocation)); + case "ML-DSA-65" -> Optional.of(new MLDSA(65, detectionLocation)); + case "ML-DSA-87" -> Optional.of(new MLDSA(87, detectionLocation)); + case "ML-KEM-512" -> Optional.of(new MLKEM(512, detectionLocation)); + case "ML-KEM-768" -> Optional.of(new MLKEM(768, detectionLocation)); + case "ML-KEM-1024" -> Optional.of(new MLKEM(1024, detectionLocation)); + default -> Optional.empty(); + }; + } + + // ===================================================================== + // Constants carried as a name or as a number + // ===================================================================== + + /** + * A constant that the engine resolved either to its member name or to its numeric value. + * + *

Which closed set to look it up in is decided by the rule's own context property, never by + * the shape of the value: {@code 4} is OAEP padding under a padding rule and NID 4 under none + * of them, and the two must not be able to swap. + */ + @Nonnull + private Optional translateParameter( + @Nonnull String kind, + @Nonnull String rawNameOrValue, + @Nonnull DetectionLocation detectionLocation) { + final String nameOrValue = normalizeConstant(rawNameOrValue); + return switch (kind) { + case "TLS" -> + OpenSslApi.protocolVersion(nameOrValue) + .flatMap(spec -> protocol(spec, detectionLocation)); + case "PADDING" -> + OpenSslApi.rsaPadding(nameOrValue).flatMap(p -> padding(p, detectionLocation)); + case "EC_CURVE_NID" -> + OpenSslApi.curveNid(nameOrValue) + .map( + curve -> + new EllipticCurveAlgorithm( + new EllipticCurve(curve, detectionLocation))); + case "EC_CURVE_NAME" -> + OpenSslApi.curveName(nameOrValue) + .map( + curve -> + new EllipticCurveAlgorithm( + new EllipticCurve(curve, detectionLocation))); + // The one variadic argument of EVP_PKEY_Q_keygen: a size_t for "RSA" and a curve-name + // string for "EC", per EVP_PKEY_keygen(3). A number is therefore the key length and a + // recognised curve name is the curve; anything else is neither and is dropped. + case "PKEY_Q_KEYGEN" -> keygenParameter(nameOrValue, detectionLocation); + default -> Optional.empty(); + }; + } + + /** + * A resolved constant in the spelling the tables use: a member name unchanged, a number in + * decimal. + * + *

Needed because a C# integer literal does not always reach a value factory as an {@code + * Integer}. The frontend classifies a decimal literal as an integer and resolves it to one, but + * a hexadecimal or binary literal arrives as the literal's own text, and OpenSSL's constants + * are conventionally written in hex -- {@code const int TLS1_2_VERSION = 0x0303;} is how every + * interop layer declares them, because that is how {@code prov_ssl.h} does. Normalising here + * rather than listing {@code "0x0303"} beside {@code "771"} in the table keeps one spelling per + * constant and covers {@code 0X}, {@code 0b}, digit separators and the {@code u}/{@code l} + * suffixes at the same time. + * + *

Anything that is not a numeric literal is returned untouched, so a member name is + * unaffected. + */ + @Nonnull + private static String normalizeConstant(@Nonnull String value) { + String text = value.trim().replace("_", ""); + int end = text.length(); + while (end > 0 && "uUlL".indexOf(text.charAt(end - 1)) >= 0) { + end--; + } + text = text.substring(0, end); + final int radix; + if (text.regionMatches(true, 0, "0x", 0, 2)) { + radix = 16; + } else if (text.regionMatches(true, 0, "0b", 0, 2)) { + radix = 2; + } else { + return value.trim(); + } + try { + return Long.toString(Long.parseLong(text.substring(2), radix)); + } catch (NumberFormatException e) { + return value.trim(); + } + } + + @Nonnull + private static Optional keygenParameter( + @Nonnull String nameOrValue, @Nonnull DetectionLocation detectionLocation) { + final String text = nameOrValue.trim(); + if (text.chars().allMatch(Character::isDigit) && !text.isEmpty()) { + return Optional.of(new KeyLength(Integer.parseInt(text), detectionLocation)); + } + return OpenSslApi.curveName(text).map(curve -> new EllipticCurve(curve, detectionLocation)); + } + + /** + * The padding node an {@code RSA_*_PADDING} constant stands for. + * + *

{@code RSA_NO_PADDING} is recorded as a padding named {@code NONE}: it is a finding in its + * own right -- raw RSA -- and leaving it out would make a textbook-RSA call site look like one + * whose padding simply was not readable. {@code PSS} and {@code X9.31} have no padding node in + * the mapper model, where PSS is an algorithm and X9.31 a signature scheme, so both are + * recorded as a plain named padding, as {@code DotNetPaddingNames} already does for {@code + * RSASignaturePadding.Pss}. + */ + @Nonnull + private static Optional padding( + @Nonnull String padding, @Nonnull DetectionLocation detectionLocation) { + return switch (padding) { + case "PKCS1" -> Optional.of(new PKCS1(detectionLocation)); + case "OAEP" -> Optional.of(new OAEP(detectionLocation)); + case "NONE", "PSS", "X9.31" -> Optional.of(new Padding(padding, detectionLocation)); + default -> Optional.empty(); + }; + } +} diff --git a/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java b/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java index a4f7e106e..e84d0769e 100644 --- a/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java +++ b/csharp/src/main/java/com/ibm/plugin/translation/translator/contexts/CSharpProtocolContextTranslator.java @@ -20,11 +20,13 @@ package com.ibm.plugin.translation.translator.contexts; import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.engine.model.CipherSuite; import com.ibm.engine.model.IValue; import com.ibm.engine.model.OperationMode; import com.ibm.engine.model.ValueAction; import com.ibm.engine.model.context.DetectionContext; import com.ibm.engine.rule.IBundle; +import com.ibm.mapper.mapper.ssl.CipherSuiteMapper; import com.ibm.mapper.mapper.ssl.SSLVersionMapper; import com.ibm.mapper.model.INode; import com.ibm.mapper.model.Protocol; @@ -79,10 +81,31 @@ public Optional translate( return switch (kind) { case "MAILKIT" -> secureSocketOptions(protocol.asString(), detectionLocation); case "SSL_MODE" -> sslMode(protocol.asString(), detectionLocation); + case "SCHANNEL_PACKAGE" -> schannelPackage(protocol.asString(), detectionLocation); + case "SCHANNEL_PROTOCOLS" -> + schannelProtocol(protocol.asString(), detectionLocation); default -> dotNetProtocol(protocol.asString(), detectionLocation); }; } + if (value instanceof CipherSuite cipherSuite) { + // The members of TlsCipherSuite are spelled exactly like the IANA registry entries, so + // the name goes straight into the project's existing table and comes back as the key + // exchange, cipher and digest it is made of. + // + // The table is also the guard, and it has to be consulted separately: CipherSuiteMapper + // falls back to a bare CipherSuite named after whatever string it was given, which is + // reasonable for the Java rules (there the argument is a suite name the developer wrote + // out) but not here. This rule reads an enum member through an ANY-typed parameter, so + // a member of some unrelated enum would otherwise become a cipher suite named after + // itself. Only a name the registry knows is accepted. + return CipherSuiteMapper.findCipherSuite(cipherSuite.get()).isPresent() + ? new CipherSuiteMapper() + .parse(cipherSuite.get(), detectionLocation) + .map(n -> n) + : Optional.empty(); + } + if (value instanceof OperationMode operationMode && !kind.isEmpty()) { // The useTls flag of fo-dicom's DicomClientFactory.Create. Only a true is recorded: a // false means the connection carries no TLS, and the absence of a TLS node under the @@ -174,6 +197,110 @@ private static Optional sslMode( }; } + /** + * Whether an SSPI security-package name is Schannel's, in which case the call speaks TLS. + * + *

{@code AcquireCredentialsHandle} is matched on an {@code ANY} receiver and acquires + * credentials for every security package Windows has, so the package name is the whole guard. + * Schannel answers to two names, {@code UNISP_NAME} ("Microsoft Unified Security Protocol + * Provider") and {@code SCHANNEL_NAME} ("Schannel"), each with an {@code _A}/{@code _W} + * spelling; both the constant's name and its literal value are accepted, because a member + * access arrives as the member's name while a string constant arrives as the string. Anything + * else -- {@code "Negotiate"}, {@code "Kerberos"}, {@code "NTLM"}, {@code "CredSSP"}, or a name + * that is not a package at all -- is not TLS and reports nothing. + * + *

No version is claimed. The package name says which protocol family is spoken, never which + * version of it. + */ + @Nonnull + private static Optional schannelPackage( + @Nonnull String name, @Nonnull DetectionLocation detectionLocation) { + return switch (name) { + case "UNISP_NAME", + "UNISP_NAME_A", + "UNISP_NAME_W", + "Microsoft Unified Security Protocol Provider", + "SCHANNEL_NAME", + "SCHANNEL_NAME_A", + "SCHANNEL_NAME_W", + "Schannel" -> + Optional.of(new TLS(detectionLocation)); + default -> Optional.empty(); + }; + } + + /** + * The protocol an {@code SP_PROT_*} flag name stands for, or nothing when the name is not one + * of them. + * + *

This reads {@code SCHANNEL_CRED.grbitEnabledProtocols} only, which is an allow-list. The + * identically-spelled flags also appear in {@code TLS_PARAMETERS.grbitDisabledProtocols}, where + * they mean the opposite; no rule reads that field, and that is deliberate -- a disabled + * protocol is not a protocol in use, so there is nothing for it to become. + * + *

The flag names are a closed set and that set is the guard, for the same reason it is on + * the .NET side: the assignment is matched on an {@code ANY} receiver and arrives here with + * whatever name its argument resolved to, which need not be a flag at all. A client-side flag + * and its server-side twin name the same protocol version -- the direction is a property of the + * endpoint, not of the cryptography -- so both map to one version, as does the {@code server | + * client} alias. + * + *

{@code SP_PROT_NONE} is zero, which the documentation defines as "Schannel selects the + * protocol"; it states TLS without a version, exactly as {@code SslProtocols.None} does. SSL 2 + * and SSL 3 keep their own names rather than becoming a nameless TLS, and DTLS likewise, so a + * reader sees what was actually offered. + * + *

Two groups of flags name DTLS without naming a version: {@code SP_PROT_DTLS_CLIENT}/{@code + * _SERVER} and {@code SP_PROT_DTLS1_X_*}. The first pair is an alias of DTLS 1.0 in the header, + * but its name does not say so, and a version is not something to read out of an alias, so both + * groups report a bare {@code DTLS}. + * + *

Two further groups of real flags deliberately yield nothing at all. {@code SP_PROT_PCT1_*} + * is Private Communications Technology, which Schannel has not supported since Windows 2000, so + * a request for it cannot describe anything the code does. And the whole-range masks ({@code + * SP_PROT_ALL}, {@code SP_PROT_UNI_*}, {@code ClientProtocolMask}) enumerate nothing specific, + * so nothing specific is recorded. + * + *

A flag written as a number rather than a name also lands here, as the literal's own source + * text, and falls through to nothing. That is deliberate; {@code Schannel}'s rule for the field + * explains why the numbers are not decoded. + */ + @Nonnull + private static Optional schannelProtocol( + @Nonnull String name, @Nonnull DetectionLocation detectionLocation) { + return switch (name) { + case "SP_PROT_TLS1_3_CLIENT", "SP_PROT_TLS1_3_SERVER", "SP_PROT_TLS1_3" -> + versioned("TLSv1.3", detectionLocation); + case "SP_PROT_TLS1_2_CLIENT", "SP_PROT_TLS1_2_SERVER", "SP_PROT_TLS1_2" -> + versioned("TLSv1.2", detectionLocation); + case "SP_PROT_TLS1_1_CLIENT", "SP_PROT_TLS1_1_SERVER", "SP_PROT_TLS1_1" -> + versioned("TLSv1.1", detectionLocation); + case "SP_PROT_TLS1_0_CLIENT", + "SP_PROT_TLS1_0_SERVER", + "SP_PROT_TLS1_0", + "SP_PROT_TLS1_CLIENT", + "SP_PROT_TLS1_SERVER", + "SP_PROT_TLS1" -> + versioned("TLSv1.0", detectionLocation); + case "SP_PROT_SSL3_CLIENT", "SP_PROT_SSL3_SERVER", "SP_PROT_SSL3" -> + Optional.of(new Protocol("SSLv3", detectionLocation)); + case "SP_PROT_SSL2_CLIENT", "SP_PROT_SSL2_SERVER", "SP_PROT_SSL2" -> + Optional.of(new Protocol("SSLv2", detectionLocation)); + case "SP_PROT_DTLS1_2_CLIENT", "SP_PROT_DTLS1_2_SERVER", "SP_PROT_DTLS1_2" -> + Optional.of(new Protocol("DTLSv1.2", detectionLocation)); + case "SP_PROT_DTLS1_0_CLIENT", "SP_PROT_DTLS1_0_SERVER", "SP_PROT_DTLS1_0" -> + Optional.of(new Protocol("DTLSv1.0", detectionLocation)); + case "SP_PROT_DTLS_CLIENT", + "SP_PROT_DTLS_SERVER", + "SP_PROT_DTLS1_X_CLIENT", + "SP_PROT_DTLS1_X_SERVER", + "SP_PROT_DTLS1_X" -> + Optional.of(new Protocol("DTLS", detectionLocation)); + case "SP_PROT_NONE" -> Optional.of(new TLS(detectionLocation)); + default -> Optional.empty(); + }; + } + @Nonnull private static Optional versioned( @Nonnull String wireName, @Nonnull DetectionLocation detectionLocation) { diff --git a/csharp/src/test/files/rules/detection/dotnet/DotNetCipherSuitesTestFile.cs b/csharp/src/test/files/rules/detection/dotnet/DotNetCipherSuitesTestFile.cs new file mode 100644 index 000000000..9aefaa676 --- /dev/null +++ b/csharp/src/test/files/rules/detection/dotnet/DotNetCipherSuitesTestFile.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Net.Security; + +/* + * CipherSuitesPolicy is the only .NET API that names cipher suites directly, and TlsCipherSuite's + * members are spelled like the IANA registry entries. Shapes modelled on dotnet/runtime's own + * tests and on real Linux-side TLS hardening code. + */ +public class DotNetCipherSuitesUsage +{ + /* The TLS 1.3 suites, as an array initializer. */ + public CipherSuitesPolicy Tls13Suites() + { + return new CipherSuitesPolicy(new[] + { + TlsCipherSuite.TLS_AES_256_GCM_SHA384, + TlsCipherSuite.TLS_CHACHA20_POLY1305_SHA256 + }); + } + + /* A single suite, the narrowest possible policy. */ + public CipherSuitesPolicy OneSuite() + { + return new CipherSuitesPolicy(new[] { TlsCipherSuite.TLS_AES_128_GCM_SHA256 }); + } + + /* The explicit element type, and a TLS 1.2 ECDHE suite. */ + public CipherSuitesPolicy Tls12Suite() + { + return new CipherSuitesPolicy( + new TlsCipherSuite[] { TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 }); + } + + /* Assigned onto the authentication options, which is how it reaches a handshake. */ + public SslClientAuthenticationOptions OnClientOptions(string host) + { + return new SslClientAuthenticationOptions + { + TargetHost = host, + CipherSuitesPolicy = new CipherSuitesPolicy(new[] + { + TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + }) + }; + } + + /* A weak suite has to be visible as itself -- that is what an inventory is read for. */ + public CipherSuitesPolicy WeakSuite() + { + return new CipherSuitesPolicy(new[] { TlsCipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA }); + } + + /* Counter-check: the list comes from elsewhere, so no suite is knowable. */ + public CipherSuitesPolicy FromParameter(IEnumerable configured) + { + return new CipherSuitesPolicy(configured); + } + + /* Counter-check: a member of a different enum must not become a cipher suite. */ + public CipherSuitesPolicy UnrelatedEnumMember() + { + return new CipherSuitesPolicy(new[] { SomeOtherEnum.TLS_SOMETHING_INVENTED }); + } + + /* Counter-check: an unrelated constructor of the same arity on another type. */ + public SomePolicy UnrelatedPolicy() + { + return new SomePolicy(new[] { TlsCipherSuite.TLS_AES_128_GCM_SHA256 }); + } +} diff --git a/csharp/src/test/files/rules/detection/dotnet/DotNetHexLiteralTestFile.cs b/csharp/src/test/files/rules/detection/dotnet/DotNetHexLiteralTestFile.cs new file mode 100644 index 000000000..4e3c01574 --- /dev/null +++ b/csharp/src/test/files/rules/detection/dotnet/DotNetHexLiteralTestFile.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; + +/* + * A key size written in hex or binary is the same number as in decimal. The C# lexer has three + * integer tokens -- INTEGER_LITERAL, HEX_INTEGER_LITERAL, BIN_INTEGER_LITERAL -- and the converter + * used to classify only the first as an integer, so 0x100 fell through to the string branch and + * the key length was silently lost. These are the shapes that pins it. + */ +public class DotNetHexLiteralUsage +{ + /* Decimal, the control case. */ + public void DecimalKeySize() + { + var aes = Aes.Create(); + aes.KeySize = 256; + } + + /* The same size in hex -- 0x100 is 256. */ + public void HexKeySize() + { + var aes = Aes.Create(); + aes.KeySize = 0x100; + } + + /* Upper-case prefix and digits. */ + public void HexUpperCase() + { + var aes = Aes.Create(); + aes.KeySize = 0XC0; + } + + /* Binary, and with the digit separator C# allows. */ + public void BinaryKeySize() + { + var aes = Aes.Create(); + aes.KeySize = 0b1000_0000; + } + + /* Counter-check: a size from a parameter stays unknown, in any base. */ + public void FromParameter(int configured) + { + var aes = Aes.Create(); + aes.KeySize = configured; + } +} diff --git a/csharp/src/test/files/rules/detection/openssl/OpenSslCipherTestFile.cs b/csharp/src/test/files/rules/detection/openssl/OpenSslCipherTestFile.cs new file mode 100644 index 000000000..e5f17b02c --- /dev/null +++ b/csharp/src/test/files/rules/detection/openssl/OpenSslCipherTestFile.cs @@ -0,0 +1,90 @@ +/* + * OpenSSL's symmetric EVP interface as C# reaches it. + * + * The shapes are taken from openssl-net (ManagedOpenSsl), the managed wrapper whose Native.cs + * declares every one of these as a `[DllImport] public extern static` and whose Cipher.cs holds + * them in static fields: + * + * public static Cipher AES_256_CBC = new Cipher(Native.EVP_aes_256_cbc(), false); + * Native.EVP_EncryptInit_ex(ctx, cipher.Handle, IntPtr.Zero, key, iv); + * + * Both the qualified form and the bare form a `using static` import produces are covered, because + * the C# frontend reports an empty receiver for the latter and the holder's name for the former. + */ + +using System; +using System.Runtime.InteropServices; + +public class OpenSslCipherUsage +{ + // The P/Invoke declarations themselves. A declaration is not a call, so none of these is a + // detection -- which is what keeps a wrapper's own Native.cs from reporting every algorithm + // OpenSSL has. + [DllImport("libcrypto")] private static extern IntPtr EVP_aes_128_cbc(); + [DllImport("libcrypto")] private static extern IntPtr EVP_aes_256_gcm(); + [DllImport("libcrypto")] private static extern int EVP_EncryptInit_ex(IntPtr ctx, IntPtr type, IntPtr impl, byte[] key, byte[] iv); + [DllImport("libcrypto")] private static extern IntPtr EVP_CIPHER_fetch(IntPtr libctx, string algorithm, string properties); + [DllImport("libcrypto")] private static extern IntPtr EVP_get_cipherbyname(string name); + + /* The inline shape: the cipher is written as an argument of the call that uses it. */ + public void InlineCipher(IntPtr ctx, byte[] key, byte[] iv) + { + EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), IntPtr.Zero, key, iv); + } + + /* The qualified shape, which is how a wrapper writes it. */ + public void QualifiedCipher(IntPtr ctx, byte[] key, byte[] iv) + { + Native.EVP_EncryptInit_ex(ctx, Native.EVP_aes_128_cbc(), IntPtr.Zero, key, iv); + } + + /* Held in a local first, the way openssl-net's Cipher class does. */ + public IntPtr HeldInLocal() + { + IntPtr cipher = Native.EVP_chacha20_poly1305(); + return cipher; + } + + /* Three-key Triple DES: the key length is what separates `ede3` from `ede`. */ + public IntPtr TripleDes() => Native.EVP_des_ede3_cbc(); + + /* Two-key Triple DES. */ + public IntPtr TwoKeyTripleDes() => Native.EVP_des_ede_cbc(); + + /* A stream cipher whose key length OpenSSL lets the caller set later: none is claimed. */ + public IntPtr StreamCipher() => Native.EVP_rc4(); + + /* The export-grade variant, whose name does state the key length. */ + public IntPtr ExportStreamCipher() => Native.EVP_rc4_40(); + + /* A feedback mode whose width is in the name. */ + public IntPtr NarrowFeedback() => Native.EVP_aes_192_cfb8(); + + /* Key wrap, with and without padding. */ + public IntPtr KeyWrap() => Native.EVP_aes_256_wrap(); + + /* Single DES in ECB -- no key length, because the name states none. */ + public IntPtr SingleDes() => Native.EVP_des_ecb(); + + /* A 3.0 fetch by algorithm name. */ + public IntPtr Fetch(IntPtr libctx) => Native.EVP_CIPHER_fetch(libctx, "AES-256-GCM", null); + + /* The pre-3.0 lookup by name, with OpenSSL's own spelling of Triple DES. */ + public IntPtr ByName() => Native.EVP_get_cipherbyname("DES-EDE3-CBC"); + + /* Counter-check: the algorithm name arrives in a method parameter, so none may be invented. */ + public IntPtr FetchFromParameter(IntPtr libctx, string algorithm) + => Native.EVP_CIPHER_fetch(libctx, algorithm, null); + + /* Counter-check: a string that is not an OpenSSL cipher name. */ + public IntPtr FetchUnknown(IntPtr libctx) => Native.EVP_CIPHER_fetch(libctx, "ROT13-256-CBC", null); + + /* Counter-check: a function OpenSSL does not have. AES-512 does not exist. */ + public IntPtr NotAFunction() => Native.EVP_aes_512_gcm(); + + /* Counter-check: EVP_EncryptInit_ex on its own names no algorithm, so it is not a rule. */ + public void InitWithHandle(IntPtr ctx, IntPtr handle, byte[] key, byte[] iv) + { + Native.EVP_EncryptInit_ex(ctx, handle, IntPtr.Zero, key, iv); + } +} diff --git a/csharp/src/test/files/rules/detection/openssl/OpenSslDigestTestFile.cs b/csharp/src/test/files/rules/detection/openssl/OpenSslDigestTestFile.cs new file mode 100644 index 000000000..18d8e85da --- /dev/null +++ b/csharp/src/test/files/rules/detection/openssl/OpenSslDigestTestFile.cs @@ -0,0 +1,120 @@ +/* + * OpenSSL's digests and HMAC as C# reaches them. + * + * openssl-net's MessageDigest.cs holds every digest the same way it holds the ciphers -- + * `public static MessageDigest SHA256 = new MessageDigest(Native.EVP_sha256(), false);` -- and its + * HMAC.cs calls `Native.HMAC(digest.Handle, key, key.Length, data, data.Length, hash, ref len)` + * with the digest as a field access, which is exactly the shape that cannot be resolved and must + * therefore leave the HMAC component without a digest rather than guess one. + */ + +using System; +using System.Runtime.InteropServices; + +public class OpenSslDigestUsage +{ + [DllImport("libcrypto")] private static extern IntPtr EVP_sha256(); + [DllImport("libcrypto")] private static extern int EVP_DigestInit_ex(IntPtr ctx, IntPtr type, IntPtr impl); + + /* The inline shape. */ + public void InlineDigest(IntPtr ctx) + { + EVP_DigestInit_ex(ctx, EVP_sha256(), IntPtr.Zero); + } + + public IntPtr Sha1() => Native.EVP_sha1(); + + public IntPtr Sha384() => Native.EVP_sha384(); + + public IntPtr Sha3() => Native.EVP_sha3_256(); + + public IntPtr Shake() => Native.EVP_shake256(); + + public IntPtr Md5() => Native.EVP_md5(); + + public IntPtr Blake2() => Native.EVP_blake2b512(); + + public IntPtr Sm3() => Native.EVP_sm3(); + + public IntPtr Ripemd() => Native.EVP_ripemd160(); + + /* A 3.0 fetch by name, in the provider's own spelling. */ + public IntPtr Fetch(IntPtr libctx) => Native.EVP_MD_fetch(libctx, "SHA2-512", null); + + /* The pre-3.0 lookup by name. */ + public IntPtr ByName() => Native.EVP_get_digestbyname("SHA256"); + + /* The one-shot HMAC: a keyed MAC happens here, and the digest is a handle we cannot read. */ + public void OneShotHmac(IntPtr md, byte[] key, byte[] data, byte[] hash, ref uint len) + { + Native.HMAC(md, key, key.Length, data, data.Length, hash, ref len); + } + + /* The streaming form, deprecated in 3.0 and still the common one. */ + public void StreamingHmac(IntPtr ctx, byte[] key, IntPtr md) + { + Native.HMAC_Init_ex(ctx, key, key.Length, md, IntPtr.Zero); + } + + /* The 1.1.0-deprecated four-argument form. */ + public void OldStreamingHmac(IntPtr ctx, byte[] key, IntPtr md) + { + Native.HMAC_Init(ctx, key, key.Length, md); + } + + /* Counter-check: a digest OpenSSL does not have. */ + public IntPtr NotADigest() => Native.EVP_sha2_384(); + + /* Counter-check: a name that is not a digest. */ + public IntPtr FetchUnknown(IntPtr libctx) => Native.EVP_MD_fetch(libctx, "SNEFRU-256", null); + + /* Counter-check: the name arrives in a parameter. */ + public IntPtr FetchFromParameter(IntPtr libctx, string name) => Native.EVP_MD_fetch(libctx, name, null); + + /* + * Counter-check: EVP_md5_sha1 is the concatenated TLS 1.0/1.1 handshake hash. It is not one + * digest and has no model, so it is deliberately absent from the table and reports nothing. + */ + public IntPtr ConcatenatedHash() => Native.EVP_md5_sha1(); + + /* Counter-check: EVP_DigestInit_ex names no algorithm of its own. */ + public void InitWithHandle(IntPtr ctx, IntPtr md) + { + Native.EVP_DigestInit_ex(ctx, md, IntPtr.Zero); + } + + /* --- EVP_MAC_fetch: the only place OpenSSL names a MAC by string. --------------------- */ + + public IntPtr Hmac() => Native.EVP_MAC_fetch(IntPtr.Zero, "HMAC", null); + + public IntPtr Cmac() => Native.EVP_MAC_fetch(IntPtr.Zero, "CMAC", null); + + public IntPtr Kmac128() => Native.EVP_MAC_fetch(IntPtr.Zero, "KMAC-128", null); + + /* The hyphenless spelling OpenSSL also accepts. */ + public IntPtr Kmac256() => Native.EVP_MAC_fetch(IntPtr.Zero, "KMAC256", null); + + /* Lower case: OpenSSL's fetch is case-insensitive, so this is the same MAC. */ + public IntPtr Poly() => Native.EVP_MAC_fetch(IntPtr.Zero, "poly1305", null); + + public IntPtr Sip() => Native.EVP_MAC_fetch(IntPtr.Zero, "SipHash", null); + + /* + * Counter-check: GMAC is AES-GCM used as a MAC. The mapper has GMAC only as a cipher mode, so + * there is no algorithm node for it -- and naming it AES would state an encryption that is not + * happening. + */ + public IntPtr Gmac() => Native.EVP_MAC_fetch(IntPtr.Zero, "GMAC", null); + + /* + * Counter-check: BLAKE2BMAC is a keyed MAC. The mapper has BLAKE2b as a message digest, and + * reporting that would say the code hashes where it authenticates. + */ + public IntPtr Blake2bMac() => Native.EVP_MAC_fetch(IntPtr.Zero, "BLAKE2BMAC", null); + + /* Counter-check: a MAC that does not exist. */ + public IntPtr Invented() => Native.EVP_MAC_fetch(IntPtr.Zero, "ROT13MAC", null); + + /* Counter-check: the name arrives in a parameter. */ + public IntPtr MacFromParameter(string name) => Native.EVP_MAC_fetch(IntPtr.Zero, name, null); +} diff --git a/csharp/src/test/files/rules/detection/openssl/OpenSslKeyTestFile.cs b/csharp/src/test/files/rules/detection/openssl/OpenSslKeyTestFile.cs new file mode 100644 index 000000000..d4c8e3b92 --- /dev/null +++ b/csharp/src/test/files/rules/detection/openssl/OpenSslKeyTestFile.cs @@ -0,0 +1,184 @@ +/* + * OpenSSL's key generation, RSA padding and password-based derivation as C# reaches them. + * + * openssl-net declares `RSA_generate_key_ex(IntPtr rsa, int bits, IntPtr e, bn_gencb_st cb)` and + * calls it with the size as a literal, which is the shape that carries the detail. The 3.x + * EVP_PKEY_CTX form is the one a new interop layer writes. + */ + +using System; +using System.Runtime.InteropServices; + +public class OpenSslKeyUsage +{ + private const int RSA_PKCS1_PADDING = 1; + private const int RSA_NO_PADDING = 3; + private const int RSA_PKCS1_OAEP_PADDING = 4; + private const int NID_X9_62_prime256v1 = 415; + private const int NID_secp384r1 = 715; + + [DllImport("libcrypto")] private static extern int RSA_generate_key_ex(IntPtr rsa, int bits, IntPtr e, IntPtr cb); + [DllImport("libcrypto")] private static extern int PKCS5_PBKDF2_HMAC(byte[] pass, int passlen, byte[] salt, int saltlen, int iter, IntPtr digest, int keylen, byte[] outKey); + + /* The deprecated-but-ubiquitous RSA key generation. */ + public void GenerateRsa4096(IntPtr rsa, IntPtr e) + { + RSA_generate_key_ex(rsa, 4096, e, IntPtr.Zero); + } + + /* A size that should be flagged. */ + public void GenerateRsa1024(IntPtr rsa, IntPtr e) + { + Native.RSA_generate_key_ex(rsa, 1024, e, IntPtr.Zero); + } + + /* The 0.9.8-deprecated form, where the size is the first argument. */ + public IntPtr GenerateRsaOldest() => Native.RSA_generate_key(2048, 65537, IntPtr.Zero, IntPtr.Zero); + + /* Multi-prime RSA: the size is read, the prime count is not modelled. */ + public void GenerateMultiPrime(IntPtr rsa, IntPtr e) + { + Native.RSA_generate_multi_prime_key(rsa, 3072, 3, e, IntPtr.Zero); + } + + /* The 3.0 one-liner. */ + public IntPtr EvpRsaGen() => Native.EVP_RSA_gen(2048); + + /* The 3.x EVP_PKEY_CTX form. */ + public void CtxRsaBits(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, 3072); + } + + /* DSA and Diffie-Hellman prime lengths. */ + public void DsaAndDh(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_dsa_paramgen_bits(ctx, 2048); + Native.EVP_PKEY_CTX_set_dh_paramgen_prime_len(ctx, 2048); + } + + /* An EC key on P-256, by NID constant resolving to its numeric value. */ + public void EcByNid(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, NID_X9_62_prime256v1); + } + + /* The same with a NID held as a member, which resolves by name. */ + public void EcByNidMember(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, Nid.NID_secp384r1); + } + + /* The 3.0 way: the curve as a group-name string. */ + public void EcByGroupName(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_group_name(ctx, "P-521"); + } + + /* The 3.0 shorthand, RSA form: the size is the one variadic argument. */ + public IntPtr QuickRsa(IntPtr libctx) => Native.EVP_PKEY_Q_keygen(libctx, null, "RSA", 4096); + + /* The same, EC form, where the variadic argument is a curve name. */ + public IntPtr QuickEc(IntPtr libctx) => Native.EVP_PKEY_Q_keygen(libctx, null, "EC", "P-384"); + + /* A parameterless type needs no further argument. */ + public IntPtr QuickEd25519(IntPtr libctx) => Native.EVP_PKEY_Q_keygen(libctx, null, "ED25519"); + + /* A post-quantum type. */ + public IntPtr QuickMlDsa(IntPtr libctx) => Native.EVP_PKEY_Q_keygen(libctx, null, "ML-DSA-65"); + + /* Every padding constant, by value. */ + public void Paddings(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_OAEP_PADDING); + Native.EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_PKCS1_PADDING); + Native.EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_NO_PADDING); + } + + /* And by name, including the two that have no padding node of their own. */ + public void PaddingsByName(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_rsa_padding(ctx, Rsa.RSA_PKCS1_PSS_PADDING); + Native.EVP_PKEY_CTX_set_rsa_padding(ctx, Rsa.RSA_X931_PADDING); + } + + /* The raw RSA primitives, which state the direction as well as the padding. */ + public void RawOperations(byte[] from, byte[] to, IntPtr rsa) + { + Native.RSA_public_encrypt(from.Length, from, to, rsa, RSA_PKCS1_OAEP_PADDING); + Native.RSA_private_decrypt(to.Length, to, from, rsa, RSA_PKCS1_OAEP_PADDING); + Native.RSA_private_encrypt(from.Length, from, to, rsa, RSA_PKCS1_PADDING); + Native.RSA_public_decrypt(to.Length, to, from, rsa, RSA_PKCS1_PADDING); + } + + /* PBKDF2 with a modern iteration count, a 16-byte salt and a 32-byte output. */ + public void Pbkdf2(byte[] pass, byte[] salt, byte[] outKey) + { + PKCS5_PBKDF2_HMAC(pass, pass.Length, salt, 16, 600000, Native.EVP_sha256(), 32, outKey); + } + + /* The SHA-1 shorthand, whose name states the pseudo-random function. */ + public void Pbkdf2Sha1(byte[] pass, byte[] salt, byte[] outKey) + { + Native.PKCS5_PBKDF2_HMAC_SHA1(pass, pass.Length, salt, 8, 1000, 20, outKey); + } + + /* scrypt: the salt and key lengths are read, the cost parameters are not modelled. */ + public void Scrypt(byte[] pass, byte[] salt, byte[] key) + { + Native.EVP_PBE_scrypt(pass, pass.Length, salt, 32, 16384, 8, 1, 0, key, 64); + } + + /* The 3.0 variant with a library context and a property query. */ + public void ScryptEx(IntPtr libctx, byte[] pass, byte[] salt, byte[] key) + { + Native.EVP_PBE_scrypt_ex(pass, pass.Length, salt, 32, 16384, 8, 1, 0, key, 64, libctx, null); + } + + /* A KDF fetched by name. */ + public IntPtr FetchHkdf(IntPtr libctx) => Native.EVP_KDF_fetch(libctx, "HKDF", null); + + public IntPtr FetchPbkdf2(IntPtr libctx) => Native.EVP_KDF_fetch(libctx, "PBKDF2", null); + + /* Counter-check: the key size arrives in a method parameter. */ + public void RsaSizeFromParameter(IntPtr rsa, int bits, IntPtr e) + { + Native.RSA_generate_key_ex(rsa, bits, e, IntPtr.Zero); + } + + /* Counter-check: the iteration count arrives in a method parameter. */ + public void Pbkdf2IterationsFromParameter(byte[] pass, byte[] salt, int iterations, byte[] outKey) + { + Native.PKCS5_PBKDF2_HMAC(pass, pass.Length, salt, 16, iterations, Native.EVP_sha256(), 32, outKey); + } + + /* Counter-check: a NID that is not a curve. */ + public void NotACurveNid(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, 6); + } + + /* Counter-check: a group name OpenSSL does not have. */ + public void NotACurveName(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_group_name(ctx, "P-128"); + } + + /* Counter-check: a padding constant that is not one. */ + public void NotAPadding(IntPtr ctx) + { + Native.EVP_PKEY_CTX_set_rsa_padding(ctx, 42); + } + + /* Counter-check: a key type EVP_PKEY_Q_keygen does not accept. */ + public IntPtr QuickUnknownType(IntPtr libctx) => Native.EVP_PKEY_Q_keygen(libctx, null, "NTRU", 701); + + /* + * Counter-check: EVP_PKEY_keygen names neither algorithm nor size -- both were set on the + * context earlier -- so it is not a rule. + */ + public void KeygenOnly(IntPtr ctx, IntPtr pkey) + { + Native.EVP_PKEY_keygen(ctx, ref pkey); + } +} diff --git a/csharp/src/test/files/rules/detection/openssl/OpenSslRandomTestFile.cs b/csharp/src/test/files/rules/detection/openssl/OpenSslRandomTestFile.cs new file mode 100644 index 000000000..08abb32b7 --- /dev/null +++ b/csharp/src/test/files/rules/detection/openssl/OpenSslRandomTestFile.cs @@ -0,0 +1,47 @@ +/* + * OpenSSL's random byte source as C# reaches it. openssl-net declares + * `RAND_bytes(byte[] buf, int num)` and calls it from its Random class. + */ + +using System; +using System.Runtime.InteropServices; + +public class OpenSslRandomUsage +{ + [DllImport("libcrypto")] private static extern int RAND_bytes(byte[] buf, int num); + + public void PublicBytes(byte[] buf) + { + RAND_bytes(buf, buf.Length); + } + + public void PrivateBytes(byte[] buf) + { + Native.RAND_priv_bytes(buf, buf.Length); + } + + public void Deprecated(byte[] buf) + { + Native.RAND_pseudo_bytes(buf, buf.Length); + } + + public void WithLibraryContext(IntPtr libctx, byte[] buf) + { + Native.RAND_bytes_ex(libctx, buf, buf.Length, 256); + } + + /* + * Counter-check: configuring the DRBG type is not a use of it, and naming the concrete + * construction from a RAND_bytes call site would be a guess about the deployment. + */ + public void SetType(IntPtr libctx) + { + Native.RAND_set_DRBG_type(libctx, "CTR-DRBG", null, null, null); + } + + /* Counter-check: RAND_seed and RAND_add feed the pool; they produce nothing. */ + public void Seed(byte[] buf) + { + Native.RAND_seed(buf, buf.Length); + } +} diff --git a/csharp/src/test/files/rules/detection/openssl/OpenSslTlsTestFile.cs b/csharp/src/test/files/rules/detection/openssl/OpenSslTlsTestFile.cs new file mode 100644 index 000000000..93541c447 --- /dev/null +++ b/csharp/src/test/files/rules/detection/openssl/OpenSslTlsTestFile.cs @@ -0,0 +1,131 @@ +/* + * OpenSSL's TLS configuration as C# reaches it. + * + * The constants appear in both of the two spellings P/Invoke code uses: as members of a holder + * class, which openssl-net does (`Native.SSL_OP_NO_TLSv1`), and as C# `const int` fields, which is + * how a hand-rolled interop layer usually declares them. The first resolves to the member's name + * and the second through the constant to its number, and both have to land on the same version. + */ + +using System; +using System.Runtime.InteropServices; + +public class OpenSslTlsUsage +{ + // The version constants as a hand-rolled interop layer declares them, with the values from + // include/openssl/prov_ssl.h. + private const int TLS1_VERSION = 0x0301; + private const int TLS1_1_VERSION = 0x0302; + private const int TLS1_2_VERSION = 0x0303; + private const int TLS1_3_VERSION = 0x0304; + private const int SSL3_VERSION = 0x0300; + private const int DTLS1_2_VERSION = 0xFEFD; + + [DllImport("libssl")] private static extern IntPtr SSL_CTX_new(IntPtr method); + [DllImport("libssl")] private static extern IntPtr TLS_client_method(); + [DllImport("libssl")] private static extern int SSL_CTX_set_min_proto_version(IntPtr ctx, int version); + + /* A version-flexible client context: TLS, and no version is stated anywhere. */ + public IntPtr FlexibleClient() => SSL_CTX_new(TLS_client_method()); + + /* The deprecated version-locked method: TLS 1.2 and nothing else. */ + public IntPtr LockedToTls12() => Native.SSL_CTX_new(Native.TLSv1_2_method()); + + /* The server side of the same. */ + public IntPtr LockedServer() => Native.SSL_CTX_new(Native.TLSv1_1_method()); + + /* SSL 3.0 is not TLS, and has to be reported as what it is. */ + public IntPtr Sslv3() => Native.SSL_CTX_new(Native.SSLv3_method()); + + /* DTLS, version-flexible and version-locked. */ + public IntPtr Dtls() => Native.SSL_CTX_new(Native.DTLS_method()); + + public IntPtr Dtls12() => Native.SSL_CTX_new(Native.DTLSv1_2_method()); + + /* Every TLS version constant, as a C# const resolving to its wire value. */ + public void MinimumVersions(IntPtr ctx) + { + Native.SSL_CTX_set_min_proto_version(ctx, TLS1_VERSION); + Native.SSL_CTX_set_min_proto_version(ctx, TLS1_1_VERSION); + Native.SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + Native.SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION); + Native.SSL_CTX_set_min_proto_version(ctx, SSL3_VERSION); + Native.SSL_CTX_set_min_proto_version(ctx, DTLS1_2_VERSION); + } + + /* The same constants as members of a holder class, which resolve by name instead. */ + public void ByMemberName(IntPtr ctx, IntPtr ssl) + { + Native.SSL_CTX_set_max_proto_version(ctx, Ssl.TLS1_3_VERSION); + Native.SSL_set_min_proto_version(ssl, Ssl.TLS1_2_VERSION); + } + + /* The TLS 1.2 cipher list, naming one suite. */ + public void CipherList(IntPtr ctx) + { + Native.SSL_CTX_set_cipher_list(ctx, "ECDHE-RSA-AES256-GCM-SHA384"); + } + + /* Two suites in one list. */ + public void TwoSuites(IntPtr ctx) + { + Native.SSL_CTX_set_cipher_list(ctx, "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384"); + } + + /* The TLS 1.3 path, which takes IANA suite names. */ + public void CipherSuites(IntPtr ctx) + { + Native.SSL_CTX_set_ciphersuites(ctx, "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"); + } + + /* A weak suite, which is exactly what an inventory is read for. */ + public void LegacySuite(IntPtr ctx) + { + Native.SSL_CTX_set_cipher_list(ctx, "DES-CBC3-SHA"); + } + + /* + * The negative form. A TLS context is being configured, which is reported, but the disabled + * versions are not turned into used ones -- SSL_OP_NO_TLSv1 means TLS 1.0 is refused. + */ + public void NegativeForm(IntPtr ctx) + { + Native.SSL_CTX_set_options(ctx, Native.SSL_OP_NO_SSLv3 | Native.SSL_OP_NO_TLSv1 | Native.SSL_OP_NO_TLSv1_1); + } + + /* Counter-check: 0 means "whatever this build supports", which names no version. */ + public void AutoVersion(IntPtr ctx) + { + Native.SSL_CTX_set_min_proto_version(ctx, 0); + } + + /* Counter-check: the version arrives in a method parameter. */ + public void VersionFromParameter(IntPtr ctx, int version) + { + Native.SSL_CTX_set_min_proto_version(ctx, version); + } + + /* Counter-check: a constant this plugin does not know. */ + public void UnknownConstant(IntPtr ctx) + { + Native.SSL_CTX_set_min_proto_version(ctx, Ssl.TLS1_4_VERSION); + } + + /* Counter-check: a cipher-list expression is a selector, not a list of suites. */ + public void ListExpression(IntPtr ctx) + { + Native.SSL_CTX_set_cipher_list(ctx, "HIGH:!aNULL:!MD5"); + } + + /* Counter-check: an alias names no suite either. */ + public void Alias(IntPtr ctx) + { + Native.SSL_CTX_set_cipher_list(ctx, "DEFAULT"); + } + + /* Counter-check: the list is built at run time. */ + public void ListFromParameter(IntPtr ctx, string ciphers) + { + Native.SSL_CTX_set_cipher_list(ctx, ciphers); + } +} diff --git a/csharp/src/test/files/rules/detection/schannel/SchannelTestFile.cs b/csharp/src/test/files/rules/detection/schannel/SchannelTestFile.cs new file mode 100644 index 000000000..b565f377d --- /dev/null +++ b/csharp/src/test/files/rules/detection/schannel/SchannelTestFile.cs @@ -0,0 +1,403 @@ +using System; +using System.Runtime.InteropServices; + +/* + * Schannel driven straight through SSPI, in the shapes hand-written P/Invoke takes. The + * declarations below are copied in structure from dotnet/runtime's own + * Interop.SSPI.cs / Interop.SchProtocols.cs / SslStreamPal.Windows.cs, which is where every + * published C# Schannel binding comes from. + */ +internal static class SChannelConstants +{ + public const string UNISP_NAME = "Microsoft Unified Security Protocol Provider"; + public const string SCHANNEL_NAME = "Schannel"; + + public const int SP_PROT_PCT1_CLIENT = 0x00000002; + public const int SP_PROT_SSL2_CLIENT = 0x00000008; + public const int SP_PROT_SSL3_SERVER = 0x00000010; + public const int SP_PROT_SSL3_CLIENT = 0x00000020; + public const int SP_PROT_TLS1_SERVER = 0x00000040; + public const int SP_PROT_TLS1_CLIENT = 0x00000080; + public const int SP_PROT_TLS1_0_CLIENT = 0x00000080; + public const int SP_PROT_TLS1_1_CLIENT = 0x00000200; + public const int SP_PROT_TLS1_2_SERVER = 0x00000400; + public const int SP_PROT_TLS1_2_CLIENT = 0x00000800; + public const int SP_PROT_TLS1_2 = SP_PROT_TLS1_2_SERVER | SP_PROT_TLS1_2_CLIENT; + public const int SP_PROT_TLS1_3_SERVER = 0x00001000; + public const int SP_PROT_TLS1_3_CLIENT = 0x00002000; + public const int SP_PROT_DTLS_CLIENT = 0x00020000; + public const int SP_PROT_DTLS1_0_CLIENT = 0x00020000; + public const int SP_PROT_DTLS1_2_CLIENT = 0x00080000; + public const int SP_PROT_NONE = 0; + public const int SP_PROT_ALL = 0x7fffffff; +} + +internal struct SCHANNEL_CRED +{ + public int dwVersion; + public int cCreds; + public IntPtr paCred; + public IntPtr hRootStore; + public int cMappers; + public IntPtr aphMappers; + public int cSupportedAlgs; + public IntPtr palgSupportedAlgs; + public int grbitEnabledProtocols; + public int dwMinimumCipherStrength; + public int dwMaximumCipherStrength; + public int dwSessionLifespan; + public int dwFlags; + public int dwCredFormat; +} + +internal struct SCH_CREDENTIALS +{ + public int dwVersion; + public int dwCredFormat; + public int cCreds; + public IntPtr paCred; + public IntPtr hRootStore; + public int dwSessionLifespan; + public int dwFlags; + public int cTlsParameters; + public IntPtr pTlsParameters; +} + +internal struct TLS_PARAMETERS +{ + public int cAlpnIds; + public IntPtr rgstrAlpnIds; + public uint grbitDisabledProtocols; + public int cDisabledCrypto; + public IntPtr pDisabledCrypto; + public int dwFlags; +} + +internal struct CRYPTO_SETTINGS +{ + public int eAlgorithmUsage; + public IntPtr strCngAlgId; + public int cChainingModes; + public IntPtr rgstrChainingModes; + public int dwMinBitLength; + public int dwMaxBitLength; +} + +internal static class SspiCli +{ + [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern int AcquireCredentialsHandle( + string pszPrincipal, + string pszPackage, + int fCredentialUse, + IntPtr pvLogonId, + IntPtr pAuthData, + IntPtr pGetKeyFn, + IntPtr pvGetKeyArgument, + out long phCredential, + out long ptsExpiry); +} + +public class SchannelUsage +{ + private const int SECPKG_CRED_OUTBOUND = 0x2; + private const int SECPKG_CRED_INBOUND = 0x1; + + /* + * A constant of this same class, referenced without a qualifier further down. Declared here + * so the counter-check below is the real shape and not an approximation of it. + */ + private const int SP_PROT_TLS1_2_CLIENT = 0x00000800; + + /* The package name written as a member access: the dominant idiom, and the one that names it. */ + public void OutboundCredential(IntPtr authData) + { + long credential; + long expiry; + SspiCli.AcquireCredentialsHandle( + null, + SChannelConstants.UNISP_NAME, + SECPKG_CRED_OUTBOUND, + IntPtr.Zero, + authData, + IntPtr.Zero, + IntPtr.Zero, + out credential, + out expiry); + } + + /* The other Schannel package name, written as a literal. */ + public void InboundCredential(IntPtr authData) + { + long credential; + long expiry; + SspiCli.AcquireCredentialsHandle( + null, + "Schannel", + SECPKG_CRED_INBOUND, + IntPtr.Zero, + authData, + IntPtr.Zero, + IntPtr.Zero, + out credential, + out expiry); + } + + /* A local string constant: it resolves through to the documented package string. */ + public void ViaLocalConstant(IntPtr authData) + { + const string package = "Microsoft Unified Security Protocol Provider"; + long credential; + long expiry; + SspiCli.AcquireCredentialsHandle( + null, + package, + SECPKG_CRED_OUTBOUND, + IntPtr.Zero, + authData, + IntPtr.Zero, + IntPtr.Zero, + out credential, + out expiry); + } + + /* The same call with inline out-variable declarations, which C# 7 allows and real code uses. */ + public void InlineOutVariables(IntPtr authData) + { + SspiCli.AcquireCredentialsHandle( + null, + SChannelConstants.SCHANNEL_NAME, + SECPKG_CRED_OUTBOUND, + IntPtr.Zero, + authData, + IntPtr.Zero, + IntPtr.Zero, + out long credential, + out long expiry); + } + + /* The legacy credential, built with an object initializer. */ + public void LegacyCredential() + { + var cred = new SCHANNEL_CRED + { + dwVersion = 4, + grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_2_CLIENT, + dwMinimumCipherStrength = 128, + dwMaximumCipherStrength = 256, + }; + } + + /* A flags combination: a set of versions, not a number. */ + public void TwoVersions() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = + SChannelConstants.SP_PROT_TLS1_2_CLIENT | SChannelConstants.SP_PROT_TLS1_3_CLIENT; + } + + /* Client and server flags of the same version, plus the server|client alias. */ + public void BothDirections() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_2_SERVER; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_3_SERVER; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_2; + } + + /* TLS 1.1 and both spellings of the TLS 1.0 flag. */ + public void OlderTlsVersions() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_1_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_0_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_SERVER; + } + + /* SSL is not TLS: it keeps its own name so a reader sees what was offered. */ + public void LegacySsl() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_SSL3_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_SSL3_SERVER; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_SSL2_CLIENT; + } + + /* DTLS, versioned and unversioned. */ + public void Datagram() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_DTLS1_2_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_DTLS1_0_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_DTLS_CLIENT; + } + + /* Zero is the documented recommendation: Schannel picks, so no version is known. */ + public void PlatformDefault() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_NONE; + } + + /* The modern credential. It has no enabled-protocol field at all. */ + public void ModernCredential() + { + var cred = new SCH_CREDENTIALS + { + dwVersion = 5, + cTlsParameters = 1, + }; + } + + /* + * Counter-check: grbitDisabledProtocols is the same flag vocabulary with the opposite + * meaning. A disabled protocol is not a protocol in use, so nothing is reported -- not even + * the bare TLS that the structure's existence would support. + */ + public void DisabledProtocols() + { + var parameters = new TLS_PARAMETERS + { + grbitDisabledProtocols = 0x00000020, + }; + parameters.grbitDisabledProtocols = 0x00000800; + } + + /* + * Counter-check: CRYPTO_SETTINGS is reachable only through TLS_PARAMETERS.pDisabledCrypto and + * is documented as "disabled cryptographic settings". Its algorithm usage, its chaining modes + * and its bit lengths all describe cryptography being switched off, so none of them becomes a + * component. + */ + public void DisabledCrypto() + { + var settings = new CRYPTO_SETTINGS + { + eAlgorithmUsage = 2, + cChainingModes = 1, + dwMinBitLength = 0, + dwMaxBitLength = 128, + }; + settings.dwMinBitLength = 256; + settings.dwMaxBitLength = 256; + } + + /* + * Counter-check: a bare reference to a const declared in the same file resolves through to + * its number, and a number is not a protocol name. The known recall gap, asserted so that it + * stays a gap rather than silently becoming a guess. + */ + public void UnqualifiedConstant() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT; + } + + /* Counter-check: a numeric literal, however correct the bit is. */ + public void NumericFlag() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = 0x00000800; + } + + /* Counter-check: the flags arrive as a method parameter, so none may be invented. */ + public void FromParameter(int protocols) + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = protocols; + } + + /* Counter-check: a flag Schannel has not honoured since Windows 2000, and a whole-range mask. */ + public void UnreportableFlags() + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_PCT1_CLIENT; + cred.grbitEnabledProtocols = SChannelConstants.SP_PROT_ALL; + } + + /* Counter-check: a name that is not a flag of this field at all. */ + public void UnknownName(Options options) + { + var cred = new SCHANNEL_CRED(); + cred.grbitEnabledProtocols = options.Preferred; + } + + /* + * Counter-check: the cipher-strength floor and ceiling are real policy, but they name no + * algorithm, and a bit length with nothing to attach it to is not a component. + */ + public void CipherStrengthOnly() + { + var cred = new SCHANNEL_CRED(); + cred.dwMinimumCipherStrength = 128; + cred.dwMaximumCipherStrength = 256; + } + + /* + * Counter-check: a field of an unrelated type whose name merely resembles the Win32 one. The + * exact Hungarian-notation name is the guard, and nothing weaker matches. + */ + public void SimilarlyNamedField(Options options) + { + options.EnabledProtocols = SChannelConstants.SP_PROT_TLS1_2_CLIENT; + options.GrbitEnabledProtocols = SChannelConstants.SP_PROT_TLS1_3_CLIENT; + } + + /* + * Counter-check: something else called AcquireCredentialsHandle. The receiver is matched as + * ANY, so only the nine-argument arity of the SSPI function keeps this quiet. + */ + public void UnrelatedHelper(Vault vault) + { + vault.AcquireCredentialsHandle("user", "Schannel", 1); + } + + /* Counter-check: a security package that is not Schannel. */ + public void NegotiatePackage(IntPtr authData) + { + long credential; + long expiry; + SspiCli.AcquireCredentialsHandle( + null, + "Negotiate", + SECPKG_CRED_OUTBOUND, + IntPtr.Zero, + authData, + IntPtr.Zero, + IntPtr.Zero, + out credential, + out expiry); + } + + /* Counter-check: the package name arrives as a parameter, so none may be invented. */ + public void PackageFromParameter(string package, IntPtr authData) + { + long credential; + long expiry; + SspiCli.AcquireCredentialsHandle( + null, + package, + SECPKG_CRED_OUTBOUND, + IntPtr.Zero, + authData, + IntPtr.Zero, + IntPtr.Zero, + out credential, + out expiry); + } +} + +public class Options +{ + public int Preferred; + public int EnabledProtocols; + public int GrbitEnabledProtocols; +} + +public class Vault +{ + public void AcquireCredentialsHandle(string user, string package, int use) + { + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuitesTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuitesTest.java new file mode 100644 index 000000000..2e5ebe1be --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetCipherSuitesTest.java @@ -0,0 +1,105 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dotnet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * The cipher suites a .NET application allows, read from {@code CipherSuitesPolicy}. + * + *

This is the only .NET API that names suites directly, and the point of the test is that the + * name goes all the way: {@code TlsCipherSuite.TLS_AES_256_GCM_SHA384} arrives as the key exchange, + * cipher and digest it is made of, not as a string. Reading it at all needed the C# engine to stop + * resolving a braced initializer to its element count, so the multi-element cases are also the + * regression test for that change. + * + *

Three counter-checks: a list taken from a parameter, a member of an unrelated enum, and an + * unrelated constructor of the same arity. None may produce a suite. + */ +class DotNetCipherSuitesTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/dotnet/DotNetCipherSuitesTestFile.cs", this); + assertThat(found) + .as("every suite the policy allows, resolved through the IANA table") + .containsExactly( + "TLS=TLS[CipherSuite=TLS_AES_256_GCM_SHA384[AssetCollection=[AES-256-GCM," + + " SHA-384], IdentifierCollection=[0x13, 0x02]]]", + "TLS=TLS[CipherSuite=TLS_CHACHA20_POLY1305_SHA256[AssetCollection=[ChaCha20-Poly1305," + + " SHA-256], IdentifierCollection=[0x13, 0x03]]]", + "TLS=TLS[CipherSuite=TLS_AES_128_GCM_SHA256[AssetCollection=[AES-128-GCM," + + " SHA-256], IdentifierCollection=[0x13, 0x01]]]", + // The explicit element type, which used to yield the policy without its + // suite: the parser puts the brackets inside `type_` and the braces into a + // collection initializer, so `new TlsCipherSuite[] { ... }` reached the + // object-creation path while the implicit `new[] { ... }` above reached the + // array path. Both forms now read their elements. + "TLS=TLS[CipherSuite=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256[AssetCollection=[ECDH," + + " AES-128-GCM, RSA], IdentifierCollection=[0xC0, 0x2F]]]", + "TLS=TLS[CipherSuite=TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384[AssetCollection=[ECDH," + + " AES-256-GCM, ECDSA-SHA-384], IdentifierCollection=[0xC0, 0x2C]]]", + // A weak suite has to be visible as itself -- 3DES and its CBC mode. + "TLS=TLS[CipherSuite=TLS_RSA_WITH_3DES_EDE_CBC_SHA[AssetCollection=[RSA," + + " 3DES-CBC, RSA], IdentifierCollection=[0x00, 0x0A]]]", + // Counter-check: the list arrives in a parameter, so no suite is knowable. + "TLS=TLS", + // Counter-check: a member of an unrelated enum. Before the registry guard + // in CSharpProtocolContextTranslator this became a cipher suite named + // TLS_SOMETHING_INVENTED. + "TLS=TLS"); + } + + /** Renders a node and its children as {@code Kind=value[children]}, children sorted. */ + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream() + .map(DotNetCipherSuitesTest::render) + .sorted() + .toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(render(node))); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetHexLiteralTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetHexLiteralTest.java new file mode 100644 index 000000000..5db4c0c3d --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/dotnet/DotNetHexLiteralTest.java @@ -0,0 +1,92 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.dotnet; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * An integer written in hex or binary is an integer. + * + *

The C# lexer has three integer tokens and the converter classified only {@code + * INTEGER_LITERAL} as one, so a hex or binary literal fell through to the string branch and a key + * size written as {@code 0x100} was silently lost. The parsing side already handled every base; + * only the classification was wrong. The last case is the counter-check that a size which is not a + * literal at all still stays unknown, in any base. + */ +class DotNetHexLiteralTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/dotnet/DotNetHexLiteralTestFile.cs", this); + assertThat(found) + .as("the same key length, whatever base it was written in") + .containsExactly( + // 256 -- the control case + "BlockCipher=AES-256[BlockSize=128, KeyLength=256," + + " Oid=2.16.840.1.101.3.4.1.4]", + // 0x100 -- the same size, and the case that was lost before the fix + "BlockCipher=AES-256[BlockSize=128, KeyLength=256," + + " Oid=2.16.840.1.101.3.4.1.4]", + // 0XC0 -- upper-case prefix and digits + "BlockCipher=AES-192[BlockSize=128, KeyLength=192," + + " Oid=2.16.840.1.101.3.4.1.2]", + // 0b1000_0000 -- binary, with the digit separator C# allows + "BlockCipher=AES-128[BlockSize=128, KeyLength=128," + + " Oid=2.16.840.1.101.3.4.1]", + // From a parameter: no KeyLength child, and none invented + "BlockCipher=AES[BlockSize=128, Oid=2.16.840.1.101.3.4.1]"); + } + + /** Renders a node and its children as {@code Kind=value[children]}, children sorted. */ + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream() + .map(DotNetHexLiteralTest::render) + .sorted() + .toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(render(node))); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipherTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipherTest.java new file mode 100644 index 000000000..fd43d9135 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslCipherTest.java @@ -0,0 +1,104 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.KeyLength; +import com.ibm.mapper.model.Mode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * An OpenSSL cipher is named by the function that returns it, so these assertions are about whether + * one function name yields all three of algorithm, key length and mode. + * + *

The counter-checks are the half that matters. Every rule here matches on an {@code ANY} + * receiver, so what keeps them quiet is that the method name must be in {@code OpenSslApi}'s table + * and that a fetch name must resolve through it: a function OpenSSL does not have, an algorithm + * string that is not a cipher, and a name built at run time all have to stay silent, and {@code + * EVP_EncryptInit_ex} on a handle has to stay silent as well -- it names no algorithm, and a rule + * there could only invent one. + */ +class OpenSslCipherTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/openssl/OpenSslCipherTestFile.cs", this); + assertThat(found) + .as("one component per cipher-returning call, and nothing else") + .containsExactly( + "AES-256-GCM", // inline, as an argument of EVP_EncryptInit_ex + "AES-128-CBC", // the same, qualified with the P/Invoke holder + "ChaCha20-Poly1305", // held in a local first + "DESede168-CBC", // ede3 is three-key Triple DES + "DESede112-CBC", // ede is two-key + "RC4", // key length settable later, so none is claimed + "RC4-40", // the export variant names its own + "AES-192-CFB8", // the feedback width is in the name + "AES-256-WRAP", // key wrap + "DES-56-ECB", // the 56 bits are the mapper's, not the name's + "AES-256-GCM", // EVP_CIPHER_fetch("AES-256-GCM") + "DESede168-CBC"); // EVP_get_cipherbyname("DES-EDE3-CBC") + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + // A detection whose value the translator declines -- an algorithm string that is not a + // cipher -- arrives with no nodes at all and contributes nothing. + nodes.forEach(node -> found.add(node.asString())); + switch (findingId) { + case 0 -> { + assertChild(nodes.get(0), KeyLength.class, 256); + assertChild(nodes.get(0), Mode.class, "GCM"); + } + // RC4's key length is set with EVP_CIPHER_CTX_set_key_length, not by the function, so + // it must be absent rather than defaulted to 128. + case 5 -> { + assertNoChild(nodes.get(0), KeyLength.class); + assertNoChild(nodes.get(0), Mode.class); + } + case 6 -> assertChild(nodes.get(0), KeyLength.class, 40); + case 8 -> assertChild(nodes.get(0), Mode.class, "WRAP"); + // EVP_CIPHER_fetch with a string that is not a cipher name. + case 12 -> assertThat(nodes).isEmpty(); + default -> { + /* covered by containsExactly above */ + } + } + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigestTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigestTest.java new file mode 100644 index 000000000..18840d5d0 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslDigestTest.java @@ -0,0 +1,105 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.DigestSize; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.MessageDigest; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * The digests mirror the ciphers: the function name is the identity, and the fetch-by-name form has + * to resolve through the same table or report nothing. + * + *

The three HMAC findings are the point of the second half. Each is an {@code HMAC} component + * with no digest under it, and that is the intended answer rather than a gap: in every real wrapper + * the {@code EVP_MD *} is a handle, and where it is written inline the digest is already a finding + * of its own on the same line, so reading it here as well would report one digest twice. + */ +class OpenSslDigestTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/openssl/OpenSslDigestTestFile.cs", this); + assertThat(found) + .as("one component per digest-returning call, plus one per HMAC call") + .containsExactly( + "SHA-256", // inline, as an argument of EVP_DigestInit_ex + "SHA-1", + "SHA-384", + "SHA3-256", + "SHAKE256", + "MD5", + "BLAKE2b", + "SM3", + "RIPEMD-160", + "SHA-512", // EVP_MD_fetch("SHA2-512") + "SHA-256", // EVP_get_digestbyname("SHA256") + "HMAC", // the one-shot form + "HMAC", // HMAC_Init_ex + "HMAC", // HMAC_Init + // EVP_MAC_fetch: the only OpenSSL call that names a MAC by string. Four + // further fetches in the fixture are asserted absent by this list being + // exact -- GMAC (mapper has it only as a cipher mode), BLAKE2BMAC (the + // mapper's BLAKE2b is a digest, not a MAC), an invented name, and a name + // taken from a parameter. + "HMAC", + "CMAC", + "KMAC128", + "KMAC256", // written "KMAC256", the hyphenless spelling + "Poly1305", // written "poly1305": the fetch is case-insensitive + "SipHash"); + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(node.asString())); + switch (findingId) { + case 0 -> assertChild(nodes.get(0), DigestSize.class, 256); + case 3 -> assertChild(nodes.get(0), DigestSize.class, 256); + // The digest of an HMAC is a handle this plugin cannot follow, so it must be absent. + case 11, 12, 13 -> assertNoChild(nodes.get(0), MessageDigest.class); + // EVP_MD_fetch with a name that is not a digest. + case 14 -> assertThat(nodes).isEmpty(); + default -> { + /* covered by containsExactly above */ + } + } + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslKeyTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslKeyTest.java new file mode 100644 index 000000000..c03e64a06 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslKeyTest.java @@ -0,0 +1,162 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.EllipticCurve; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.KeyLength; +import com.ibm.mapper.model.NumberOfIterations; +import com.ibm.mapper.model.Padding; +import com.ibm.mapper.model.SaltLength; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * Key generation, RSA padding and password-based derivation, where the detail is in the arguments + * rather than in the function name alone. + * + *

Three kinds of assertion carry the weight here. The key sizes and curves prove that the value + * beside the algorithm is read -- {@code RSA-4096}, {@code EC-prime256v1} from a numeric NID, + * {@code EC-secp384r1} from a NID member name, {@code EC-secp521r1} from a group-name string. The + * paddings prove that all six {@code RSA_*_PADDING} constants resolve, including {@code + * RSA_NO_PADDING}, which is recorded rather than dropped because raw RSA is a finding. And the + * PBKDF2 assertions prove the three numbers an auditor reads such a call site for: 600000 + * iterations, a 16-byte salt and a 32-byte derived key, reported in bits. + * + *

The counter-checks assert absence of a size that arrives in a parameter, of an iteration count + * that does, of a NID that is not a curve, of a group name that is not one, and of a padding + * constant that is not one -- and that {@code EVP_PKEY_keygen}, which names neither algorithm nor + * size, produces nothing at all. + */ +class OpenSslKeyTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/openssl/OpenSslKeyTestFile.cs", this); + assertThat(found) + .as("one component per key-generation, padding or derivation call") + .containsExactly( + "RSA-4096", // RSA_generate_key_ex + "RSA-1024", // the same, at a size worth flagging + "RSA-2048", // RSA_generate_key, size at index 0 + "RSA-3072", // RSA_generate_multi_prime_key + "RSA-2048", // EVP_RSA_gen + "RSA-3072", // EVP_PKEY_CTX_set_rsa_keygen_bits + "DSA-2048", + "FFDH-2048", // EVP_PKEY_CTX_set_dh_paramgen_prime_len + "EC-prime256v1", // NID by numeric value + "EC-secp384r1", // NID by member name + "EC-secp521r1", // group name "P-521" + "RSA-4096", // EVP_PKEY_Q_keygen(.., "RSA", 4096) + "EC", // EVP_PKEY_Q_keygen(.., "EC", "P-384"); curve is a child + "Ed25519", // a parameterless type + "ML-DSA-65", + "RSA-OAEP", // set_rsa_padding(RSA_PKCS1_OAEP_PADDING) + "RSA", // set_rsa_padding(RSA_PKCS1_PADDING) + "RSA", // set_rsa_padding(RSA_NO_PADDING) + "RSA", // set_rsa_padding(RSA_PKCS1_PSS_PADDING) + "RSA", // set_rsa_padding(RSA_X931_PADDING) + "RSA-OAEP", // RSA_public_encrypt + "RSA-OAEP", // RSA_private_decrypt + "RSA", // RSA_private_encrypt + "RSA", // RSA_public_decrypt + "SHA-256", // the EVP_sha256() written inside the PBKDF2 call + "PBKDF2", + "PBKDF2-SHA-1", // the name states the pseudo-random function + "scrypt", + "scrypt", // EVP_PBE_scrypt_ex + "HKDF", // EVP_KDF_fetch("HKDF") + "PBKDF2", // EVP_KDF_fetch("PBKDF2") + "RSA", // counter-check: size in a parameter + "SHA-256", // the digest of the next call, which is still inline + "PBKDF2", // counter-check: iteration count in a parameter + "RSA"); // counter-check: a padding constant that is not one + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(node.asString())); + switch (findingId) { + case 0 -> assertChild(nodes.get(0), KeyLength.class, 4096); + case 8 -> assertChild(nodes.get(0), EllipticCurve.class, "prime256v1"); + case 11 -> assertChild(nodes.get(0), KeyLength.class, 4096); + // The EC shorthand: the type string is the component and the curve is its child, so + // the curve has to be there although the component's name does not spell it. + case 12 -> { + assertChild(nodes.get(0), EllipticCurve.class, "secp384r1"); + assertNoChild(nodes.get(0), KeyLength.class); + } + case 17 -> assertChild(nodes.get(0), Padding.class, "NONE"); + case 18 -> assertChild(nodes.get(0), Padding.class, "PSS"); + case 19 -> assertChild(nodes.get(0), Padding.class, "X9.31"); + // PBKDF2: 600000 iterations, a 16-byte salt and a 32-byte key, in bits. + case 25 -> { + assertChild(nodes.get(0), NumberOfIterations.class, 600000); + assertChild(nodes.get(0), SaltLength.class, 128); + assertChild(nodes.get(0), KeyLength.class, 256); + } + case 26 -> { + assertChild(nodes.get(0), NumberOfIterations.class, 1000); + assertChild(nodes.get(0), SaltLength.class, 64); + assertChild(nodes.get(0), KeyLength.class, 160); + } + // scrypt: the lengths are read; N, r and p have no model and are not invented. + case 27, 28 -> { + assertChild(nodes.get(0), SaltLength.class, 256); + assertChild(nodes.get(0), KeyLength.class, 512); + assertNoChild(nodes.get(0), NumberOfIterations.class); + } + // The key size arrives in a method parameter. + case 31 -> assertNoChild(nodes.get(0), KeyLength.class); + // The iteration count arrives in a method parameter; the two literal lengths stay. + case 33 -> { + assertNoChild(nodes.get(0), NumberOfIterations.class); + assertChild(nodes.get(0), SaltLength.class, 128); + } + // A NID that is not a curve, and a group name that is not one: no component at all, + // because the curve was the whole finding. + case 34, 35, 37 -> assertThat(nodes).isEmpty(); + // A padding constant that is not one: the RSA context is still stated, the padding is + // not guessed. + case 36 -> assertNoChild(nodes.get(0), Padding.class); + default -> { + /* covered by containsExactly above */ + } + } + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandomTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandomTest.java new file mode 100644 index 000000000..278f973e6 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslRandomTest.java @@ -0,0 +1,71 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * OpenSSL's DRBG, reported as a generic generator because the call selects nothing. + * + *

The counter-checks are the two calls that touch randomness without drawing from it: {@code + * RAND_set_DRBG_type} configures a generator and {@code RAND_seed} feeds the pool, and neither is a + * use of the generator. The concrete construction is deliberately not named anywhere -- OpenSSL + * defaults to CTR-DRBG over AES-256, but that default is a property of the build and the + * configuration file rather than of the source being scanned. + */ +class OpenSslRandomTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/openssl/OpenSslRandomTestFile.cs", this); + assertThat(found) + .as("one generator component per call that draws random bytes") + .containsExactly( + "DRBG", // RAND_bytes + "DRBG", // RAND_priv_bytes + "DRBG", // RAND_pseudo_bytes + "DRBG"); // RAND_bytes_ex + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(node.asString())); + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslTlsTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslTlsTest.java new file mode 100644 index 000000000..79ea87f09 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/openssl/OpenSslTlsTest.java @@ -0,0 +1,141 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.openssl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.mapper.model.Version; +import com.ibm.mapper.model.collections.CipherSuiteCollection; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * Which TLS version, and which suites, an OpenSSL endpoint written in C# offers. + * + *

Both spellings of the version constants are exercised: a C# {@code const int} initialised to + * the hex wire value, which resolves to the number, and a member of a holder class, which resolves + * to the name. The counter-checks cover the three ways the answer must stay absent -- {@code 0} + * meaning "whatever the build supports", a version arriving in a method parameter, and a constant + * that is not one of OpenSSL's -- and the three ways a cipher string is not a list of suites: a + * list expression, an alias, and a string built at run time. + * + *

{@code SSL_CTX_set_options} appears once, and the assertion on it is that it reports a TLS + * endpoint and no version although the call names three. {@code SSL_OP_NO_TLSv1} means TLS + * 1.0 is refused; reporting it as offered would be the one mistake this rule set cannot make. + */ +class OpenSslTlsTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/openssl/OpenSslTlsTestFile.cs", this); + assertThat(found) + .as("the version where the call states one, a bare TLS where it does not") + .containsExactly( + // SSL_CTX_new(TLS_client_method()): the method states no version, and the + // context call states that TLS is spoken. Two findings on one line. + "TLS", + "TLS", + "TLSv1.2", // TLSv1_2_method + "TLS", + "TLSv1.1", // TLSv1_1_method + "TLS", + "SSLv3", // SSLv3_method -- not TLS, and reported as what it is + "TLS", + "DTLS", // DTLS_method, version-flexible + "TLS", + "DTLSv1.2", // DTLSv1_2_method + "TLS", + // set_min_proto_version with each constant, as a C# const in hex + "TLSv1.0", + "TLSv1.1", + "TLSv1.2", + "TLSv1.3", + "SSLv3", + "DTLSv1.2", + // the same constants written as members of a holder class + "TLSv1.3", // set_max_proto_version + "TLSv1.2", // SSL_set_min_proto_version + "TLS", // set_cipher_list, one suite + "TLS", // set_cipher_list, two suites + "TLS", // set_ciphersuites, two TLS 1.3 suites + "TLS", // set_cipher_list, a 3DES suite + "TLS"); // set_options -- a TLS context, and no version + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + nodes.forEach(node -> found.add(node.asString())); + switch (findingId) { + // The version-flexible method and the context call both state TLS and no version. + case 0, 1 -> assertNoChild(nodes.get(0), Version.class); + case 14 -> assertChild(nodes.get(0), Version.class, "1.2"); + case 20 -> + assertChild( + nodes.get(0), + CipherSuiteCollection.class, + "[TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384]"); + case 21 -> + assertChild( + nodes.get(0), + CipherSuiteCollection.class, + "[TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256," + + " TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384]"); + case 22 -> + assertChild( + nodes.get(0), + CipherSuiteCollection.class, + "[TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256]"); + case 23 -> + assertChild( + nodes.get(0), + CipherSuiteCollection.class, + "[TLS_RSA_WITH_3DES_EDE_CBC_SHA]"); + // set_options: a TLS endpoint, with neither a version nor a suite read out of the + // SSL_OP_NO_* bitmask. + case 24 -> { + assertNoChild(nodes.get(0), Version.class); + assertNoChild(nodes.get(0), CipherSuiteCollection.class); + } + // The four declined detections: version 0, an unknown version constant, a cipher-list + // expression and a cipher-list alias. + case 25, 26, 27, 28 -> assertThat(nodes).isEmpty(); + default -> { + /* covered by containsExactly above */ + } + } + } +} diff --git a/csharp/src/test/java/com/ibm/plugin/rules/detection/schannel/SchannelTest.java b/csharp/src/test/java/com/ibm/plugin/rules/detection/schannel/SchannelTest.java new file mode 100644 index 000000000..237c23225 --- /dev/null +++ b/csharp/src/test/java/com/ibm/plugin/rules/detection/schannel/SchannelTest.java @@ -0,0 +1,164 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2026 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.ibm.plugin.rules.detection.schannel; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.language.csharp.CSharpCheck; +import com.ibm.engine.language.csharp.CSharpScanContext; +import com.ibm.engine.language.csharp.CSharpSymbol; +import com.ibm.engine.language.csharp.tree.CSharpTree; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.CSharpVerifier; +import com.ibm.plugin.TestBase; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; + +/** + * What direct Schannel P/Invoke states, and -- mostly -- what it does not. + * + *

The counter-checks are the greater part of this fixture, because the two field rules match on + * an {@code ANY} receiver and the Win32 field name is their only guard. The deny-list fields are + * asserted absent on purpose: {@code grbitDisabledProtocols} and every {@code CRYPTO_SETTINGS} + * field describe cryptography being switched off, and reporting them would state the opposite of + * what the source says. + */ +class SchannelTest extends TestBase { + + private final List found = new ArrayList<>(); + + @Test + void test() throws Exception { + CSharpVerifier.verify("rules/detection/schannel/SchannelTestFile.cs", this); + assertThat(found) + .as("every protocol direct Schannel P/Invoke states, and nothing it does not") + .containsExactly( + // AcquireCredentialsHandle, once per Schannel package spelling: a member + // access to UNISP_NAME, the literal "Schannel", a local string constant + // that resolves through to the documented package string, and a member + // access to SCHANNEL_NAME. No version: the call names a package, not a + // version. + "Zeile 117 TLS=TLS", + "Zeile 134 TLS=TLS", + "Zeile 152 TLS=TLS", + // The same call with inline `out long credential` declarations. Worth + // asserting: an argument whose value cannot be converted keeps its + // position, so the nine-argument arity the rule requires still holds. + "Zeile 167 TLS=TLS", + // An object initializer. The initializer's setters are emitted before the + // creation statement they belong to, which is why 185 precedes 182. The + // dwMinimumCipherStrength and dwMaximumCipherStrength written on the next + // two lines contribute nothing -- they are a policy floor and ceiling on + // bulk cipher key length and name no algorithm to attach a length to. + "Zeile 185 TLS=TLSv1.2[Version=1.2]", + "Zeile 182 TLS=TLS", + // A flags combination is a set, not a number: both versions are offered. + "Zeile 194 TLS=TLS", + "Zeile 195 TLS=TLSv1.2[Version=1.2]", + "Zeile 195 TLS=TLSv1.3[Version=1.3]", + // The server-side flags and the server|client alias name the same versions + // as their client-side twins. The direction is a property of the endpoint, + // not of the cryptography. + "Zeile 202 TLS=TLS", + "Zeile 203 TLS=TLSv1.2[Version=1.2]", + "Zeile 204 TLS=TLSv1.3[Version=1.3]", + "Zeile 205 TLS=TLSv1.2[Version=1.2]", + // TLS 1.1, then the three spellings of the TLS 1.0 flag: SP_PROT_TLS1_0_* + // is an alias of SP_PROT_TLS1_*, and both have to read the same. + "Zeile 211 TLS=TLS", + "Zeile 212 TLS=TLSv1.1[Version=1.1]", + "Zeile 213 TLS=TLSv1.0[Version=1.0]", + "Zeile 214 TLS=TLSv1.0[Version=1.0]", + "Zeile 215 TLS=TLSv1.0[Version=1.0]", + // SSL keeps its own name rather than becoming a nameless TLS, so a reader + // sees what was actually offered. + "Zeile 221 TLS=TLS", + "Zeile 222 Protocol=SSLv3", + "Zeile 223 Protocol=SSLv3", + "Zeile 224 Protocol=SSLv2", + // DTLS likewise. SP_PROT_DTLS_CLIENT is an alias of DTLS 1.0 in the + // header, but the name does not say so, so it reports a bare DTLS. + "Zeile 230 TLS=TLS", + "Zeile 231 Protocol=DTLSv1.2", + "Zeile 232 Protocol=DTLSv1.0", + "Zeile 233 Protocol=DTLS", + // SP_PROT_NONE is zero, which the documentation defines as "Schannel + // selects the protocol": TLS without a version is the whole of what is + // known, exactly as for SslProtocols.None. + "Zeile 239 TLS=TLS", + "Zeile 240 TLS=TLS", + // The modern credential structure. It has no enabled-protocol field at + // all, so the creation is the entire statement it makes. + "Zeile 246 TLS=TLS", + // Everything from here on is a counter-check, and each contributes only + // the bare TLS of the `new SCHANNEL_CRED()` on its first line -- a + // credential structure really is a Schannel credential. What is absent is + // the version: an unqualified constant that resolves to a number (294), a + // numeric literal (301), a method parameter (308), the obsolete PCT flag + // and a whole-range mask (315, 316), and a name that is no flag of this + // field (323). The cipher-strength pair at 333/334 adds nothing either. + "Zeile 293 TLS=TLS", + "Zeile 300 TLS=TLS", + "Zeile 307 TLS=TLS", + "Zeile 314 TLS=TLS", + "Zeile 322 TLS=TLS", + "Zeile 332 TLS=TLS"); + // containsExactly is exhaustive, so the following are asserted absent by its very + // completeness -- they are the point of the fixture's second half: + // - TLS_PARAMETERS.grbitDisabledProtocols (262, 264) -- the same flag vocabulary with + // the opposite meaning, plus the TLS_PARAMETERS creation that carries it. + // - every CRYPTO_SETTINGS field (275-283) -- eAlgorithmUsage, cChainingModes, + // dwMinBitLength, dwMaxBitLength. The structure is documented as "disabled + // cryptographic settings"; an algorithm being switched off is not one in use. + // - EnabledProtocols / GrbitEnabledProtocols on an unrelated type (343, 344) -- the + // exact Win32 field name is the guard, and nothing weaker matches. + // - an unrelated nine-less AcquireCredentialsHandle (353). + // - the "Negotiate" package (361) and a package name taken from a parameter (378). + } + + private static String render(@Nonnull INode node) { + String self = node.getKind().getSimpleName() + "=" + node.asString(); + List children = + node.getChildren().values().stream().map(SchannelTest::render).sorted().toList(); + return children.isEmpty() ? self : self + children; + } + + @Override + public void asserts( + int findingId, + @Nonnull + DetectionStore + detectionStore, + @Nonnull List nodes) { + // A detection whose value the translator declines -- a deny-list field, an unknown flag + // name, a package that is not Schannel -- arrives here with no nodes at all. + nodes.forEach( + node -> + found.add( + (node instanceof com.ibm.mapper.model.Protocol p + ? "Zeile " + p.getDetectionContext().lineNumber() + : "Zeile ?") + + " " + + render(node))); + } +} diff --git a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java index b2bbd2ca3..7ea463ecb 100755 --- a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java +++ b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpDetectionEngine.java @@ -397,13 +397,27 @@ private List> resolveValues( } if (tree instanceof CSharpArrayCreationTree arrayCreation) { - // Mirrors the Java engine's NEW_ARRAY handling: an array's length/element-count is - // resolved as an Integer regardless of which factory asked — G2 (above) is what stops a - // SizeFactory from ever seeing a String, and every other factory either wants an - // Integer - // too or simply ignores an argument it did not ask for (e.g. a constant-valued - // ModeFactory("CBC") does not look at the resolved value at all). - return resolveArrayCreationSize(clazz, arrayCreation, valueFactory, resolving); + // Mirrors the Java engine's NEW_ARRAY handling, which branches on the asking factory: + // a SizeFactory wants the array's length, everything else wants the values in it. + // + // Returning the length to every factory, as this did before, meant a collection + // argument could never be read -- `new[] { TlsCipherSuite.TLS_AES_256_GCM_SHA384, ... + // }` + // resolved to the Integer 2. That is why the C# side had no rule able to read a list of + // cipher suites while the Java side has had `setEnabledCipherSuites(String[])` all + // along. + if (valueFactory instanceof SizeFactory) { + return resolveArrayCreationSize(clazz, arrayCreation, valueFactory, resolving); + } + final List> elements = new ArrayList<>(); + for (CSharpTree element : arrayCreation.getElements()) { + elements.addAll(resolveValues(clazz, element, valueFactory, resolving)); + } + // An array with no readable elements falls back to the length, so a factory that does + // want a count still gets one from `new byte[32]`. + return elements.isEmpty() + ? resolveArrayCreationSize(clazz, arrayCreation, valueFactory, resolving) + : elements; } if (tree instanceof CSharpBinaryExpressionTree binary) { diff --git a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java index 1be88f342..891a58b48 100644 --- a/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java +++ b/engine/src/main/java/com/ibm/engine/language/csharp/CSharpTreeConverter.java @@ -1755,10 +1755,17 @@ private void walkInitializerOfUntypedCreation( @Nonnull CSharpScope scope) { CSharpParser.Type_Context typeCtx = objCreationCtx.type_(); + // An array initializer is an array form in its own right. Without it, `new T[] { a, b }` + // and `new[] { a, b }` fell through to the object-creation path and became an opaque + // creation of type "T[]" with no arguments, so their elements were invisible: neither a + // rank specifier (which sits inside type_ for the explicit form) nor an expression_list + // (which only the sized form `new T[n]` has) is present for them. boolean isArrayForm = (objCreationCtx.expression_list() != null && !objCreationCtx.expression_list().expression().isEmpty()) - || !objCreationCtx.rank_specifier().isEmpty(); + || !objCreationCtx.rank_specifier().isEmpty() + || objCreationCtx.array_initializer() != null + || isArrayTypeWithCollectionInitializer(objCreationCtx, typeCtx); if (isArrayForm) { return convertArrayCreation(objCreationCtx, typeCtx, scope); } @@ -1933,9 +1940,19 @@ private void emitIfStatementWorthy(@Nullable CSharpTree tree) { int col = ctx.getStart().getCharPositionInLine(); if (init.OPEN_BRACE() != null) { - // stackalloc T[] { a, b, c } — the element count is the length + // stackalloc T[] { a, b, c } — the element count is the length, and the elements + // themselves are kept so a non-size factory can read the values (see + // CSharpArrayCreationTree#getElements). int count = init.expression().size(); - return new CSharpArrayCreationTree(line, col, elementType, null, count, scope); + List elements = new ArrayList<>(); + for (CSharpParser.ExpressionContext element : init.expression()) { + CSharpTree converted = convertExpression(element, scope); + if (converted != null) { + elements.add(converted); + } + } + return new CSharpArrayCreationTree( + line, col, elementType, null, count, elements, scope); } if (!init.expression().isEmpty()) { CSharpTree length = convertExpression(init.expression(0), scope); @@ -1944,6 +1961,57 @@ private void emitIfStatementWorthy(@Nullable CSharpTree tree) { return new CSharpArrayCreationTree(line, col, elementType, null, -1, scope); } + /** + * Whether {@code new T[] { a, b }} is being looked at -- the explicit-element-type array, which + * reaches here in a shape of its own. + * + *

The parser puts the brackets inside {@code type_}, so the type reads {@code T[]} and + * {@code rank_specifier()} is empty, and it parses the braces as an {@code + * object_or_collection_initializer} rather than an {@code array_initializer}. Without this + * check the creation therefore fell through to the object-creation path and became an opaque + * creation of type {@code "T[]"}, whose braces were walked for call sites only: the elements + * were never offered as values of an array, so {@code new TlsCipherSuite[] { ... }} yielded the + * policy it was passed to and not one of its suites, while the implicit {@code new[] { ... }} + * yielded both. + * + *

The type has to carry a rank specifier for this to apply. A collection initializer on a + * non-array type is a genuine collection -- {@code new List { a, b }} -- and keeps its + * existing treatment, because a list is not resolved element-wise anywhere. + */ + private static boolean isArrayTypeWithCollectionInitializer( + @Nonnull CSharpParser.ObjectCreationExpressionContext ctx, + @Nullable CSharpParser.Type_Context typeCtx) { + if (typeCtx == null || typeCtx.rank_specifier().isEmpty()) { + return false; + } + CSharpParser.Object_or_collection_initializerContext initializer = + ctx.object_or_collection_initializer(); + return initializer != null && initializer.collection_initializer() != null; + } + + /** + * The element expressions of a collection initializer that belongs to an array creation. + * + *

The same values {@link #convertArrayElements} reads out of an {@code array_initializer}, + * reached through the other of the two shapes the parser produces for a braced array. + */ + @Nonnull + private List convertCollectionElements( + @Nonnull CSharpParser.Collection_initializerContext collection, + @Nonnull CSharpScope scope) { + List elements = new ArrayList<>(); + for (CSharpParser.Element_initializerContext element : collection.element_initializer()) { + if (element.non_assignment_expression() != null) { + CSharpTree converted = + convertArithmeticOrPrimary(element.non_assignment_expression(), scope); + if (converted != null) { + elements.add(converted); + } + } + } + return elements; + } + @Nonnull private CSharpTree convertArrayCreation( @Nonnull CSharpParser.ObjectCreationExpressionContext ctx, @@ -1960,11 +2028,55 @@ private CSharpTree convertArrayCreation( } if (ctx.array_initializer() != null) { int count = ctx.array_initializer().variable_initializer().size(); - return new CSharpArrayCreationTree(line, col, elementType, null, count, scope); + return new CSharpArrayCreationTree( + line, + col, + elementType, + null, + count, + convertArrayElements(ctx.array_initializer(), scope), + scope); + } + if (isArrayTypeWithCollectionInitializer(ctx, typeCtx)) { + CSharpParser.Collection_initializerContext collection = + ctx.object_or_collection_initializer().collection_initializer(); + return new CSharpArrayCreationTree( + line, + col, + elementType, + null, + collection.element_initializer().size(), + convertCollectionElements(collection, scope), + scope); } return new CSharpArrayCreationTree(line, col, elementType, null, -1, scope); } + /** + * The element expressions of a braced array initializer. + * + *

An element that is itself an array initializer ({@code new int[][] { {1}, {2} }}) is + * skipped rather than flattened: a nested array is not a value of the outer array's element + * type, and flattening would present a jagged array's contents as if they were one list. + */ + @Nonnull + private List convertArrayElements( + @Nonnull CSharpParser.Array_initializerContext initializer, + @Nonnull CSharpScope scope) { + List elements = new ArrayList<>(); + for (CSharpParser.Variable_initializerContext element : + initializer.variable_initializer()) { + if (element.expression() == null) { + continue; + } + CSharpTree converted = convertExpression(element.expression(), scope); + if (converted != null) { + elements.add(converted); + } + } + return elements; + } + // ----------------------------------------------------------------------- // Argument conversion // ----------------------------------------------------------------------- @@ -2047,7 +2159,15 @@ private List convertArgumentList( int line = literalCtx.getStart().getLine(); int col = literalCtx.getStart().getCharPositionInLine(); - if (literalCtx.INTEGER_LITERAL() != null) { + // The lexer has three integer tokens and all three are integers. Testing only + // INTEGER_LITERAL let a hex or binary literal fall through to the string branch at the + // bottom, so `aes.KeySize = 0x100` resolved to the text "0x100" instead of 256 and the key + // length was silently lost. Nothing else had to change: parseIntOrNull in + // CSharpDetectionEngine already strips the 0x/0b prefix, the digit separators and the + // u/l suffixes. + if (literalCtx.INTEGER_LITERAL() != null + || literalCtx.HEX_INTEGER_LITERAL() != null + || literalCtx.BIN_INTEGER_LITERAL() != null) { return new CSharpLiteralTree(line, col, CSharpLiteralTree.Kind.INTEGER, text); } if (literalCtx.REAL_LITERAL() != null) { diff --git a/engine/src/main/java/com/ibm/engine/language/csharp/tree/CSharpArrayCreationTree.java b/engine/src/main/java/com/ibm/engine/language/csharp/tree/CSharpArrayCreationTree.java index 769a70334..be45dff9f 100644 --- a/engine/src/main/java/com/ibm/engine/language/csharp/tree/CSharpArrayCreationTree.java +++ b/engine/src/main/java/com/ibm/engine/language/csharp/tree/CSharpArrayCreationTree.java @@ -19,6 +19,7 @@ */ package com.ibm.engine.language.csharp.tree; +import java.util.List; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -54,6 +55,17 @@ public final class CSharpArrayCreationTree implements CSharpTree { */ private final int initializerElementCount; + /** + * The converted element expressions of a braced initializer, empty when the array has none (a + * sized array, or an initializer whose elements are themselves arrays). + * + *

Kept beside the count because the two answer different questions and the asking value + * factory decides which one it wants: a {@code SizeFactory} wants the length, everything else + * wants the values. The Java engine has always made that distinction; this is what lets the C# + * engine make it too. + */ + @Nonnull private final List elements; + @Nullable private final CSharpScope scope; public CSharpArrayCreationTree( @@ -63,14 +75,39 @@ public CSharpArrayCreationTree( @Nullable CSharpTree lengthExpression, int initializerElementCount, @Nullable CSharpScope scope) { + this( + line, + column, + elementType, + lengthExpression, + initializerElementCount, + List.of(), + scope); + } + + public CSharpArrayCreationTree( + int line, + int column, + @Nullable String elementType, + @Nullable CSharpTree lengthExpression, + int initializerElementCount, + @Nonnull List elements, + @Nullable CSharpScope scope) { this.line = line; this.column = column; this.elementType = elementType; this.lengthExpression = lengthExpression; this.initializerElementCount = initializerElementCount; + this.elements = List.copyOf(elements); this.scope = scope; } + /** The element expressions of a braced initializer; empty when there are none. */ + @Nonnull + public List getElements() { + return elements; + } + @Override public int getLine() { return line; diff --git a/output/src/main/java/com/ibm/output/cyclondx/builder/Utils.java b/output/src/main/java/com/ibm/output/cyclondx/builder/Utils.java index 221c03eae..39733d49e 100644 --- a/output/src/main/java/com/ibm/output/cyclondx/builder/Utils.java +++ b/output/src/main/java/com/ibm/output/cyclondx/builder/Utils.java @@ -47,7 +47,15 @@ private Utils() { .findFirst(); } - public static @Nonnull Predicate distinctByKey( + // @Nonnull sits before the modifiers, not after the type parameter. javax.annotation.Nonnull + // declares no @Target, so by JLS 9.6.4.1 it is applicable in declaration contexts only -- and + // ` @Nonnull Predicate` is a *type* context. javac accepts it anyway; the Eclipse + // compiler does not, and emits a class that throws "Unresolved compilation problem" when + // called. An IDE Java language server (VSCode's redhat.java, IntelliJ with a Maven output + // directory) compiles into target/classes alongside Maven, so that poisoned class then fails + // Maven's own tests with an error pointing at code that compiles perfectly well. + @Nonnull + public static Predicate distinctByKey( @Nonnull Function keyExtractor) { Map map = new ConcurrentHashMap<>(); return t -> map.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null;