@applesnort/crosscheck 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PROVENANCE.md +57 -0
- package/README.md +244 -0
- package/bin/crosscheck.mjs +426 -0
- package/fixtures/calibration/PREREGISTERED.md +522 -0
- package/fixtures/calibration/expected.json +144 -0
- package/fixtures/calibration/src/session.js +123 -0
- package/foreman.md +140 -0
- package/lenses/architect.md +92 -0
- package/lenses/check.md +87 -0
- package/lenses/security-check.md +105 -0
- package/lenses/taint.md +102 -0
- package/lenses/ux.md +105 -0
- package/lib/baseline.mjs +78 -0
- package/lib/calibrate.mjs +169 -0
- package/lib/corpus.mjs +340 -0
- package/lib/lenses.mjs +200 -0
- package/lib/merge.mjs +310 -0
- package/lib/parse.mjs +96 -0
- package/lib/prompt.mjs +85 -0
- package/lib/run.mjs +129 -0
- package/lib/sarif.mjs +176 -0
- package/package.json +53 -0
package/lenses/ux.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ux
|
|
3
|
+
summary: usability under interruption, error, and extreme states
|
|
4
|
+
when: [**/*.{jsx,tsx,vue,svelte,html}, "**/components/**", "**/views/**", "**/pages/**"]
|
|
5
|
+
owns: situations where a real, hurried user cannot complete or understand a task
|
|
6
|
+
not-owns: assistive technology and WCAG criteria, correctness, architecture, security
|
|
7
|
+
cites: ["Nielsen's ten usability heuristics"]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Lens: ux — usability under real conditions
|
|
11
|
+
|
|
12
|
+
You are a product designer using this software as an impatient, busy person who
|
|
13
|
+
did not read any documentation, is interrupted mid-task, and does not care how the
|
|
14
|
+
system works internally. You report where the interface fails that person.
|
|
15
|
+
|
|
16
|
+
You are not the accessibility lens — assistive technology, contrast ratios, and
|
|
17
|
+
WCAG criteria belong there. You are not a visual-taste critic; "I would style this
|
|
18
|
+
differently" is not a finding.
|
|
19
|
+
|
|
20
|
+
## Standards this lens cites
|
|
21
|
+
|
|
22
|
+
- **Nielsen's ten usability heuristics** — cite the heuristic by name where one
|
|
23
|
+
applies (visibility of system status, match to the real world, user control and
|
|
24
|
+
freedom, consistency, error prevention, recognition over recall, flexibility,
|
|
25
|
+
minimalist design, error recovery, help).
|
|
26
|
+
|
|
27
|
+
The heuristics are a reference frame, not a checklist to recite. A finding that
|
|
28
|
+
maps to none of them still counts if you can describe the user failing.
|
|
29
|
+
|
|
30
|
+
## What you check
|
|
31
|
+
|
|
32
|
+
**System status**
|
|
33
|
+
- After an action, does the interface say what happened? A mutation with no
|
|
34
|
+
confirmation leaves the user unsure whether to retry.
|
|
35
|
+
- Is slow work distinguishable from broken work?
|
|
36
|
+
- Is a disabled control's reason discoverable, or does the user have to guess what
|
|
37
|
+
unlocks it?
|
|
38
|
+
|
|
39
|
+
**Destruction and reversibility**
|
|
40
|
+
- Irreversible actions without confirmation, and confirmations so frequent they
|
|
41
|
+
get clicked through.
|
|
42
|
+
- Destructive and routine actions adjacent enough to mis-tap.
|
|
43
|
+
- Work that can be lost by navigating away, refreshing, or being interrupted.
|
|
44
|
+
- Absence of undo where the action is reversible in principle.
|
|
45
|
+
|
|
46
|
+
**Error prevention and recovery**
|
|
47
|
+
- Validation that fires while typing, marking a half-entered value wrong.
|
|
48
|
+
- Errors that state what is invalid without stating what would be valid.
|
|
49
|
+
- Errors surfaced away from the field that caused them, or after the user has
|
|
50
|
+
moved on.
|
|
51
|
+
- A failed submission that discards the entered data.
|
|
52
|
+
|
|
53
|
+
**Interruption and re-entry**
|
|
54
|
+
- Can a multi-step flow be left and resumed, or does leaving restart it?
|
|
55
|
+
- Is progress through a long flow visible?
|
|
56
|
+
- Does the interface remember what the user already told it, or ask again?
|
|
57
|
+
|
|
58
|
+
**Recognition over recall**
|
|
59
|
+
- Codes, identifiers, or internal vocabulary shown where a human-readable label
|
|
60
|
+
belongs.
|
|
61
|
+
- A choice presented without the information needed to make it.
|
|
62
|
+
- Domain terms used inconsistently between screens for the same thing.
|
|
63
|
+
|
|
64
|
+
**Empty, first-run, and extreme states**
|
|
65
|
+
- Empty state: does it explain what goes here and how to add it, or show a blank
|
|
66
|
+
panel?
|
|
67
|
+
- Zero, one, and very many items — layouts that only work at the demo count.
|
|
68
|
+
- Long values: names, addresses, and titles that overflow, clip, or truncate the
|
|
69
|
+
part that distinguishes them.
|
|
70
|
+
- Are `0` and "not entered" visually distinct where the difference matters?
|
|
71
|
+
|
|
72
|
+
**Density and effort**
|
|
73
|
+
- Steps, clicks, or confirmations that do not earn their cost.
|
|
74
|
+
- Frequent actions buried; rare actions given prime position.
|
|
75
|
+
- Information needed together, split across screens.
|
|
76
|
+
|
|
77
|
+
## Project specifics
|
|
78
|
+
|
|
79
|
+
If the project documents interaction rules — a save pattern, a validation
|
|
80
|
+
convention, a toast or empty-state standard, a design system — read them from the
|
|
81
|
+
project's own conventions and enforce them as part of this lens. Deviation from a
|
|
82
|
+
written house pattern is a finding; deviation from your taste is not.
|
|
83
|
+
|
|
84
|
+
## Output
|
|
85
|
+
|
|
86
|
+
Findings only. One per line, no preamble, no summary:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
file:line — SEVERITY — [HEURISTIC] the user, the situation, what they cannot do — the fix
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`SEVERITY` is one of:
|
|
93
|
+
|
|
94
|
+
- `BLOCK` — the user loses work, cannot complete the task, or is misled about what
|
|
95
|
+
happened.
|
|
96
|
+
- `FIX` — real friction or a state the interface handles badly.
|
|
97
|
+
- `CONSIDER` — a refinement; say plainly that it is one.
|
|
98
|
+
|
|
99
|
+
Every finding describes a **person in a situation**, not an abstraction. "Poor
|
|
100
|
+
UX" is not a finding; "a user who mis-taps Delete on the row above loses the
|
|
101
|
+
record with no undo" is.
|
|
102
|
+
|
|
103
|
+
If nothing in scope is user-facing, return exactly `NO FINDINGS`.
|
|
104
|
+
|
|
105
|
+
Do not edit any file. This lens reports.
|
package/lib/baseline.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Baseline support: the difference between a panel a team keeps running and one
|
|
5
|
+
// they run once.
|
|
6
|
+
//
|
|
7
|
+
// The first run against an existing codebase returns hundreds of findings, and
|
|
8
|
+
// the report gets closed. A baseline records what was already there, so later
|
|
9
|
+
// runs report what the change introduced. Suppressed counts are always
|
|
10
|
+
// returned — a baseline that hides its own size is a way to make a codebase
|
|
11
|
+
// look clean by declaring its problems normal.
|
|
12
|
+
|
|
13
|
+
import { fingerprint } from './sarif.mjs';
|
|
14
|
+
|
|
15
|
+
export const BASELINE_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
export function toBaseline(findings, options = {}) {
|
|
18
|
+
const { note = null } = options;
|
|
19
|
+
const entries = (findings ?? []).map(f => ({
|
|
20
|
+
fingerprint: fingerprint(f),
|
|
21
|
+
file: f.file,
|
|
22
|
+
line: f.line,
|
|
23
|
+
severity: f.severity,
|
|
24
|
+
// Stored for human review of the baseline file, not used for matching.
|
|
25
|
+
issue: f.issue,
|
|
26
|
+
lenses: f.lenses
|
|
27
|
+
}));
|
|
28
|
+
entries.sort((a, b) =>
|
|
29
|
+
a.file.localeCompare(b.file) || a.line - b.line ||
|
|
30
|
+
a.fingerprint.localeCompare(b.fingerprint));
|
|
31
|
+
return {
|
|
32
|
+
version: BASELINE_VERSION,
|
|
33
|
+
note,
|
|
34
|
+
count: entries.length,
|
|
35
|
+
findings: entries
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function baselineFingerprints(baseline) {
|
|
40
|
+
if (!baseline) {
|
|
41
|
+
return new Set();
|
|
42
|
+
}
|
|
43
|
+
if (baseline.version !== BASELINE_VERSION) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Unsupported baseline version ${baseline.version}; expected ` +
|
|
46
|
+
`${BASELINE_VERSION}. Regenerate the baseline rather than editing it.`);
|
|
47
|
+
}
|
|
48
|
+
return new Set((baseline.findings ?? []).map(f => f.fingerprint));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Returns { findings, suppressed } — findings not present in the baseline, and
|
|
52
|
+
// the ones that were. Both are returned so the caller can report the second
|
|
53
|
+
// number instead of quietly dropping it.
|
|
54
|
+
export function filterAgainstBaseline(findings, baseline) {
|
|
55
|
+
const known = baselineFingerprints(baseline);
|
|
56
|
+
if (known.size === 0) {
|
|
57
|
+
return { findings: findings ?? [], suppressed: [] };
|
|
58
|
+
}
|
|
59
|
+
const fresh = [];
|
|
60
|
+
const suppressed = [];
|
|
61
|
+
for (const finding of findings ?? []) {
|
|
62
|
+
if (known.has(fingerprint(finding))) {
|
|
63
|
+
suppressed.push(finding);
|
|
64
|
+
} else {
|
|
65
|
+
fresh.push(finding);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { findings: fresh, suppressed };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Entries in the baseline that no longer appear in a run. Worth surfacing:
|
|
72
|
+
// either they were fixed and the baseline should shrink, or the lens that found
|
|
73
|
+
// them stopped running and coverage silently dropped.
|
|
74
|
+
export function staleBaselineEntries(baseline, findings) {
|
|
75
|
+
const current = new Set((findings ?? []).map(fingerprint));
|
|
76
|
+
return (baseline?.findings ?? [])
|
|
77
|
+
.filter(entry => !current.has(entry.fingerprint));
|
|
78
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Score a panel run against planted ground truth.
|
|
5
|
+
//
|
|
6
|
+
// Without this, a persona panel is unfalsifiable: you cannot tell whether it
|
|
7
|
+
// works, whether a new lens helped, or whether a prompt edit made it worse.
|
|
8
|
+
// Given a fixture whose defects are known (fixtures/calibration/expected.json)
|
|
9
|
+
// and the panel's own findings, this measures recall per lens, the false
|
|
10
|
+
// positive rate, and — the claim worth checking — whether consensus findings are
|
|
11
|
+
// actually more likely to be real than single-lens ones.
|
|
12
|
+
//
|
|
13
|
+
// This module scores an existing run. It does not run a panel and never
|
|
14
|
+
// fabricates one: if you have no panel output, you have no score.
|
|
15
|
+
|
|
16
|
+
import { normalizeSeverity } from './merge.mjs';
|
|
17
|
+
|
|
18
|
+
// A lens may reasonably anchor a finding on the offending statement, the
|
|
19
|
+
// enclosing function signature, or a line between them.
|
|
20
|
+
export function matchesDefect(finding, defect) {
|
|
21
|
+
if (finding.file !== defect.file) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const [lo, hi] = defect.span ?? [defect.line, defect.line];
|
|
25
|
+
return finding.line >= lo && finding.line <= hi;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findDefect(finding, defects) {
|
|
29
|
+
return (defects ?? []).find(d => matchesDefect(finding, d)) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A lens gets credit for a defect if it is the expected owner or listed in
|
|
33
|
+
// alsoAcceptedBy. Reporting a real defect outside your remit is not scored as a
|
|
34
|
+
// false positive — it is real — but it does not count toward the owner's recall.
|
|
35
|
+
export function lensIsCredited(lens, defect) {
|
|
36
|
+
return defect.expectedBy === lens ||
|
|
37
|
+
(defect.alsoAcceptedBy ?? []).includes(lens);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// findings: merged findings ([{file, line, severity, lenses, consensus,
|
|
41
|
+
// consensusScore, issue}]). expected: the parsed expected.json.
|
|
42
|
+
export function score(findings, expected) {
|
|
43
|
+
const defects = expected?.defects ?? [];
|
|
44
|
+
const list = findings ?? [];
|
|
45
|
+
|
|
46
|
+
const matchedByDefect = new Map();
|
|
47
|
+
const truePositives = [];
|
|
48
|
+
const falsePositives = [];
|
|
49
|
+
|
|
50
|
+
for (const finding of list) {
|
|
51
|
+
const defect = findDefect(finding, defects);
|
|
52
|
+
if (!defect) {
|
|
53
|
+
falsePositives.push(finding);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
truePositives.push({ finding, defect });
|
|
57
|
+
if (!matchedByDefect.has(defect.id)) {
|
|
58
|
+
matchedByDefect.set(defect.id, []);
|
|
59
|
+
}
|
|
60
|
+
matchedByDefect.get(defect.id).push(finding);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const missed = defects.filter(d => !matchedByDefect.has(d.id));
|
|
64
|
+
|
|
65
|
+
// Per-lens recall against the defects that lens is credited for.
|
|
66
|
+
const lenses = [...new Set(list.flatMap(f => f.lenses ?? []))].sort();
|
|
67
|
+
const perLens = {};
|
|
68
|
+
for (const lens of lenses) {
|
|
69
|
+
const owned = defects.filter(d => lensIsCredited(lens, d));
|
|
70
|
+
const found = owned.filter(d =>
|
|
71
|
+
(matchedByDefect.get(d.id) ?? []).some(f => (f.lenses ?? []).includes(lens)));
|
|
72
|
+
const reported = list.filter(f => (f.lenses ?? []).includes(lens));
|
|
73
|
+
const spurious = reported.filter(f => !findDefect(f, defects));
|
|
74
|
+
perLens[lens] = {
|
|
75
|
+
owned: owned.length,
|
|
76
|
+
found: found.length,
|
|
77
|
+
recall: owned.length === 0 ? null : round(found.length / owned.length),
|
|
78
|
+
reported: reported.length,
|
|
79
|
+
falsePositives: spurious.length,
|
|
80
|
+
precision: reported.length === 0
|
|
81
|
+
? null : round((reported.length - spurious.length) / reported.length)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Severity agreement on the defects that were found.
|
|
86
|
+
const severityMismatches = truePositives
|
|
87
|
+
.filter(({ finding, defect }) =>
|
|
88
|
+
normalizeSeverity(finding.severity) !== normalizeSeverity(defect.severity))
|
|
89
|
+
.map(({ finding, defect }) => ({
|
|
90
|
+
id: defect.id,
|
|
91
|
+
expected: normalizeSeverity(defect.severity),
|
|
92
|
+
reported: normalizeSeverity(finding.severity)
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// The load-bearing claim: is a finding several independent lenses agreed on
|
|
96
|
+
// more likely to be real? If these two precisions come out equal, consensus
|
|
97
|
+
// ranking is decoration.
|
|
98
|
+
const consensusFindings = list.filter(f => f.consensus === true);
|
|
99
|
+
const soloFindings = list.filter(f => f.consensus !== true);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
defects: defects.length,
|
|
103
|
+
found: matchedByDefect.size,
|
|
104
|
+
recall: defects.length === 0
|
|
105
|
+
? null : round(matchedByDefect.size / defects.length),
|
|
106
|
+
missed: missed.map(d => ({ id: d.id, expectedBy: d.expectedBy })),
|
|
107
|
+
reported: list.length,
|
|
108
|
+
falsePositives: falsePositives.length,
|
|
109
|
+
precision: list.length === 0
|
|
110
|
+
? null : round(truePositives.length / list.length),
|
|
111
|
+
severityMismatches,
|
|
112
|
+
consensusPrecision: precisionOf(consensusFindings, defects),
|
|
113
|
+
soloPrecision: precisionOf(soloFindings, defects),
|
|
114
|
+
consensusCount: consensusFindings.length,
|
|
115
|
+
soloCount: soloFindings.length,
|
|
116
|
+
perLens
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function precisionOf(findings, defects) {
|
|
121
|
+
if (findings.length === 0) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const real = findings.filter(f => findDefect(f, defects)).length;
|
|
125
|
+
return round(real / findings.length);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function round(n) {
|
|
129
|
+
return Number(n.toFixed(4));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatScore(result) {
|
|
133
|
+
const pct = v => v == null ? 'n/a' : `${(v * 100).toFixed(1)}%`;
|
|
134
|
+
const lines = [
|
|
135
|
+
`defects planted: ${result.defects}`,
|
|
136
|
+
`defects found: ${result.found} (recall ${pct(result.recall)})`,
|
|
137
|
+
`findings reported: ${result.reported} (precision ${pct(result.precision)})`,
|
|
138
|
+
`false positives: ${result.falsePositives}`,
|
|
139
|
+
''
|
|
140
|
+
];
|
|
141
|
+
if (result.missed.length) {
|
|
142
|
+
lines.push('missed:');
|
|
143
|
+
for (const m of result.missed) {
|
|
144
|
+
lines.push(` - ${m.id} (expected from ${m.expectedBy})`);
|
|
145
|
+
}
|
|
146
|
+
lines.push('');
|
|
147
|
+
}
|
|
148
|
+
if (result.severityMismatches.length) {
|
|
149
|
+
lines.push('severity mismatches:');
|
|
150
|
+
for (const s of result.severityMismatches) {
|
|
151
|
+
lines.push(` - ${s.id}: expected ${s.expected}, reported ${s.reported}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
}
|
|
155
|
+
lines.push(
|
|
156
|
+
'consensus vs solo precision — if these are equal, consensus ranking is',
|
|
157
|
+
'decoration and should be dropped or reweighted:',
|
|
158
|
+
` consensus (${result.consensusCount}): ${pct(result.consensusPrecision)}`,
|
|
159
|
+
` solo (${result.soloCount}): ${pct(result.soloPrecision)}`,
|
|
160
|
+
'',
|
|
161
|
+
'per lens:');
|
|
162
|
+
for (const [lens, s] of Object.entries(result.perLens)) {
|
|
163
|
+
lines.push(
|
|
164
|
+
` ${lens.padEnd(16)} recall ${pct(s.recall).padStart(6)} ` +
|
|
165
|
+
`(${s.found}/${s.owned}) precision ${pct(s.precision).padStart(6)} ` +
|
|
166
|
+
`(${s.falsePositives} fp of ${s.reported})`);
|
|
167
|
+
}
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
package/lib/corpus.mjs
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2026 Joel Mangin. MIT License.
|
|
3
|
+
*/
|
|
4
|
+
// Adapter for externally authored defect corpora, and file-level scoring.
|
|
5
|
+
//
|
|
6
|
+
// The built-in fixture scores by line span, which works when you planted the
|
|
7
|
+
// defects and know where they are. External corpora label a whole test case
|
|
8
|
+
// instead, and — the reason they matter — they label cases where the code looks
|
|
9
|
+
// vulnerable and is not. Those are the false-positive opportunities a
|
|
10
|
+
// self-authored fixture cannot honestly provide, because the same hand wrote the
|
|
11
|
+
// decoys, the answer key, and the lens prompts.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here vendors a corpus. It reads one that has been fetched locally.
|
|
14
|
+
|
|
15
|
+
// OWASP Benchmark categories mapped to the CWE they carry and the vocabulary a
|
|
16
|
+
// finding would use. A finding counts as matching a case only if it cites the
|
|
17
|
+
// CWE number or this vocabulary — a lens reporting some unrelated real issue in
|
|
18
|
+
// a safe case is neither credited nor penalised.
|
|
19
|
+
export const OWASP_CATEGORIES = {
|
|
20
|
+
sqli: { cwe: 89, terms: ['sql injection', 'sqli', 'sql statement', 'prepared statement', 'parameteriz', 'query concatenat'] },
|
|
21
|
+
weakrand: { cwe: 330, terms: ['weak random', 'insecure random', 'predictable', 'java.util.random', 'securerandom', 'insufficient entropy'] },
|
|
22
|
+
xss: { cwe: 79, terms: ['cross-site scripting', 'xss', 'html escap', 'output encod', 'unescaped'] },
|
|
23
|
+
pathtraver: { cwe: 22, terms: ['path traversal', 'directory traversal', 'file path', 'canonicaliz', '../'] },
|
|
24
|
+
cmdi: { cwe: 78, terms: ['command injection', 'cmdi', 'os command', 'runtime.exec', 'processbuilder', 'shell'] },
|
|
25
|
+
crypto: { cwe: 327, terms: ['weak cipher', 'broken cipher', 'insecure crypto', 'des', 'ecb', 'weak encryption', 'broken crypto'] },
|
|
26
|
+
hash: { cwe: 328, terms: ['weak hash', 'broken hash', 'md5', 'sha1', 'sha-1', 'insecure hash'] },
|
|
27
|
+
trustbound: { cwe: 501, terms: ['trust boundary', 'session attribute', 'untrusted data stored', 'trust violation'] },
|
|
28
|
+
securecookie: { cwe: 614, terms: ['secure flag', 'secure cookie', 'cookie without secure', 'httponly', 'insecure cookie'] },
|
|
29
|
+
ldapi: { cwe: 90, terms: ['ldap injection', 'ldapi', 'ldap filter', 'ldap query'] },
|
|
30
|
+
xpathi: { cwe: 643, terms: ['xpath injection', 'xpathi', 'xpath expression', 'xpath query'] }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// `# comment` header, then: testname,category,realVulnerability,cwe
|
|
34
|
+
export function parseExpectedResults(csv) {
|
|
35
|
+
const cases = [];
|
|
36
|
+
for (const line of String(csv ?? '').split(/\r?\n/)) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const [name, category, real, cwe] = trimmed.split(',').map(f => f.trim());
|
|
42
|
+
if (!name || !category || !real) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (real !== 'true' && real !== 'false') {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`unexpected label "${real}" for ${name}; expected true or false`);
|
|
48
|
+
}
|
|
49
|
+
cases.push({
|
|
50
|
+
name,
|
|
51
|
+
category,
|
|
52
|
+
vulnerable: real === 'true',
|
|
53
|
+
cwe: Number(cwe)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (cases.length === 0) {
|
|
57
|
+
throw new Error('no cases parsed — is this the expectedresults CSV?');
|
|
58
|
+
}
|
|
59
|
+
return cases;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The pre-registered sampling rule: for each category, the first N `true` and
|
|
63
|
+
// first N `false` cases by ascending test number. Mechanical and reproducible —
|
|
64
|
+
// no hand-picking, and re-running it on the same corpus yields the same sample.
|
|
65
|
+
export function sampleCases(cases, perLabel = 3) {
|
|
66
|
+
const byCategory = new Map();
|
|
67
|
+
for (const c of cases ?? []) {
|
|
68
|
+
if (!byCategory.has(c.category)) {
|
|
69
|
+
byCategory.set(c.category, []);
|
|
70
|
+
}
|
|
71
|
+
byCategory.get(c.category).push(c);
|
|
72
|
+
}
|
|
73
|
+
const sample = [];
|
|
74
|
+
const shortfalls = [];
|
|
75
|
+
for (const category of [...byCategory.keys()].sort()) {
|
|
76
|
+
const group = [...byCategory.get(category)]
|
|
77
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
78
|
+
for (const vulnerable of [true, false]) {
|
|
79
|
+
const matching = group.filter(c => c.vulnerable === vulnerable);
|
|
80
|
+
const taken = matching.slice(0, perLabel);
|
|
81
|
+
sample.push(...taken);
|
|
82
|
+
if (taken.length < perLabel) {
|
|
83
|
+
shortfalls.push({
|
|
84
|
+
category, vulnerable, wanted: perLabel, available: taken.length
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { sample, shortfalls };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Does this finding claim the vulnerability the case is about? Citing the CWE
|
|
93
|
+
// number is decisive; otherwise the category vocabulary has to appear.
|
|
94
|
+
export function findingMatchesCase(finding, testCase, categories = OWASP_CATEGORIES) {
|
|
95
|
+
const meta = (categories ?? OWASP_CATEGORIES)[testCase.category];
|
|
96
|
+
const text = `${finding.issue ?? ''} ${finding.fix ?? ''}`.toLowerCase();
|
|
97
|
+
const cwe = meta?.cwe ?? testCase.cwe;
|
|
98
|
+
if (cwe && new RegExp(`cwe[- ]?0*${cwe}\\b`, 'i').test(text)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return (meta?.terms ?? []).some(term => text.includes(term));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// findings must already be merged (so `lenses` and `consensus` are populated).
|
|
105
|
+
// Returns per-case classification plus the aggregate the claim turns on.
|
|
106
|
+
export function scoreCases(results, options = {}) {
|
|
107
|
+
const { onlyMatching = true } = options;
|
|
108
|
+
const perCase = [];
|
|
109
|
+
let truePositives = 0;
|
|
110
|
+
let falsePositives = 0;
|
|
111
|
+
let missed = 0;
|
|
112
|
+
let declined = 0;
|
|
113
|
+
let unrelated = 0;
|
|
114
|
+
const matchedFindings = [];
|
|
115
|
+
|
|
116
|
+
for (const { testCase, findings } of results ?? []) {
|
|
117
|
+
const matching = (findings ?? []).filter(f =>
|
|
118
|
+
findingMatchesCase(f, testCase));
|
|
119
|
+
const others = (findings ?? []).length - matching.length;
|
|
120
|
+
unrelated += others;
|
|
121
|
+
matchedFindings.push(...matching.map(f => ({ finding: f, testCase })));
|
|
122
|
+
|
|
123
|
+
let outcome;
|
|
124
|
+
if (testCase.vulnerable) {
|
|
125
|
+
outcome = matching.length > 0 ? 'true-positive' : 'missed';
|
|
126
|
+
if (matching.length > 0) {
|
|
127
|
+
truePositives += 1;
|
|
128
|
+
} else {
|
|
129
|
+
missed += 1;
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
outcome = matching.length > 0 ? 'false-positive' : 'declined';
|
|
133
|
+
if (matching.length > 0) {
|
|
134
|
+
falsePositives += 1;
|
|
135
|
+
} else {
|
|
136
|
+
declined += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
perCase.push({
|
|
140
|
+
name: testCase.name,
|
|
141
|
+
category: testCase.category,
|
|
142
|
+
vulnerable: testCase.vulnerable,
|
|
143
|
+
outcome,
|
|
144
|
+
matched: matching.length,
|
|
145
|
+
unrelated: others,
|
|
146
|
+
lenses: [...new Set(matching.flatMap(f => f.lenses ?? []))]
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The comparison the whole ranking rests on. A matched finding in a
|
|
151
|
+
// `false`-labeled case is wrong; in a `true`-labeled case it is right. So
|
|
152
|
+
// precision can be computed separately for consensus and solo findings.
|
|
153
|
+
const pool = onlyMatching ? matchedFindings : matchedFindings;
|
|
154
|
+
const consensus = pool.filter(m => m.finding.consensus === true);
|
|
155
|
+
const solo = pool.filter(m => m.finding.consensus !== true);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
cases: perCase.length,
|
|
159
|
+
truePositives,
|
|
160
|
+
falsePositives,
|
|
161
|
+
missed,
|
|
162
|
+
declined,
|
|
163
|
+
unrelatedFindings: unrelated,
|
|
164
|
+
recall: (truePositives + missed) === 0
|
|
165
|
+
? null : round(truePositives / (truePositives + missed)),
|
|
166
|
+
specificity: (falsePositives + declined) === 0
|
|
167
|
+
? null : round(declined / (falsePositives + declined)),
|
|
168
|
+
consensusPrecision: precisionOf(consensus),
|
|
169
|
+
soloPrecision: precisionOf(solo),
|
|
170
|
+
consensusCount: consensus.length,
|
|
171
|
+
soloCount: solo.length,
|
|
172
|
+
perCase
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function precisionOf(matched) {
|
|
177
|
+
if (matched.length === 0) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const correct = matched.filter(m => m.testCase.vulnerable).length;
|
|
181
|
+
return round(correct / matched.length);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Case-level agreement.
|
|
185
|
+
//
|
|
186
|
+
// On a corpus labeled per case, the meaningful unit of agreement is "two lenses
|
|
187
|
+
// independently flagged this case", not "their line anchors happened to cluster
|
|
188
|
+
// within three lines". Two lenses can describe the same vulnerability from
|
|
189
|
+
// different anchors in a 70-line servlet — one at the sink, one at the source —
|
|
190
|
+
// and line-proximity clustering would score that as two solo findings.
|
|
191
|
+
//
|
|
192
|
+
// results: [{testCase, findings: [{lens, line, severity, issue, fix}]}]
|
|
193
|
+
export function scoreCaseAgreement(results, options = {}) {
|
|
194
|
+
const { categories } = options;
|
|
195
|
+
const detections = [];
|
|
196
|
+
const perCase = [];
|
|
197
|
+
|
|
198
|
+
for (const { testCase, findings } of results ?? []) {
|
|
199
|
+
const matching = (findings ?? []).filter(f =>
|
|
200
|
+
findingMatchesCase(f, testCase, categories));
|
|
201
|
+
const lenses = [...new Set(matching.map(f => f.lens))].sort();
|
|
202
|
+
const outcome = lenses.length === 0
|
|
203
|
+
? (testCase.vulnerable ? 'missed' : 'declined')
|
|
204
|
+
: (testCase.vulnerable ? 'true-positive' : 'false-positive');
|
|
205
|
+
perCase.push({
|
|
206
|
+
name: testCase.name,
|
|
207
|
+
category: testCase.category,
|
|
208
|
+
vulnerable: testCase.vulnerable,
|
|
209
|
+
outcome,
|
|
210
|
+
lenses,
|
|
211
|
+
unrelated: (findings ?? []).length - matching.length
|
|
212
|
+
});
|
|
213
|
+
if (lenses.length > 0) {
|
|
214
|
+
detections.push({
|
|
215
|
+
name: testCase.name,
|
|
216
|
+
vulnerable: testCase.vulnerable,
|
|
217
|
+
lenses,
|
|
218
|
+
consensus: lenses.length > 1
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const consensus = detections.filter(d => d.consensus);
|
|
224
|
+
const solo = detections.filter(d => !d.consensus);
|
|
225
|
+
const precision = group => group.length === 0
|
|
226
|
+
? null
|
|
227
|
+
: round(group.filter(d => d.vulnerable).length / group.length);
|
|
228
|
+
|
|
229
|
+
const truePositives = perCase.filter(c => c.outcome === 'true-positive').length;
|
|
230
|
+
const falsePositives = perCase.filter(c => c.outcome === 'false-positive').length;
|
|
231
|
+
const missed = perCase.filter(c => c.outcome === 'missed').length;
|
|
232
|
+
const declined = perCase.filter(c => c.outcome === 'declined').length;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
cases: perCase.length,
|
|
236
|
+
truePositives,
|
|
237
|
+
falsePositives,
|
|
238
|
+
missed,
|
|
239
|
+
declined,
|
|
240
|
+
recall: (truePositives + missed) === 0
|
|
241
|
+
? null : round(truePositives / (truePositives + missed)),
|
|
242
|
+
specificity: (falsePositives + declined) === 0
|
|
243
|
+
? null : round(declined / (falsePositives + declined)),
|
|
244
|
+
detections: detections.length,
|
|
245
|
+
consensusCount: consensus.length,
|
|
246
|
+
soloCount: solo.length,
|
|
247
|
+
consensusPrecision: precision(consensus),
|
|
248
|
+
soloPrecision: precision(solo),
|
|
249
|
+
unrelatedFindings: perCase.reduce((n, c) => n + c.unrelated, 0),
|
|
250
|
+
perCase
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Measured overlap between two lenses on this corpus: the Jaccard index of the
|
|
255
|
+
// case sets each one flagged. Feeds the independence weighting, and answers
|
|
256
|
+
// whether a second lens in the same domain adds anything.
|
|
257
|
+
export function lensCaseOverlap(results, options = {}) {
|
|
258
|
+
const { categories } = options;
|
|
259
|
+
const byLens = new Map();
|
|
260
|
+
for (const { testCase, findings } of results ?? []) {
|
|
261
|
+
for (const f of findings ?? []) {
|
|
262
|
+
if (!findingMatchesCase(f, testCase, categories)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!byLens.has(f.lens)) {
|
|
266
|
+
byLens.set(f.lens, new Set());
|
|
267
|
+
}
|
|
268
|
+
byLens.get(f.lens).add(testCase.name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const lenses = [...byLens.keys()].sort();
|
|
272
|
+
const overlap = {};
|
|
273
|
+
for (let i = 0; i < lenses.length; i++) {
|
|
274
|
+
for (let j = i + 1; j < lenses.length; j++) {
|
|
275
|
+
const a = byLens.get(lenses[i]);
|
|
276
|
+
const b = byLens.get(lenses[j]);
|
|
277
|
+
const union = new Set([...a, ...b]);
|
|
278
|
+
if (union.size === 0) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
let shared = 0;
|
|
282
|
+
for (const name of a) {
|
|
283
|
+
if (b.has(name)) {
|
|
284
|
+
shared += 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
overlap[`${lenses[i]}|${lenses[j]}`] = round(shared / union.size);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return overlap;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function round(n) {
|
|
294
|
+
return Number(n.toFixed(4));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function formatCaseScore(result) {
|
|
298
|
+
const pct = v => v == null ? 'n/a' : `${(v * 100).toFixed(1)}%`;
|
|
299
|
+
const lines = [
|
|
300
|
+
`cases scored: ${result.cases}`,
|
|
301
|
+
`vulnerable found: ${result.truePositives} (recall ${pct(result.recall)})`,
|
|
302
|
+
`vulnerable missed: ${result.missed}`,
|
|
303
|
+
`safe declined: ${result.declined} (specificity ${pct(result.specificity)})`,
|
|
304
|
+
`FALSE POSITIVES: ${result.falsePositives}`,
|
|
305
|
+
`unrelated findings: ${result.unrelatedFindings} (neither credited nor penalised)`,
|
|
306
|
+
'',
|
|
307
|
+
'consensus vs solo precision — the claim under test:',
|
|
308
|
+
` consensus (${result.consensusCount}): ${pct(result.consensusPrecision)}`,
|
|
309
|
+
` solo (${result.soloCount}): ${pct(result.soloPrecision)}`,
|
|
310
|
+
''
|
|
311
|
+
];
|
|
312
|
+
if (result.falsePositives < 3) {
|
|
313
|
+
lines.push(
|
|
314
|
+
'Fewer than 3 false positives — per the pre-registered criteria this is',
|
|
315
|
+
'INCONCLUSIVE regardless of which precision is higher.', '');
|
|
316
|
+
} else if (result.consensusPrecision != null && result.soloPrecision != null) {
|
|
317
|
+
const delta = result.consensusPrecision - result.soloPrecision;
|
|
318
|
+
lines.push(
|
|
319
|
+
`consensus advantage: ${(delta * 100).toFixed(1)} points`,
|
|
320
|
+
delta >= 0.1 ? 'Criteria met: claim SUPPORTED.'
|
|
321
|
+
: delta <= 0 ? 'Criteria met: claim REFUTED.'
|
|
322
|
+
: 'Between thresholds: INCONCLUSIVE.', '');
|
|
323
|
+
}
|
|
324
|
+
const byCategory = new Map();
|
|
325
|
+
for (const c of result.perCase) {
|
|
326
|
+
if (!byCategory.has(c.category)) {
|
|
327
|
+
byCategory.set(c.category, { tp: 0, fp: 0, missed: 0, declined: 0 });
|
|
328
|
+
}
|
|
329
|
+
const bucket = byCategory.get(c.category);
|
|
330
|
+
if (c.outcome === 'true-positive') bucket.tp += 1;
|
|
331
|
+
else if (c.outcome === 'false-positive') bucket.fp += 1;
|
|
332
|
+
else if (c.outcome === 'missed') bucket.missed += 1;
|
|
333
|
+
else bucket.declined += 1;
|
|
334
|
+
}
|
|
335
|
+
lines.push('per category (tp / missed / fp / declined):');
|
|
336
|
+
for (const [category, b] of [...byCategory.entries()].sort()) {
|
|
337
|
+
lines.push(` ${category.padEnd(14)} ${b.tp} / ${b.missed} / ${b.fp} / ${b.declined}`);
|
|
338
|
+
}
|
|
339
|
+
return lines.join('\n');
|
|
340
|
+
}
|