Skip to content

Commit 371af22

Browse files
feat(065): security_corpus_v1.json (D2 security regression dataset) (#551)
Add the D2 labeled security corpus the detection scorer measures against, plus a co-located validator test enforcing the contract and cross-entity invariants (INV-3, INV-4 / SC-004). - 43 entries: 20 malicious (tool_poisoning/prompt_injection/shadowing/rug_pull), 15 clean benign, 8 attack-resembling hard negatives (2 per attack category). - Every entry carries label + category + provenance.{source,license} (FR-007). - Sources limited to self-authored + DVMCP (MIT); MCPTox/MCP-AttackBench and the unconfirmed-license mcp-injection-experiments are referenced externally only, never vendored (CN-005, R-07, R-A). The validator fails the build on any non-redistributable license. - corpus_test.go validates against security-corpus.schema.json (santhosh-tekuri jsonschema/v6) and asserts attack coverage + >=1 hard negative per category. Related #739 Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 9d51472 commit 371af22

3 files changed

Lines changed: 695 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Spec 065 — Evaluation datasets
2+
3+
## `security_corpus_v1.json` (D2)
4+
5+
Labeled security regression corpus the D2 detection scorer measures against
6+
(precision / recall / F1 / FPR per detector). Conforms to
7+
[`../contracts/security-corpus.schema.json`](../contracts/security-corpus.schema.json)
8+
and the cross-entity invariants in [`../data-model.md`](../data-model.md)
9+
(INV-3, INV-4). Validated by `corpus_test.go` in this directory.
10+
11+
**Composition (43 entries):**
12+
13+
| Label | Category | Count |
14+
|-------|----------|-------|
15+
| malicious | `tool_poisoning` | 6 |
16+
| malicious | `prompt_injection` | 6 |
17+
| malicious | `shadowing` | 4 |
18+
| malicious | `rug_pull` | 4 |
19+
| benign | `benign` (clean base rate) | 15 |
20+
| benign | `hard_negative` (attack-resembling) | 8 |
21+
22+
Hard negatives are benign descriptions that *superficially resemble* an attack
23+
(e.g. a secrets-scanner that lists `~/.ssh/id_rsa` as an example, a tool that
24+
legitimately says "ignore case"). They exist to expose noisy detectors
25+
(SC-004 / INV-3). Each hard-negative `id` is `hn_<attack_category>_<n>`, encoding
26+
the attack it mimics so false positives map back to a category.
27+
28+
## Provenance & licensing (FR-007 / CN-005 / R-07 / R-A)
29+
30+
Every entry carries `provenance.{source,license}`, and the test fails the build
31+
if any license is outside the redistributable allowlist (CN-005 / INV-4).
32+
33+
- **`self-authored` / `self-authored`** — the dominant source; short
34+
tool-description strings written from scratch, modeled on public attack
35+
taxonomies. Redistributable by construction.
36+
- **`DVMCP` / `MIT`** — a subset adapted from the MIT-licensed
37+
[Damn Vulnerable MCP](https://github.com/harishsg993010/damn-vulnerable-MCP-server)
38+
project.
39+
40+
### External benchmarks (referenced, NOT vendored)
41+
42+
Per CN-005 and risk R-A, the following are **referenced externally only** and no
43+
text from them is vendored into this repo:
44+
45+
- **MCPTox** and **MCP-AttackBench** — restrictive / research-only licenses.
46+
- **`mcp-injection-experiments`** — LICENSE unconfirmed (research.md R-A); where it
47+
inspired a pattern, the corresponding entry was rewritten from scratch and
48+
labeled `self-authored`. The corpus test rejects any entry sourced from these.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Package datasets holds the validator for the Spec 065 evaluation datasets.
2+
//
3+
// This test enforces the D2 security-corpus contract
4+
// (specs/065-evaluation-foundation/contracts/security-corpus.schema.json) plus
5+
// the cross-entity invariants from data-model.md that plain JSON Schema cannot
6+
// express (INV-3, INV-4, SC-004): every entry carries a redistributable
7+
// provenance, label/category are coherent, and every attack category is
8+
// covered by at least one malicious sample and at least one attack-resembling
9+
// benign hard negative.
10+
package datasets
11+
12+
import (
13+
"encoding/json"
14+
"os"
15+
"strings"
16+
"testing"
17+
18+
"github.com/santhosh-tekuri/jsonschema/v6"
19+
)
20+
21+
const (
22+
corpusFile = "security_corpus_v1.json"
23+
schemaFile = "../contracts/security-corpus.schema.json"
24+
)
25+
26+
// attackCategories are the four malicious taxonomies the corpus must cover.
27+
var attackCategories = []string{"tool_poisoning", "prompt_injection", "shadowing", "rug_pull"}
28+
29+
// redistributableLicenses is the allowlist enforcing CN-005 / FR-007 / INV-4:
30+
// no entry may carry a redistribution-restricted license.
31+
var redistributableLicenses = map[string]bool{
32+
"self-authored": true,
33+
"MIT": true,
34+
"Apache-2.0": true,
35+
"BSD-3-Clause": true,
36+
"CC0-1.0": true,
37+
}
38+
39+
// restrictedSources must never be vendored (CN-005 + R-A): MCPTox / MCP-AttackBench
40+
// are restrictive; mcp-injection-experiments has an unconfirmed LICENSE (R-A) so we
41+
// deliberately reference it externally only and never vendor its text.
42+
var restrictedSources = map[string]bool{
43+
"MCPTox": true,
44+
"MCP-AttackBench": true,
45+
"mcp-injection-experiments": true,
46+
}
47+
48+
type provenance struct {
49+
Source string `json:"source"`
50+
License string `json:"license"`
51+
}
52+
53+
type entry struct {
54+
ID string `json:"id"`
55+
Description string `json:"description"`
56+
Label string `json:"label"`
57+
Category string `json:"category"`
58+
Provenance provenance `json:"provenance"`
59+
}
60+
61+
type corpus struct {
62+
Entries []entry `json:"entries"`
63+
}
64+
65+
func loadCorpus(t *testing.T) corpus {
66+
t.Helper()
67+
raw, err := os.ReadFile(corpusFile)
68+
if err != nil {
69+
t.Fatalf("read %s: %v", corpusFile, err)
70+
}
71+
inst, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw)))
72+
if err != nil {
73+
t.Fatalf("parse %s: %v", corpusFile, err)
74+
}
75+
76+
// Validate against the committed JSON Schema contract.
77+
schemaRaw, err := os.ReadFile(schemaFile)
78+
if err != nil {
79+
t.Fatalf("read %s: %v", schemaFile, err)
80+
}
81+
schemaDoc, err := jsonschema.UnmarshalJSON(strings.NewReader(string(schemaRaw)))
82+
if err != nil {
83+
t.Fatalf("parse schema: %v", err)
84+
}
85+
c := jsonschema.NewCompiler()
86+
if err := c.AddResource("security-corpus.schema.json", schemaDoc); err != nil {
87+
t.Fatalf("add schema resource: %v", err)
88+
}
89+
sch, err := c.Compile("security-corpus.schema.json")
90+
if err != nil {
91+
t.Fatalf("compile schema: %v", err)
92+
}
93+
if err := sch.Validate(inst); err != nil {
94+
t.Fatalf("corpus fails schema contract: %v", err)
95+
}
96+
97+
// Re-decode into typed structs for the invariant checks.
98+
var typed corpus
99+
if err := json.Unmarshal(raw, &typed); err != nil {
100+
t.Fatalf("decode corpus into structs: %v", err)
101+
}
102+
return typed
103+
}
104+
105+
func TestCorpus_SchemaAndStructure(t *testing.T) {
106+
c := loadCorpus(t)
107+
108+
if len(c.Entries) == 0 {
109+
t.Fatal("corpus has no entries (schema minItems=1)")
110+
}
111+
112+
seen := map[string]bool{}
113+
for i, e := range c.Entries {
114+
if e.ID == "" {
115+
t.Errorf("entry %d: empty id", i)
116+
}
117+
if seen[e.ID] {
118+
t.Errorf("entry %d: duplicate id %q", i, e.ID)
119+
}
120+
seen[e.ID] = true
121+
122+
if strings.TrimSpace(e.Description) == "" {
123+
t.Errorf("entry %q: empty description", e.ID)
124+
}
125+
126+
// INV-4 / FR-007: label + category + provenance license present and coherent.
127+
switch e.Label {
128+
case "malicious":
129+
if !contains(attackCategories, e.Category) {
130+
t.Errorf("entry %q: malicious label requires an attack category, got %q", e.ID, e.Category)
131+
}
132+
case "benign":
133+
if e.Category != "benign" && e.Category != "hard_negative" {
134+
t.Errorf("entry %q: benign label requires category benign|hard_negative, got %q", e.ID, e.Category)
135+
}
136+
default:
137+
t.Errorf("entry %q: invalid label %q", e.ID, e.Label)
138+
}
139+
140+
// CN-005 / INV-4: redistributable provenance only; restricted sources never vendored.
141+
if e.Provenance.Source == "" {
142+
t.Errorf("entry %q: empty provenance.source", e.ID)
143+
}
144+
if restrictedSources[e.Provenance.Source] {
145+
t.Errorf("entry %q: source %q is restricted/unconfirmed and must not be vendored (CN-005/R-A)", e.ID, e.Provenance.Source)
146+
}
147+
if !redistributableLicenses[e.Provenance.License] {
148+
t.Errorf("entry %q: license %q is not in the redistributable allowlist (CN-005/FR-007)", e.ID, e.Provenance.License)
149+
}
150+
}
151+
}
152+
153+
func TestCorpus_AttackCoverageAndHardNegatives(t *testing.T) {
154+
c := loadCorpus(t)
155+
156+
maliciousByCat := map[string]int{}
157+
hardNegByMimic := map[string]int{}
158+
159+
for _, e := range c.Entries {
160+
if e.Label == "malicious" {
161+
maliciousByCat[e.Category]++
162+
}
163+
if e.Label == "benign" && e.Category == "hard_negative" {
164+
// Convention: hard-negative ids encode the attack they mimic as
165+
// hn_<attack_category>_<n> so INV-3 (which attack a benign FP resembles)
166+
// is machine-readable.
167+
mimic := mimickedAttack(e.ID)
168+
if mimic == "" {
169+
t.Errorf("hard_negative %q: id must be hn_<attack_category>_<n> to declare the mimicked attack", e.ID)
170+
continue
171+
}
172+
hardNegByMimic[mimic]++
173+
}
174+
}
175+
176+
// INV-3 / SC-004: every attack category is covered by >=1 malicious sample
177+
// AND >=1 attack-resembling benign hard negative.
178+
for _, cat := range attackCategories {
179+
if maliciousByCat[cat] == 0 {
180+
t.Errorf("attack category %q has no malicious sample", cat)
181+
}
182+
if hardNegByMimic[cat] == 0 {
183+
t.Errorf("attack category %q has no hard-negative benign (SC-004/INV-3)", cat)
184+
}
185+
}
186+
}
187+
188+
func mimickedAttack(id string) string {
189+
rest, ok := strings.CutPrefix(id, "hn_")
190+
if !ok {
191+
return ""
192+
}
193+
for _, cat := range attackCategories {
194+
if strings.HasPrefix(rest, cat+"_") {
195+
return cat
196+
}
197+
}
198+
return ""
199+
}
200+
201+
func contains(xs []string, x string) bool {
202+
for _, v := range xs {
203+
if v == x {
204+
return true
205+
}
206+
}
207+
return false
208+
}

0 commit comments

Comments
 (0)