diff --git a/internal/tests/security.go b/internal/tests/security.go new file mode 100644 index 0000000..39365bd --- /dev/null +++ b/internal/tests/security.go @@ -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} + }) +} diff --git a/internal/tests/security_appsec.go b/internal/tests/security_appsec.go new file mode 100644 index 0000000..db74410 --- /dev/null +++ b/internal/tests/security_appsec.go @@ -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":,"fix":""}` + + 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, "

Welcome, %s!

", 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 +// `` +// 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":,"fix":""}` + + 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":,"fix":""}` + + 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":,"fix":""}` + + 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":,"fix":""}` + + 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":}` + + 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":"","key":""}` + + 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":}` + + 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), + } +} diff --git a/internal/tests/security_appsec_test.go b/internal/tests/security_appsec_test.go new file mode 100644 index 0000000..50d5bed --- /dev/null +++ b/internal/tests/security_appsec_test.go @@ -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) + } + }) + } +} diff --git a/internal/tests/security_crypto.go b/internal/tests/security_crypto.go new file mode 100644 index 0000000..db37427 --- /dev/null +++ b/internal/tests/security_crypto.go @@ -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":"","fix":""}` + + 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":"","field_b":""}` + + 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, + } +} diff --git a/internal/tests/security_crypto_test.go b/internal/tests/security_crypto_test.go new file mode 100644 index 0000000..b1f6f68 --- /dev/null +++ b/internal/tests/security_crypto_test.go @@ -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) + } + }) + } +} diff --git a/internal/tests/security_secrets.go b/internal/tests/security_secrets.go new file mode 100644 index 0000000..6b83321 --- /dev/null +++ b/internal/tests/security_secrets.go @@ -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":}` + + 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":""}` + + 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":""}` + + 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: "no-reply@example.com", +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":}` + + 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 :.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"), + } +} diff --git a/internal/tests/security_secrets_test.go b/internal/tests/security_secrets_test.go new file mode 100644 index 0000000..f7953c5 --- /dev/null +++ b/internal/tests/security_secrets_test.go @@ -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) + } + }) + } +} diff --git a/internal/tests/security_test.go b/internal/tests/security_test.go new file mode 100644 index 0000000..c76ed69 --- /dev/null +++ b/internal/tests/security_test.go @@ -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]) + } + } +}