mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Add a new security category with three subcategories: appsec, crypto, and secrets. Each subcategory holds 10 self-contained, deterministic tests grounded in real vulnerability classes and remediation procedures. Appsec tests spot line-numbered injection, XSS, path traversal, SSRF, IDOR, and open-redirect flaws in synthetic fixtures, plus CSRF, rate limiting, input validation, and log-secret placement questions. Crypto tests cover password hashing, constant-time comparison, JWT alg=none, TLS floor version, AES-GCM nonce reuse, random source choice, key rotation ordering, HMAC versus asymmetric signing, certificate chain validation, and hash-versus-encrypt choices for PII. Secrets tests cover hardcoded-secret spotting, remediation ordering (rotate before history rewrite), vault tradeoffs, Kubernetes Secret base64 nuance, zero-downtime rotation ordering, least-privilege scoping, diff secret spotting, fork-PR CI exposure, SSH agent identity handling, and gitignore-versus-history nuance. A shared security.go file exposes registerSecurityTests and two reusable helpers: secNoUnnegatedMention, a bidirectional negation-aware guard adapted from kubernetes.go so a correct warning against a discouraged term is never penalized, and secExactAnswer, a forgiving exact-match evaluator for single-token answers. This change touches only new internal/tests/security*.go files. It does not modify catalog.go or catalog_test.go; the orchestrator wires registerSecurityTests at integration. A wiring test in security_test.go verifies the registration directly, mirroring the agents_test.go pattern. Every test carries a ground-truth comment, and every evaluator's unit test covers at least three correct phrasings and two incorrect ones.
367 lines
16 KiB
Go
367 lines
16 KiB
Go
package tests
|
|
|
|
import (
|
|
"regexp"
|
|
|
|
"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`)
|
|
|
|
// 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?`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("bcrypt", "argon2"),
|
|
secNoUnnegatedMention(secWeakPasswordHashPattern),
|
|
)
|
|
|
|
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 ==.`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("timing attack", "timing side channel", "time-based", "constant-time", "constant time"),
|
|
eval.ContainsAny("hmac.equal", "subtle.constanttimecompare", "constanttimecompare"),
|
|
)
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// secTLSFloorPattern requires the digits 1.2 as a standalone version
|
|
// number, tolerant of a leading "TLS"/"TLSv" and surrounding punctuation.
|
|
const secTLSFloorPattern = `\b1\.2\b`
|
|
|
|
// 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: eval.Regex(secTLSFloorPattern),
|
|
}
|
|
}
|
|
|
|
// 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?`
|
|
|
|
evaluator := eval.Mean(
|
|
eval.ContainsAny("forge", "forgery", "authentication", "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, and confirm: math/rand must never be used to generate a
|
|
security-sensitive token like this one. Explain the one concrete risk of
|
|
the current approach.`
|
|
|
|
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:
|
|
["add-new-key-to-verify-set", "start-signing-with-new-key",
|
|
"wait-for-old-tokens-to-expire", "remove-old-key-from-verify-set"]
|
|
|
|
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: secExactAnswer("asymmetric"),
|
|
}
|
|
}
|
|
|
|
// secManInTheMiddlePattern accepts the standard term for the attack with
|
|
// or without hyphens/spaces between the words.
|
|
const secManInTheMiddlePattern = `(?i)man[\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,
|
|
}
|
|
}
|