Skip to content

Commit 9d51472

Browse files
feat(065): add cmd/scan-eval D2 detector bridge (B1) (#550)
Bridge the Spec 065 / D2 security corpus to mcpproxy's production sensitive-data detector and emit per-entry, per-detector verdict JSON for the Python SecurityScorer (B3). Offline, deterministic test tooling only — no runtime or REST surface (Security-by-Default, R-03). - cmd/scan-eval: reads a security-corpus.schema.json-conforming file, runs each entry.description through security.NewDetector(nil).Scan, echoes ground-truth id/label/category, emits scan-verdict.schema.json. - Flags: --corpus (required), --out (default stdout), --detectors=sensitive-data (default), --scanners (reserved opt-in extension point for the deferred Docker bundled-scanner pass). - Exit codes: 0 ok, 4 bad/missing corpus or flags, 1 write failure. - contracts/scan-verdict.schema.json: the verdict output contract B3 consumes to derive per-detector TP/FP/TN/FN -> P/R/F1/FPR. - Test-first: TP (embedded AWS key), TN, missing/empty corpus, and deterministic-output coverage; committed minimal corpus fixture. The fixture demonstrates honest measurement (INV-3): the detector is a true positive on a credential-exfil description, a false negative on pure prompt-injection text, and a visible false positive on a benign doc referencing ~/.aws/credentials — i.e. it measures real coverage rather than trivially passing. Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent e7537c6 commit 9d51472

5 files changed

Lines changed: 533 additions & 0 deletions

File tree

cmd/scan-eval/eval.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
9+
)
10+
11+
// detectorSensitiveData is the id of the deterministic, in-process
12+
// sensitive-data/secret detector bridged in this PR (Gate-2 approved scope).
13+
// Docker bundled scanners are a deferred opt-in extension point (--scanners).
14+
const detectorSensitiveData = "sensitive-data"
15+
16+
// corpusEntry mirrors one item of contracts/security-corpus.schema.json.
17+
type corpusEntry struct {
18+
ID string `json:"id"`
19+
Description string `json:"description"`
20+
Label string `json:"label"`
21+
Category string `json:"category"`
22+
Provenance struct {
23+
Source string `json:"source"`
24+
License string `json:"license"`
25+
} `json:"provenance"`
26+
}
27+
28+
// corpus is the D2 security corpus document. corpus_version/version are
29+
// optional; the schema only mandates entries. Unknown fields are tolerated so
30+
// the tool stays dataset-agnostic across corpus revisions.
31+
type corpus struct {
32+
CorpusVersion string `json:"corpus_version"`
33+
Version string `json:"version"`
34+
Entries []corpusEntry `json:"entries"`
35+
}
36+
37+
// resolvedVersion returns the corpus version for echoing into the verdict
38+
// report, preferring corpus_version, then version, else "unknown".
39+
func (c *corpus) resolvedVersion() string {
40+
switch {
41+
case c.CorpusVersion != "":
42+
return c.CorpusVersion
43+
case c.Version != "":
44+
return c.Version
45+
default:
46+
return "unknown"
47+
}
48+
}
49+
50+
// detectionView is the per-detection projection emitted in verdicts. It drops
51+
// detector-internal fields (location, is_likely_example) the scorer does not
52+
// need, keeping the contract minimal.
53+
type detectionView struct {
54+
Type string `json:"type"`
55+
Category string `json:"category"`
56+
Severity string `json:"severity"`
57+
}
58+
59+
// detectorVerdict is one detector's call on one entry.
60+
type detectorVerdict struct {
61+
Detector string `json:"detector"`
62+
Flagged bool `json:"flagged"`
63+
MaxSeverity string `json:"max_severity"`
64+
Detections []detectionView `json:"detections"`
65+
}
66+
67+
// verdictEntry echoes ground truth and carries every detector's verdict.
68+
type verdictEntry struct {
69+
ID string `json:"id"`
70+
Label string `json:"label"`
71+
Category string `json:"category"`
72+
Verdicts []detectorVerdict `json:"verdicts"`
73+
}
74+
75+
// verdictReport is the top-level output (contracts/scan-verdict.schema.json),
76+
// the contract consumed by the Python SecurityScorer (B3).
77+
type verdictReport struct {
78+
CorpusVersion string `json:"corpus_version"`
79+
Detectors []string `json:"detectors"`
80+
Entries []verdictEntry `json:"entries"`
81+
}
82+
83+
// loadCorpus reads and decodes a D2 security corpus JSON file. A read/parse
84+
// failure or an empty entry set is a config error (callers map it to exit 4).
85+
func loadCorpus(path string) (*corpus, error) {
86+
data, err := os.ReadFile(path)
87+
if err != nil {
88+
return nil, fmt.Errorf("reading corpus %q: %w", path, err)
89+
}
90+
var c corpus
91+
if err := json.Unmarshal(data, &c); err != nil {
92+
return nil, fmt.Errorf("parsing corpus %q: %w", path, err)
93+
}
94+
if len(c.Entries) == 0 {
95+
return nil, fmt.Errorf("corpus %q has no entries", path)
96+
}
97+
return &c, nil
98+
}
99+
100+
// evaluate runs every corpus entry's description through the detector and
101+
// projects the result into the verdict contract. Output ordering follows the
102+
// corpus order and the detector's deterministic pattern order, so repeated
103+
// runs over an unchanged corpus are byte-identical (INV-5).
104+
func evaluate(c *corpus, detector *security.Detector) *verdictReport {
105+
report := &verdictReport{
106+
CorpusVersion: c.resolvedVersion(),
107+
Detectors: []string{detectorSensitiveData},
108+
Entries: make([]verdictEntry, 0, len(c.Entries)),
109+
}
110+
111+
for _, e := range c.Entries {
112+
// The corpus stores the tool description text; scan it as a response
113+
// payload (the detector treats arguments/response identically).
114+
res := detector.Scan("", e.Description)
115+
116+
v := detectorVerdict{
117+
Detector: detectorSensitiveData,
118+
Flagged: res.Detected,
119+
MaxSeverity: res.MaxSeverity(),
120+
Detections: make([]detectionView, 0, len(res.Detections)),
121+
}
122+
for _, d := range res.Detections {
123+
v.Detections = append(v.Detections, detectionView{
124+
Type: d.Type,
125+
Category: d.Category,
126+
Severity: d.Severity,
127+
})
128+
}
129+
130+
report.Entries = append(report.Entries, verdictEntry{
131+
ID: e.ID,
132+
Label: e.Label,
133+
Category: e.Category,
134+
Verdicts: []detectorVerdict{v},
135+
})
136+
}
137+
138+
return report
139+
}

