mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
A shared NoUnnegatedMention primitive replaces four per-category implementations, with a bidirectional clause-scoped window and an adjectival cue set. The docker layer fixture drops WORKDIR, which creates a real layer and made the pinned answer wrong. The replica failover test now forces a JSON verdict so a negated answer cannot score. Remaining exact-answer tests migrate to ExactToken. Ordering prompts shuffle their candidate lists so echoing scores zero.
441 lines
20 KiB
Go
441 lines
20 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
|
)
|
|
|
|
func registerSecurityCryptoTests(r *testkit.Registry) {
|
|
r.Register(secPasswordHashChoiceTest())
|
|
r.Register(secConstantTimeCompareTest())
|
|
r.Register(secJWTAlgNoneTest())
|
|
r.Register(secTLSFloorVersionTest())
|
|
r.Register(secAESGCMNonceReuseTest())
|
|
r.Register(secRandSourceChoiceTest())
|
|
r.Register(secRotateOrderTest())
|
|
r.Register(secHMACVsSignatureTest())
|
|
r.Register(secCertChainValidationTest())
|
|
r.Register(secHashVsEncryptPIITest())
|
|
}
|
|
|
|
// secWeakPasswordHashPattern matches a mention of a fast, general-purpose
|
|
// hash (MD5, plain SHA-1/256/512) - the ones that must never be used
|
|
// directly for password storage because they lack a tunable work factor.
|
|
var secWeakPasswordHashPattern = regexp.MustCompile(`(?i)\b(md5|sha-?1|sha-?256|sha-?512)\b`)
|
|
|
|
// secWeakHashNamedAndNegated scores full credit only when the response BOTH
|
|
// safely negates every mention of a weak/fast general-purpose hash (or
|
|
// never mentions one) AND actually names at least one of them by name
|
|
// (md5/sha-1/sha-256/sha-512) - C11's fix folded into a single AND rather
|
|
// than an independent extra ContainsAny term, so a wrong answer that
|
|
// recommends SHA-256 outright cannot earn partial credit merely for having
|
|
// typed "SHA-256" while doing so.
|
|
func secWeakHashNamedAndNegated() eval.Evaluator {
|
|
named := eval.ContainsAny("md5", "sha-1", "sha1", "sha-256", "sha256")
|
|
negated := secNoUnnegatedMention(secWeakPasswordHashPattern)
|
|
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
|
|
if n := negated.Evaluate(ctx, response); n.Value != 1 {
|
|
return eval.Score{Value: 0, Detail: "unnegated mention of a discouraged hash: " + n.Detail}
|
|
}
|
|
if m := named.Evaluate(ctx, response); m.Value != 1 {
|
|
return eval.Score{Value: 0, Detail: "never names a weak hash by name"}
|
|
}
|
|
return eval.Score{Value: 1, Detail: "a weak hash is named and every mention is safely negated"}
|
|
})
|
|
}
|
|
|
|
// secPasswordHashChoiceTest: recommend bcrypt/argon2 for password storage
|
|
// while not being penalized for correctly warning against SHA/MD5.
|
|
//
|
|
// ground truth: bcrypt and argon2(id) are deliberately slow, tunable-cost
|
|
// password hashing functions designed to resist brute force even after a
|
|
// database leak. MD5 and plain SHA-1/256/512 are fast, general-purpose
|
|
// hashes with no work factor - an attacker with GPUs can try billions of
|
|
// candidates per second against a leaked SHA-256 hash - so they must never
|
|
// be used to store passwords directly, even though a good answer will
|
|
// still name them by way of warning against them.
|
|
func secPasswordHashChoiceTest() testkit.Test {
|
|
prompt := `Design password storage for a new signup endpoint. Which
|
|
hashing approach should be used to store user passwords at rest, and which
|
|
common hash functions must never be used for this specific purpose?`
|
|
|
|
// C11: secNoUnnegatedMention alone scores full credit for a response
|
|
// that never names any weak hash at all ("no mention" is vacuously
|
|
// negation-safe), which rewards skipping the prompt's second half
|
|
// entirely ("which common hash functions must never be used"). A
|
|
// combined (not independent) second term additionally requires the
|
|
// response to actually name at least one weak hash BY NAME while ALSO
|
|
// safely negating it - folded into one AND, not a bare extra
|
|
// ContainsAny term, since an independent term would give partial credit
|
|
// to a wrong answer merely for mentioning "SHA-256" while recommending
|
|
// it (as opposed to warning against it).
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("bcrypt", "argon2"),
|
|
secWeakHashNamedAndNegated(),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "sec-password-hash-choice",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Recommend bcrypt/argon2 for password storage without being penalized for correctly warning against SHA/MD5.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// secConstantTimeCompareTest: name the timing-attack defect in comparing an
|
|
// HMAC signature with == and the constant-time API that fixes it.
|
|
//
|
|
// ground truth: Go's == (and bytes.Equal) on two byte slices compares
|
|
// byte-by-byte and returns as soon as it finds a difference, so the time
|
|
// the comparison takes leaks how many leading bytes matched. An attacker
|
|
// who can measure response timing can recover the correct signature one
|
|
// byte at a time. The fix is a constant-time comparison: hmac.Equal
|
|
// (crypto/hmac) or subtle.ConstantTimeCompare (crypto/subtle), both of
|
|
// which always examine every byte regardless of where a mismatch occurs.
|
|
func secConstantTimeCompareTest() testkit.Test {
|
|
prompt := `A webhook handler recomputes an HMAC-SHA256 signature for an
|
|
incoming payload and compares it to the value of the X-Signature header
|
|
using Go's == operator on the two byte slices.
|
|
|
|
Name the security defect this introduces, and the correct Go API to use
|
|
instead of ==.`
|
|
|
|
// CC1: "constant-time"/"constant time" describes the FIX (a
|
|
// constant-time comparison), not the defect itself, so it belongs in
|
|
// the fix group rather than the defect-naming group - a response that
|
|
// only ever says "constant-time" never actually names the timing-attack
|
|
// defect the prompt asks for.
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("timing attack", "timing side channel", "time-based"),
|
|
eval.ContainsAny("hmac.equal", "subtle.constanttimecompare", "constanttimecompare", "constant-time", "constant time"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "sec-constant-time-compare",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Name the timing-attack defect in comparing an HMAC signature with == and require hmac.Equal/subtle.ConstantTimeCompare.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// secJWTAlgNoneTest: name the alg=none bypass and the allowlist-based fix.
|
|
//
|
|
// ground truth: a verifier that reads alg from the token's own header and
|
|
// skips verification whenever it is "none" lets an attacker forge any
|
|
// token by setting alg to none and stripping the signature entirely -
|
|
// nothing about the forged token's own claim of its algorithm should ever
|
|
// be trusted. The fix is to hardcode/allowlist the algorithm(s) the server
|
|
// expects and reject anything else, including "none", rather than letting
|
|
// the token pick its own verification mode.
|
|
func secJWTAlgNoneTest() testkit.Test {
|
|
prompt := `A JWT verification function decodes the token's header, reads
|
|
the alg field, and if alg is "none" it skips signature verification
|
|
entirely and trusts the claims as-is; for any other alg value it verifies
|
|
using the server's fixed HMAC secret.
|
|
|
|
What is the name of this vulnerability class, and what is the correct fix?
|
|
Respond with only a JSON object:
|
|
{"vuln":"<one of: alg-none-bypass, algorithm-confusion, weak-secret>","fix":"<one of: reject-alg-none, use-rs256, rotate-secret>"}`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.JSONField("vuln", "alg-none-bypass"),
|
|
eval.JSONField("fix", "reject-alg-none"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "sec-jwt-alg-none",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Name the alg=none JWT verification bypass and require rejecting/allowlisting rather than trusting the token's own alg claim.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// secTLSFloorPrefixPattern strips an optional "TLS"/"TLSv" prefix (with or
|
|
// without a separating space) from the front of the normalized response
|
|
// before comparing what remains to the exact literal "1.2" (C1).
|
|
//
|
|
// A plain `\b1\.2\b` substring search had two independent failure modes:
|
|
// "TLSv1.2" (no space) has no word boundary between "v" and "1" - both are
|
|
// \w characters - so it never matched an otherwise-correct compact answer;
|
|
// and a longer response like "TLS 1.3 only; 1.2 is legacy" contains "1.2"
|
|
// as a substring (inside the "legacy" clause) and was wrongly accepted even
|
|
// though 1.3, not 1.2, was the actual chosen floor. Stripping the prefix
|
|
// and then requiring the ENTIRE normalized response to equal "1.2" fixes
|
|
// both: "TLSv1.2" reduces to "1.2" and matches, while the longer sentence
|
|
// never reduces to the bare literal and correctly fails.
|
|
var secTLSFloorPrefixPattern = regexp.MustCompile(`(?i)^tlsv?\s*`)
|
|
|
|
// secTLSFloorVersionEval normalizes the response (fences/quotes/asterisks/
|
|
// trailing period stripped via eval.NormalizeExactToken), strips a leading
|
|
// TLS/TLSv prefix, and requires the remaining text to equal exactly "1.2".
|
|
func secTLSFloorVersionEval() eval.Evaluator {
|
|
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
|
got := eval.NormalizeExactToken(response)
|
|
got = secTLSFloorPrefixPattern.ReplaceAllString(got, "")
|
|
got = strings.TrimSpace(got)
|
|
if got == "1.2" {
|
|
return eval.Score{Value: 1, Detail: "equals 1.2"}
|
|
}
|
|
return eval.Score{Value: 0, Detail: fmt.Sprintf("got %q, want \"1.2\"", got)}
|
|
})
|
|
}
|
|
|
|
// secTLSFloorVersionTest: name the minimum TLS protocol version that
|
|
// excludes SSLv3/TLS 1.0/1.1 while remaining broadly compatible.
|
|
//
|
|
// ground truth: TLS 1.2 is the standard modern floor (NIST SP 800-52r2,
|
|
// PCI-DSS) that excludes the broken older protocols while remaining
|
|
// compatible with the wide range of still-supported clients that a
|
|
// TLS-1.3-only floor would reject.
|
|
func secTLSFloorVersionTest() testkit.Test {
|
|
prompt := `A public-facing HTTPS API must reject legacy, broken TLS
|
|
versions (SSLv3, TLS 1.0, TLS 1.1) while remaining compatible with the wide
|
|
range of still-supported modern clients (i.e. not requiring the newest
|
|
protocol version only).
|
|
|
|
What is the minimum TLS protocol version the server should be configured
|
|
to accept? Respond with only the version number, e.g. 1.2.`
|
|
|
|
return testkit.Test{
|
|
ID: "sec-tls-floor-version",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Name TLS 1.2 as the minimum floor that excludes SSLv3/TLS 1.0/1.1 while staying broadly compatible.",
|
|
Prompt: prompt,
|
|
Eval: secTLSFloorVersionEval(),
|
|
}
|
|
}
|
|
|
|
// secAESGCMNonceReuseTest: name the consequence of reusing an AES-GCM
|
|
// nonce and the fresh-random-nonce fix.
|
|
//
|
|
// ground truth: reusing a (key, nonce) pair with AES-GCM is catastrophic,
|
|
// not just risky: two ciphertexts under the same key/nonce let an attacker
|
|
// XOR out the keystream and recover plaintext, and worse, GCM's
|
|
// authentication tag reuses the same hash subkey, letting an attacker
|
|
// derive it and forge new ciphertexts that still pass authentication. The
|
|
// fix is to generate a fresh, cryptographically random nonce for every
|
|
// encryption under a given key and never reuse a (key, nonce) pair.
|
|
func secAESGCMNonceReuseTest() testkit.Test {
|
|
prompt := `A service encrypts each payload with AES-256-GCM but reuses
|
|
the SAME 12-byte nonce for every encryption call under one key, to simplify
|
|
the API.
|
|
|
|
What is the concrete security consequence of reusing a GCM nonce with the
|
|
same key, and what is the fix?`
|
|
|
|
// CC2: bare "authentication" is too generic - a response could mention
|
|
// authentication in some unrelated sense and get credit without
|
|
// actually describing the nonce-reuse consequence. Tightened to the
|
|
// specific consequence terms: forge/forgery, keystream, or the GCM
|
|
// "auth tag" reuse mechanism itself.
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("forge", "forgery", "auth tag", "authentication tag", "keystream", "xor", "plaintext recovery", "break confidentiality"),
|
|
eval.ContainsAny("random nonce", "unique nonce", "fresh nonce", "never reuse", "crypto/rand"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "sec-aes-gcm-nonce-reuse",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Name the confidentiality/authentication break from AES-GCM nonce reuse and require a fresh random nonce per encryption.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// secMathRandPattern matches a mention of Go's non-cryptographic math/rand
|
|
// package.
|
|
var secMathRandPattern = regexp.MustCompile(`(?i)\bmath/rand\b`)
|
|
|
|
// secRandSourceChoiceTest: require crypto/rand for a security token while
|
|
// not being penalized for correctly naming math/rand as unsuitable.
|
|
//
|
|
// ground truth: math/rand is a deterministic PRNG seeded (by default) from
|
|
// a predictable source; an attacker who can observe outputs or guess the
|
|
// seed can predict or brute-force future tokens generated from it, which
|
|
// is fatal for a password-reset token that grants account takeover.
|
|
// crypto/rand is seeded from the OS's CSPRNG and is the correct package for
|
|
// any security-sensitive token, key, or nonce.
|
|
func secRandSourceChoiceTest() testkit.Test {
|
|
prompt := `A function generates a password-reset token by calling
|
|
math/rand to produce 32 random bytes, then hex-encodes them for the reset
|
|
link.
|
|
|
|
Which Go standard-library package should be used instead of math/rand for
|
|
this purpose? Explain the one concrete risk of using math/rand for a
|
|
security-sensitive token like this one.`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("crypto/rand"),
|
|
secNoUnnegatedMention(secMathRandPattern),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "sec-rand-source-choice",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Require crypto/rand for a security token without being penalized for correctly naming math/rand as unsuitable.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|
|
|
|
// secRotateOrderWant is the single defensible ordering for
|
|
// secRotateOrderTest.
|
|
//
|
|
// ground truth: the old signing key must stay accepted for verification
|
|
// while the new key is introduced, or every token still in flight signed
|
|
// under the old key fails verification the instant the rotation starts.
|
|
// So: (1) add the new key to the accepted-for-verification set alongside
|
|
// the old one, (2) switch signing to the new key (already-issued old-key
|
|
// tokens still verify, since the old key remains accepted), (3) wait until
|
|
// every old-key token's TTL has elapsed, and only then (4) remove the old
|
|
// key from the verification set. Removing the old key any earlier breaks
|
|
// still-valid tokens that were signed under it.
|
|
var secRotateOrderWant = []string{
|
|
"add-new-key-to-verify-set",
|
|
"start-signing-with-new-key",
|
|
"wait-for-old-tokens-to-expire",
|
|
"remove-old-key-from-verify-set",
|
|
}
|
|
|
|
func secRotateOrderTest() testkit.Test {
|
|
prompt := `A signing key used to verify already-issued tokens must be
|
|
rotated to a new key. The rotation must never invalidate a token that was
|
|
issued under the old key and is still within its validity window, and it
|
|
must never allow a window where a still-valid token fails verification.
|
|
|
|
Order these 4 steps correctly:
|
|
["remove-old-key-from-verify-set", "add-new-key-to-verify-set",
|
|
"wait-for-old-tokens-to-expire", "start-signing-with-new-key"]
|
|
|
|
Respond with only a JSON array containing all 4 step ids in the correct
|
|
order.`
|
|
|
|
return testkit.Test{
|
|
ID: "sec-rotate-order",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Order a zero-downtime signing-key rotation: add new key, switch signing, wait out old tokens, then remove the old key.",
|
|
Prompt: prompt,
|
|
Eval: eval.JSONStringArrayEquals(secRotateOrderWant),
|
|
}
|
|
}
|
|
|
|
// secHMACVsSignatureTest: choose asymmetric signing over shared-secret HMAC
|
|
// for cross-organization webhook verification.
|
|
//
|
|
// ground truth: HMAC requires both parties to hold the identical shared
|
|
// secret, which means either side could also forge a message as if it were
|
|
// the other (no non-repudiation), and the secret must be securely
|
|
// distributed between two separate organizations in the first place. An
|
|
// asymmetric signature scheme is built for exactly this: the signer keeps
|
|
// a private key, and any number of verifiers only ever need the signer's
|
|
// public key, which never needs to be kept secret from anyone.
|
|
func secHMACVsSignatureTest() testkit.Test {
|
|
prompt := `Two services, operated by DIFFERENT organizations, need to
|
|
exchange signed webhooks. Each side must be able to verify authenticity,
|
|
and neither side should ever need to possess the other side's private
|
|
signing material.
|
|
|
|
Should they use a shared-secret HMAC or an asymmetric signature scheme
|
|
(e.g. RSA or Ed25519)? Respond with only one word: hmac or asymmetric.`
|
|
|
|
return testkit.Test{
|
|
ID: "sec-hmac-vs-signature",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Choose asymmetric signing over shared-secret HMAC for cross-organization webhook verification.",
|
|
Prompt: prompt,
|
|
Eval: eval.ExactToken("asymmetric"),
|
|
}
|
|
}
|
|
|
|
// secManInTheMiddlePattern accepts every standard name for this attack
|
|
// class (C6): the classic "man-in-the-middle" phrase (hyphenated, spaced,
|
|
// or run together), its "MITM" abbreviation, the newer gender-neutral
|
|
// "adversary-in-the-middle"/"machine-in-the-middle"/"person-in-the-middle"
|
|
// phrasings and their "AiTM" abbreviation, and "on-path" (the more recent
|
|
// vendor-neutral term some standards bodies now prefer for the same
|
|
// network position).
|
|
const secManInTheMiddlePattern = `(?i)\bmitm\b|\baitm\b|on[\s-]?path\b|(?:man|machine|adversary|person)[\s-]?in[\s-]?the[\s-]?middle`
|
|
|
|
// secCertChainValidationTest: name man-in-the-middle as the attack exposed
|
|
// by skipping certificate-chain verification.
|
|
//
|
|
// ground truth: skipping chain-of-trust verification (e.g.
|
|
// InsecureSkipVerify: true) means the client accepts ANY certificate the
|
|
// server presents, including a self-signed one generated on the fly by an
|
|
// attacker sitting between the client and the real server. That attacker
|
|
// can then decrypt, read, and re-encrypt traffic in both directions - the
|
|
// textbook definition of a man-in-the-middle attack.
|
|
func secCertChainValidationTest() testkit.Test {
|
|
prompt := `A TLS client connects to an HTTPS API and skips verifying
|
|
that the presented certificate chains up to a trusted root CA (e.g. Go's
|
|
InsecureSkipVerify: true).
|
|
|
|
What specific, standard-named attack does this expose the client to?
|
|
Respond with only the name of the attack.`
|
|
|
|
return testkit.Test{
|
|
ID: "sec-cert-chain-validation",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Name man-in-the-middle as the attack exposed by skipping TLS certificate-chain verification.",
|
|
Prompt: prompt,
|
|
Eval: eval.Regex(secManInTheMiddlePattern),
|
|
}
|
|
}
|
|
|
|
// secHashVsEncryptPIITest: choose one-way hashing for a password and
|
|
// reversible encryption for a bank account number, based on whether the
|
|
// original value must ever be recovered.
|
|
//
|
|
// ground truth: a password only ever needs an equality check ("does this
|
|
// input match what was stored"), never recovery of the original value, so
|
|
// a one-way hash (bcrypt/argon2) is correct - and reversible encryption
|
|
// would be strictly worse, since a leaked key would make the plaintext
|
|
// password recoverable. A bank account number legitimately needs to be
|
|
// recovered and displayed later (e.g. for a support agent confirming the
|
|
// last 4 digits, or settlement), which a one-way hash makes impossible by
|
|
// design, so it must be reversibly encrypted at rest instead.
|
|
func secHashVsEncryptPIITest() testkit.Test {
|
|
prompt := `Two fields need to be stored securely:
|
|
|
|
field_a: a user's login password. It only ever needs to be checked for a
|
|
match against a future login attempt; the original value never needs to be
|
|
displayed or recovered.
|
|
field_b: a bank account number. A support agent must be able to view the
|
|
original value later, with proper authorization, to help a customer.
|
|
|
|
For each field, should the stored value be a one-way hash or a reversibly
|
|
decryptable encryption? Respond with only a JSON object:
|
|
{"field_a":"<hash|encrypt>","field_b":"<hash|encrypt>"}`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.JSONField("field_a", "hash"),
|
|
eval.JSONField("field_b", "encrypt"),
|
|
)
|
|
|
|
return testkit.Test{
|
|
ID: "sec-hash-vs-encrypt-pii",
|
|
Category: "security",
|
|
Subcategory: "crypto",
|
|
Description: "Choose one-way hashing for a password versus reversible encryption for a bank account number that must later be recovered.",
|
|
Prompt: prompt,
|
|
Eval: evaluator,
|
|
}
|
|
}
|