Feat: withNamedMethodParameter() for python detection rules and tests - #509
Feat: withNamedMethodParameter() for python detection rules and tests#509san-zrl wants to merge 3 commits into
Conversation
Signed-off-by: san-zrl <san@zurich.ibm.com>
| } | ||
| } | ||
|
|
||
| if (!checkCurrentIndexState( |
There was a problem hiding this comment.
Blocker — the main example of this PR does not work.
checkCurrentIndexState still starts with if (arguments.size() <= index) return false;, and here we pass the declared index. For a named parameter the argument was already resolved, possibly at a lower position, so this check throws the result away.
Walk through SHA512.new(truncate="256") — one argument in the call:
data(index 0): nodata=keyword; the argument at position 0 istruncate="256", which has a keyword, so it is not taken as positional.datais optional, so it is skipped andpositionalIndexbecomes 1.truncate(index 1): found by name at position 0. Good.- This line:
checkCurrentIndexState(1, ...)->arguments.size() (1) <= 1-> returnsfalse-> the parameter is dropped.
I verified it by printing the child stores and running each positive test alone:
| call site | truncate captured? |
|---|---|
SHA512.new(truncate="256") |
no (0 children) |
SHA512.new(data=b"msg", truncate="256") |
yes |
SHA512.new(truncate="256", data=b"msg") |
yes |
SHA512.new(b"msg", "256") |
yes |
SHA512.new(b"msg", truncate="256") |
yes |
| Cryptodome variant | yes |
This is exactly the case the PR is about: an optional parameter left out, a later one passed by name.
Suggested fix: for named parameters the argument is already resolved, so the arity part of the check is pointless. Either pass the real position of the resolved argument, or split checkCurrentIndexState so the arguments.size() <= index part is skipped for named parameters.
There was a problem hiding this comment.
Fixed the main example.
PythonDetectionEngine.java:
- Line 579 (named-parameter path): replaced checkCurrentIndexState(positionalIndex, …) with checkSymbolTraceState(isInvocation, traceSymbol, expressionTree). The arity guard is no longer invoked because findArgumentByKeyword already guarantees the argument exists before this point is reached.
- Lines 677–693 (checkCurrentIndexState): the symbol-trace body was extracted into the new private method checkSymbolTraceState; checkCurrentIndexState now delegates to it after its arity guard, so the positional path is completely unchanged.
- The new checkSymbolTraceState() carries a Javadoc that makes the separation explicit and points back to findArgumentByKeyword as the reason the arity guard is not needed there.
| expressionTree)) | ||
| .forEach(detectionStore::onReceivingNewDetection); | ||
| if (detectionRule.actionFactory() != null) { | ||
| MethodDetection<Tree> methodDetection = new MethodDetection<>(expressionTree, null); |
There was a problem hiding this comment.
Blocker — optional=false does not stop the rule from firing.
The finding is created here, before the extraction loop. The required-parameter paths below only return afterwards, so the MethodDetection is already in the store.
I tested it: I changed the rule to .withNamedMethodParameter("truncate", "str", /* optional */ false) and ran testNoArgs (SHA512.new(), no truncate at all). The test still passes, which means the # Noncompliant finding is still reported.
So optional=false only removes the captured child value. It does not prevent detection, which is what the PR description promises.
Either fix the description, or move the required-parameter checks up into the guard block above, next to the unpacking and unknown-keyword guards.
There was a problem hiding this comment.
PythonDetectionEngine.java
Inside the existing if (hasNamedParams) guard block — after the unknown-keyword check but before MethodDetection is emitted — a pre-flight loop now scans all declared parameters (line 487):
// Reject if any required named parameter is absent from the call site.
// This must run before MethodDetection is emitted so that a call that is missing a
// required argument produces no finding at all (not just a missing child detection).
int preCheckIndex = 0;
for (Parameter<Tree> p : detectionRule.parameters()) {
if (p.getKeywordName().isPresent() && !p.isKeywordOptional()) {
Optional<Argument> present =
findArgumentByKeyword(p.getKeywordName().get(), preCheckIndex, arguments);
if (present.isEmpty()) {
return; // required arg absent → no finding emitted
}
}
preCheckIndex++;
}The loop reuses findArgumentByKeyword (the same resolver used later in the extraction loop) so keyword lookup and positional-fallback semantics are identical. Because preCheckIndex advances for every parameter — not just required ones — the positional fallback slot stays correct relative to the rule's declared parameter order.
SHA512NewNegativeRequiredNamedParamAbsentTest.py
A plain SHA512.new() call with no # Noncompliant marker — verifyNoIssue asserts zero findings.
PycryptoCryptoHashSHA512RequiredParamTest.java
A self-contained test class that wires a rule variant where truncate is optional=false. testNegativeRequiredNamedParamAbsent calls verifyNoIssue and its asserts() override throws if it is ever reached, making the intent explicit.
| positionalIndex++; | ||
| continue; | ||
| } else { | ||
| // required named parameter absent → rule does not fire; stop processing |
There was a problem hiding this comment.
See the comment on the MethodDetection emission above. This return runs after the finding was already created, so a missing required parameter does not prevent the rule from firing — it only skips the child value.
There was a problem hiding this comment.
Resolved. Execution order is
| Step | Line | What happens |
|---|---|---|
| 1 | 490–504 | Pre-flight loop: every required named parameter is checked for presence. If any is absent → return before anything is written to the store. |
| 2 | 507–510 | MethodDetection emitted — only reached if step 1 passed. |
| 3 | 524–533 | Extraction loop: the else { return; } at line 532 is now a defensive dead branch — the pre-flight already ensures every required parameter exists. |
The stale comment "rule does not fire" was the misleading part, because by that point the finding had already been emitted (step 2 runs before step 3). The updated comment now accurately reflects that this path is unreachable in normal operation.
| expression = regularArgument.expression(); | ||
|
|
||
| // Reject if any keyword argument name is not declared in the rule. | ||
| Set<String> declaredKeywordNames = |
There was a problem hiding this comment.
declaredKeywordNames only collects names from withNamedMethodParameter. Parameters declared with withMethodParameter have no name, so they are never in this set.
That means a mixed rule rejects legal Python. Example:
.withMethodParameter("str") // no name
.withNamedMethodParameter("length", "int", true)f(algorithm="sha256", length=32) # rejected: "algorithm" is an "unknown keyword"Passing a positional parameter by keyword is completely normal Python, so every mixed rule has this hole. Worth deciding whether mixed rules are supported at all — if yes, positional declarations need a name too.
There was a problem hiding this comment.
PythonDetectionEngine.java:
// Reject if any keyword argument does not match any rule parameter — neither by
// keyword name nor by positional index. The index check handles mixed rules where a
// leading withMethodParameter slot is passed by name at the call site, which Python
// allows.
List<Parameter<Tree>> params = detectionRule.parameters();
boolean hasUnknownKeyword =
IntStream.range(0, arguments.size())
.anyMatch(
i -> {
Argument a = arguments.get(i);
if (!(a instanceof RegularArgument ra)
|| ra.keywordArgument() == null) {
return false;
}
String name = ra.keywordArgument().name();
return params.stream()
.noneMatch(
p ->
p.getKeywordName()
.map(name::equals)
.orElse(false)
|| (p.getKeywordName()
.isEmpty()
&& p.getIndex()
== i));
});
if (hasUnknownKeyword) {
return;
}A keyword argument at index i with name n is "unknown" if no rule parameter satisfies:
(a) p.getKeywordName() == n —> named param whose declared name matches
(b) p.getKeywordName().isEmpty() && p.getIndex() == i —> unnamed param at the same slot
Also added PycryptoCryptoHashSHA512MixedRuleTest.java with a hypothetical rule
.withMethodParameter(ANY)
.withNamedMethodParameter("truncate", "str", /* optional */ true)and corresponding test files SHA512MixedRuleWithTruncateTest.py (detecting truncate correctly)
from Crypto.Hash import SHA512
# Rule: withMethodParameter(ANY) + withNamedMethodParameter("truncate", "str", optional=true)
# data slot is unnamed/required (index 0), truncate is optional named (index 1).
# --- truncate present → must fire and capture truncate ---
# data positional, truncate positional fallback
h = SHA512.new(b"msg", "256") # Noncompliant {{(MessageDigest) SHA-512}}
# data positional, truncate by keyword name
h = SHA512.new(b"msg", truncate="256") # Noncompliant {{(MessageDigest) SHA-512}}
# data by keyword (unnamed slot — Python allows naming positional params), truncate by keyword
h = SHA512.new(data=b"msg", truncate="256") # Noncompliant {{(MessageDigest) SHA-512}}
# truncate by keyword, data absent — must NOT fire (data is required); no Noncompliant marker
# both by keyword reordered: NOT included — the unnamed slot has no declared keyword name so the
# engine cannot match 'data=' at a non-natural position; that call site is unsupported for mixed rules.and SHA512MixedRuleNoTruncateTest.java
from Crypto.Hash import SHA512
# Rule: withMethodParameter(ANY) + withNamedMethodParameter("truncate", "str", optional=true)
# data slot is unnamed/required (index 0), truncate is optional named (index 1).
# --- truncate absent → must fire, no truncate child ---
# data positional only
h = SHA512.new(b"msg") # Noncompliant {{(MessageDigest) SHA-512}}
# data by keyword (unnamed slot), truncate absent
h = SHA512.new(data=b"msg") # Noncompliant {{(MessageDigest) SHA-512}}| protected final int index; | ||
|
|
||
| /** Non-null when this parameter was declared with {@code withNamedMethodParameter}. */ | ||
| @Nullable private final String keywordName; |
There was a problem hiding this comment.
I would not put two loose fields on the base class. Here is what we could do instead.
| plain | named | |
|---|---|---|
| not detectable | Parameter |
NamedParameter |
| detectable | DetectableParameter |
NamedDetectableParameter |
And this PR already creates both named variants: a plain named Parameter for the depending-rules path, and a named DetectableParameter for the value path.
There is a second problem: Parameter.is() compares the type tag with equals, not instanceof. A subclass of DetectableParameter would either fail is(DetectableParameter.class), or have to pass the parent tag and lie about its real class. The engine would then need instanceof in some places and is() in others.
**Nonnull concern: ** This state is representable and means nothing:
new Parameter<>(type, 0, false, rules, null, true); // no name, but "optional" = trueAlso the new public constructor below copies all five field assignments instead of delegating to the protected one.
Suggestion — one nullable field holding a small record:
public record NamedArgument(@Nonnull String name, boolean optional) {}
@Nullable private final NamedArgument namedArgument;
@Nonnull
public Optional<NamedArgument> getNamedArgument() {
return Optional.ofNullable(namedArgument);
}- the name is non-null by construction, enforced in one place
nullmeans "not a named parameter" — the meaningless combination disappears- one new constructor instead of two, and the existing ones can delegate
- the engine reads one field instead of two
I would also rename keyword* to argument*. Named arguments exist in several languages, and Parameter is the shared language-neutral class — keyword is Python wording.
| ParametersFactoryBuilder<T> withMethodParameterMatchExactType(@Nonnull String type); | ||
|
|
||
| @Nonnull | ||
| ParametersFactoryBuilder<T> withNamedMethodParameter( |
There was a problem hiding this comment.
This method sits on IDetectionRule<T>, so a Java or Go rule can call it too. But the feature is Python-only:
JavaDetectionEngineand the Go engine never readgetKeywordName()— I grepped, there are no callers there.DetectionRuleBuilderImpl.build()still drops theMethodMatchertype list for any rule with a named parameter, whatever the language.
Combined, a Java rule using this would match any call with that method name and any argument count, and then read arguments by position. That is a false-positive generator.
Suggestion: reject named parameters for non-Python rules, or at minimum add a clear warning in the javadoc here.
| */ | ||
| DetectionStore<PythonCheck, Tree, Symbol, PythonVisitorContext> truncateStore = | ||
| getStoreOfValueType(AlgorithmParameter.class, detectionStore.getChildren()); | ||
| if (truncateStore != null) { |
There was a problem hiding this comment.
This if is why the suite is green while the feature is broken.
If nothing was captured, truncateStore is null, the block is skipped, and the test passes. See my comment on PythonDetectionEngine — SHA512.new(truncate="256") captures nothing today, and this assert does not notice.
The # Noncompliant comments do not help either, because every fixture uses the same expected message:
h = SHA512.new(truncate="256") # Noncompliant {{(MessageDigest) SHA-512}}
h = SHA512.new(data=b"msg") # Noncompliant {{(MessageDigest) SHA-512}}The message says nothing about truncate.
As it stands, 6 of the 10 positive tests only prove "SHA-512 was found", which the old positional code could already do.
Suggestion: let each test declare whether it expects the child, then assert it:
assertThat(truncateStore).isNotNull(); // capture expected
assertThat(truncateValue.asString()).isEqualTo("256");assertThat(truncateStore).isNull(); // no capture expectedThere was a problem hiding this comment.
PycryptoCryptoHashSHA512Test.java
Added a per-instance flag, defaulting to false, set explicitly by each test method before calling verify(). Because JUnit creates a fresh instance per test, there is no shared-state risk between tests.
Each test method sets the flag
- the 6 fixtures that pass truncate="256", set expectTruncateChild = true
- the 4 that don't set it false.
asserts() now has two hard branches instead of a soft if (store != null)
| case | assertion |
|---|---|
| expectTruncateChild = true | assertThat(truncateStore).isNotNull() + full value check |
| expectTruncateChild = false | assertThat(truncateStore).isNull() |
| * captured. | ||
| */ | ||
| @Test | ||
| void testTruncateKeywordOnly() { |
There was a problem hiding this comment.
This is the test for the PR's headline example, and today it passes without capturing anything — see the comment on the conditional assert below.
Once that assert is made unconditional, this test will fail until the arity bug in PythonDetectionEngine is fixed. That is the right order: make the test fail first, then fix the engine.
There was a problem hiding this comment.
Engine is fixed. Test is green. It also asserts the detection of truncate.
There was a problem hiding this comment.
Blockers
SHA512.new(truncate="256")— the headline example — captures nothing. The arity check incheckCurrentIndexStatestill uses the declared index, so a keyword argument at a lower position is dropped.- The tests cannot catch that.
asserts()wraps the check inif (truncateStore != null), and all fixtures expect the same# Noncompliantmessage, so 6 of the 10 positive tests assert nothing about the new feature. optional=falsestill reports a finding —MethodDetectionis emitted before the required-parameter checks. Verified by makingtruncaterequired and runningSHA512.new().- Mixed positional + named rules lose the positional type and arity checks, and the unknown-keyword guard then rejects legal Python.
withNamedMethodParameterfollowed byaddDependingDetectionRulessilently loses the name.
Worth deciding
The DSL sits on IDetectionRule<T> but only Python implements it. PycryptoCryptoHash is not registered, and the captured value never becomes SHA-512/256. docs/DETECTION_RULE_STRUCTURE.md is not updated.
The same keyword handling is also missing in extractArgumentFromMethodCaller and PythonSemantic.resolveValues — that one probably matters more in real code, and needs no DSL change.
… optional false Signed-off-by: san-zrl <san@zurich.ibm.com>
Signed-off-by: san-zrl <san@zurich.ibm.com>
|
This PR is obsolete. A new implementation of the feature is in PR #527. |
Add
withNamedMethodParameterto the detection rule DSLEngine · Python · keyword-argument-aware parameter matching
Motivation
Python callers routinely pass arguments out of order using keyword syntax, and routinely omit optional parameters entirely:
The existing
withMethodParameter(type)method is purely positional: it reads argument n for parameter n and requires an exact argument count enforced byMethodMatcher. It cannot handle reordered arguments, omitted optional parameters, or the keyword-argument call style that is idiomatic in Python cryptography APIs.withNamedMethodParametersolves all three.New DSL method
Ordering constraint
withMethodParameterdeclarations must precede allwithNamedMethodParameterdeclarations in a rule. Violation throwsIllegalStateExceptionat rule construction time.MethodMatcher mode selection
When all parameters are declared with
withMethodParameterthe existing exact-count type-listMethodMatcheris used — no change in behaviour. As soon as at least onewithNamedMethodParameteris declared the no-type-list constructor is used instead, which accepts any call to the matched method and delegates all structural checking to the extraction loop described below.Extraction algorithm
For each
withNamedMethodParameterdeclaration, in declaration order:RegularArgumentwhosekeywordArgument().name()equalsname. If found, use that argument regardless of its position.optional=trueparameter was skipped, emit aWARNlog before accepting (positional attribution may be ambiguous).type. Skipped whentypeisANY. When the type cannot be statically determined,resolveTreeTypereturns an accept-all predicate — the same conservative fallback used byMethodMatcherfor positional parameters. On mismatch:optional=true→ treat as absent and skip;optional=false→ rule does not fire.optional=false: rule does not fire.optional=true: skip silently.Pre-extraction guards (all run before any finding is emitted)
The following checks apply only when at least one named parameter is declared. All run before
MethodDetectionis emitted, so a rejected call produces no finding at all.UnpackingExpression(**dor*args), the call is rejected. The contents of unpacked iterables and dicts cannot be statically inspected, so no structural guarantee can be made.withNamedMethodParameterin the rule, the call is rejected. The call does not match the function signature the rule was written for.Example —
SHA512.new(data=None, truncate=None)truncatecaptured?SHA512.new()SHA512.new(data=b"msg")SHA512.new(truncate="256")SHA512.new(data=b"msg", truncate="256")SHA512.new(truncate="256", data=b"msg")SHA512.new(b"msg", "256")SHA512.new(b"msg", truncate="256")SHA512.new(truncate=256)SHA512.new(unknown_kwarg="x")SHA512.new(**d)SHA512.new(*args)Changed files
engine/…/rule/Parameter.javakeywordNameandkeywordOptionalfields, two new constructors, andgetKeywordName()/isKeywordOptional()accessors.engine/…/rule/DetectableParameter.javasuper().engine/…/rule/IDetectionRule.javawithNamedMethodParameter(name, type, optional)to all six builder step interfaces.engine/…/rule/builder/DetectionRuleBuilderImpl.javabuild()to select the no-type-listMethodMatcherwhen named params are present. Introduced acopy()helper to eliminate repeated constructor calls.engine/…/language/python/PythonDetectionEngine.javaanalyseExpression(): unpacking guard; unknown-keyword guard;MethodDetectionemission moved after both guards; keyword-name lookup with positional fallback and Hole 1WARNlog; per-argument type check usingPythonSemantic.resolveTreeType. AddedfindArgumentByKeyword()helper and extracted shared loop body intoprocessParameterExpression(). UpdatedgetTraceSymbol()to apply the same keyword-name lookup.python/…/detection/pycrypto/hash/PycryptoCryptoHash.javaCrypto.Hash.SHA512.new/Cryptodome.Hash.SHA512.newdemonstrating the feature.python/…/detection/pycrypto/hash/PycryptoCryptoHashSHA512Test.java+ 15 Python fixture filesKnown limitation
There is no upper-bound arity check: extra positional arguments beyond the declared parameter count are silently ignored. This does not occur in valid Python (the interpreter would raise
TypeError), so it is not a source of false positives in practice. The unpacking guard already handles the one realistic case where extra arguments could arrive opaquely (**d).