cmd/scan-eval/eval_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
11+
)
12+
13+
const minCorpus = "testdata/security_corpus_min.json"
14+
15+
func findEntry(t *testing.T, r *verdictReport, id string) verdictEntry {
16+
t.Helper()
17+
for _, e := range r.Entries {
18+
if e.ID == id {
19+
return e
20+
}
21+
}
22+
t.Fatalf("entry %q not found in report", id)
23+
return verdictEntry{}
24+
}
25+
26+
// sensitiveDataVerdict returns the single sensitive-data verdict for an entry.
27+
func sensitiveDataVerdict(t *testing.T, e verdictEntry) detectorVerdict {
28+
t.Helper()
29+
for _, v := range e.Verdicts {
30+
if v.Detector == detectorSensitiveData {
31+
return v
32+
}
33+
}
34+
t.Fatalf("entry %q has no %q verdict", e.ID, detectorSensitiveData)
35+
return detectorVerdict{}
36+
}
37+
38+
// TestEvaluate_SchemaShape — TDD #1: evaluate() over the fixture echoes
39+
// id/label/category and emits one sensitive-data verdict per entry.
40+
func TestEvaluate_SchemaShape(t *testing.T) {
41+
c, err := loadCorpus(minCorpus)
42+
if err != nil {
43+
t.Fatalf("loadCorpus: %v", err)
44+
}
45+
46+
report := evaluate(c, security.NewDetector(nil))
47+
48+
if report.CorpusVersion != "test-min-v1" {
49+
t.Errorf("corpus_version = %q, want %q", report.CorpusVersion, "test-min-v1")
50+
}
51+
if len(report.Detectors) != 1 || report.Detectors[0] != detectorSensitiveData {
52+
t.Errorf("detectors = %v, want [%q]", report.Detectors, detectorSensitiveData)
53+
}
54+
if len(report.Entries) != len(c.Entries) {
55+
t.Fatalf("entries = %d, want %d", len(report.Entries), len(c.Entries))
56+
}
57+
for i, e := range report.Entries {
58+
src := c.Entries[i]
59+
if e.ID != src.ID || e.Label != src.Label || e.Category != src.Category {
60+
t.Errorf("entry %d ground truth not echoed: got (%q,%q,%q) want (%q,%q,%q)",
61+
i, e.ID, e.Label, e.Category, src.ID, src.Label, src.Category)
62+
}
63+
v := sensitiveDataVerdict(t, e)
64+
if v.Detections == nil {
65+
t.Errorf("entry %q: detections must be non-nil (B3 contract requires the array)", e.ID)
66+
}
67+
}
68+
}
69+
70+
// TestEvaluate_TruePositive — TDD #2 / INV-3 positive: a malicious entry whose
71+
// description embeds an AWS key flags critical.
72+
func TestEvaluate_TruePositive(t *testing.T) {
73+
c, err := loadCorpus(minCorpus)
74+
if err != nil {
75+
t.Fatalf("loadCorpus: %v", err)
76+
}
77+
report := evaluate(c, security.NewDetector(nil))
78+
79+
v := sensitiveDataVerdict(t, findEntry(t, report, "tp-aws-key-001"))
80+
if !v.Flagged {
81+
t.Fatalf("tp-aws-key-001: flagged = false, want true (TP)")
82+
}
83+
if v.MaxSeverity != "critical" {
84+
t.Errorf("tp-aws-key-001: max_severity = %q, want %q", v.MaxSeverity, "critical")
85+
}
86+
found := false
87+
for _, d := range v.Detections {
88+
if d.Type == "aws_access_key" {
89+
found = true
90+
}
91+
}
92+
if !found {
93+
t.Errorf("tp-aws-key-001: expected an aws_access_key detection, got %+v", v.Detections)
94+
}
95+
}
96+
97+
// TestEvaluate_TrueNegative — TDD #3 / INV-3 negative: a plain benign
98+
// description is not flagged (no false positive).
99+
func TestEvaluate_TrueNegative(t *testing.T) {
100+
c, err := loadCorpus(minCorpus)
101+
if err != nil {
102+
t.Fatalf("loadCorpus: %v", err)
103+
}
104+
report := evaluate(c, security.NewDetector(nil))
105+
106+
v := sensitiveDataVerdict(t, findEntry(t, report, "benign-weather-001"))
107+
if v.Flagged {
108+
t.Errorf("benign-weather-001: flagged = true, want false (TN). detections=%+v", v.Detections)
109+
}
110+
if v.MaxSeverity != "" {
111+
t.Errorf("benign-weather-001: max_severity = %q, want empty", v.MaxSeverity)
112+
}
113+
if len(v.Detections) != 0 {
114+
t.Errorf("benign-weather-001: detections = %+v, want none", v.Detections)
115+
}
116+
}
117+
118+
// TestRun_MissingCorpus — TDD #4: bad/missing corpus and missing flag both
119+
// exit 4 (config error, matching repo convention).
120+
func TestRun_MissingCorpus(t *testing.T) {
121+
cases := []struct {
122+
name string
123+
args []string
124+
}{
125+
{"no --corpus flag", []string{}},
126+
{"nonexistent file", []string{"--corpus", filepath.Join(t.TempDir(), "nope.json")}},
127+
{"unparsable flag", []string{"--corpus", minCorpus, "--bogus"}},
128+
}
129+
for _, tc := range cases {
130+
t.Run(tc.name, func(t *testing.T) {
131+
var out, errBuf bytes.Buffer
132+
if code := run(tc.args, &out, &errBuf); code != exitConfigError {
133+
t.Errorf("run(%v) = %d, want %d. stderr=%q", tc.args, code, exitConfigError, errBuf.String())
134+
}
135+
})
136+
}
137+
}
138+
139+
// TestRun_EmptyCorpus — an entries-less corpus is a config error.
140+
func TestRun_EmptyCorpus(t *testing.T) {
141+
p := filepath.Join(t.TempDir(), "empty.json")
142+
if err := os.WriteFile(p, []byte(`{"entries":[]}`), 0o644); err != nil {
143+
t.Fatal(err)
144+
}
145+
var out, errBuf bytes.Buffer
146+
if code := run([]string{"--corpus", p}, &out, &errBuf); code != exitConfigError {
147+
t.Errorf("run(empty corpus) = %d, want %d", code, exitConfigError)
148+
}
149+
}
150+
151+
// TestRun_Deterministic — TDD #5 / INV-5 spirit: two runs over an unchanged
152+
// corpus produce byte-identical, schema-parseable verdict JSON.
153+
func TestRun_Deterministic(t *testing.T) {
154+
var a, b bytes.Buffer
155+
if code := run([]string{"--corpus", minCorpus}, &a, &bytes.Buffer{}); code != exitOK {
156+
t.Fatalf("run #1 = %d, want %d", code, exitOK)
157+
}
158+
if code := run([]string{"--corpus", minCorpus}, &b, &bytes.Buffer{}); code != exitOK {
159+
t.Fatalf("run #2 = %d, want %d", code, exitOK)
160+
}
161+
if a.String() != b.String() {
162+
t.Errorf("non-deterministic output across runs")
163+
}
164+
var report verdictReport
165+
if err := json.Unmarshal(a.Bytes(), &report); err != nil {
166+
t.Fatalf("stdout is not valid verdict JSON: %v", err)
167+
}
168+
if len(report.Entries) != 4 {
169+
t.Errorf("entries = %d, want 4", len(report.Entries))
170+
}
171+
}
172+
173+
// TestRun_WritesToFile — --out writes the same bytes it would print to stdout.
174+
func TestRun_WritesToFile(t *testing.T) {
175+
var stdoutBuf bytes.Buffer
176+
if code := run([]string{"--corpus", minCorpus}, &stdoutBuf, &bytes.Buffer{}); code != exitOK {
177+
t.Fatalf("stdout run = %d", code)
178+
}
179+
180+
outPath := filepath.Join(t.TempDir(), "verdict.json")
181+
if code := run([]string{"--corpus", minCorpus, "--out", outPath}, &bytes.Buffer{}, &bytes.Buffer{}); code != exitOK {
182+
t.Fatalf("file run = %d", code)
183+
}
184+
got, err := os.ReadFile(outPath)
185+
if err != nil {
186+
t.Fatalf("reading --out file: %v", err)
187+
}
188+
if string(got) != stdoutBuf.String() {
189+
t.Errorf("--out file differs from stdout output")
190+
}
191+
}

0 commit comments

Comments
 (0)