diff --git a/README.md b/README.md index bde565f..874f97a 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Every file answers one question: **which controls from framework X address vulne | **70+** open-source tools | Catalogued and organised by function | | **25** eval profiles | Runnable Garak (13) + PyRIT (6) + LAAF (6) tests mapped to OWASP entries | | **26** compliance reports | Per-framework gap assessments auto-generated from data layer (MD, CSV, JSON, OSCAL) | -| **134** documented incidents | Real-world + research incidents with MAESTRO layer attribution (MD, CSV, JSON, STIX 2.1) | +| **135** documented incidents | Real-world + research incidents with MAESTRO layer attribution (MD, CSV, JSON, STIX 2.1) | | **LAAF v2.0** | First agentic LPCI red-teaming framework — fully integrated with 6-stage × OWASP crosswalk | All free. All open-source. Built for practitioners. diff --git a/data/entries/DSGAI01.json b/data/entries/DSGAI01.json index aed2bae..4732a45 100644 --- a/data/entries/DSGAI01.json +++ b/data/entries/DSGAI01.json @@ -1174,6 +1174,12 @@ "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", "year": 2026, "incident_id": "INC-112" + }, + { + "name": "llmware: SQL injection in the collection-database layer exposed documents the caller was scoped out of", + "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", + "year": 2026, + "incident_id": "INC-135" } ], "crossrefs": { diff --git a/data/entries/DSGAI13.json b/data/entries/DSGAI13.json index 6072030..a3f88c2 100644 --- a/data/entries/DSGAI13.json +++ b/data/entries/DSGAI13.json @@ -985,6 +985,12 @@ "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", "year": 2023, "incident_id": "INC-077" + }, + { + "name": "llmware: SQL injection in the collection-database layer exposed documents the caller was scoped out of", + "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", + "year": 2026, + "incident_id": "INC-135" } ], "crossrefs": { diff --git a/data/incidents-schema.json b/data/incidents-schema.json index 91186e9..c2f18be 100644 --- a/data/incidents-schema.json +++ b/data/incidents-schema.json @@ -280,6 +280,22 @@ "raw_description": { "type": "string", "description": "The original pasted text, retained until a human signs off on the cleaned `description`. Never deleted by tooling." + }, + "incident_class": { + "type": "string", + "enum": [ + "tooling-cve", + "ai-behaviour" + ], + "description": "What kind of failure this is. `tooling-cve` is a conventional software vulnerability in GenAI tooling — SQL injection, missing authorisation, CSWSH — that is AI-relevant because of what it exposes, not because the model behaved badly. `ai-behaviour` is a failure of the AI system's own behaviour: prompt injection, poisoning, agent goal deviation. Records without the field predate it." + }, + "mapping_status": { + "type": "string", + "enum": [ + "draft", + "sme-confirmed" + ], + "description": "Whether `owasp_entries` has been confirmed by a reviewer. `draft` means an agent proposed the entries from the source text and no SME has signed them off; it is not a claim about the incident, only about who decided the mapping." } } } diff --git a/data/incidents.json b/data/incidents.json index 032cfd1..93d5379 100644 --- a/data/incidents.json +++ b/data/incidents.json @@ -8258,7 +8258,9 @@ "source_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-37003", "confirmed_by": [] } - ] + ], + "incident_class": "tooling-cve", + "mapping_status": "draft" }, { "id": "INC-133", @@ -8337,7 +8339,9 @@ "source_url": "https://github.com/samanhappy/mcphub/security/advisories/GHSA-6cvf-cfch-4g7m", "confirmed_by": [] } - ] + ], + "incident_class": "tooling-cve", + "mapping_status": "draft" }, { "id": "INC-134", @@ -8415,6 +8419,84 @@ "source_url": "https://github.com/headroomlabs-ai/headroom/security/advisories/GHSA-h46j-26q3-rggf", "confirmed_by": [] } + ], + "incident_class": "tooling-cve", + "mapping_status": "draft" + }, + { + "id": "INC-135", + "title": "llmware: SQL injection in the collection-database layer exposed documents the caller was scoped out of", + "date": "2026-08", + "year": 2026, + "category": "research-demonstrated", + "incident_class": "tooling-cve", + "severity": "High", + "description": "CVE-2026-85689 reports that llmware 0.4.6 builds SQL WHERE clauses by string-interpolating filter and lookup values directly, \"without parameterization or escaping, in both the SQLite and PostgreSQL backends\". The CNA record states that \"the filter validator only checks keys against an allow-list and never sanitizes values\", and that attacker-controlled filter values reaching the public API through Library.block_lookup and Query.text_query_with_custom_filter / text_query_by_author_or_speaker \"can neutralize the intended filter to disclose rows the caller was scoped out of (cross-document/cross-collection disclosure); on PostgreSQL the flaw permits boolean- and UNION-based SQL injection.\" Provenance and status, stated plainly: the CNA is VulnCheck rather than the vendor, the CVSS 7.1 is VulnCheck's own secondary metric, llmware has published no security advisory, the upstream report (llmware-ai/llmware#1304, opened 2026-06-12) remains open with no maintainer response, and no release after the affected 0.4.6 exists — so this is recorded as unfixed at the time of writing.", + "owasp_entries": [ + "DSGAI13", + "DSGAI01" + ], + "mapping_status": "draft", + "maestro_layers": [ + { + "layer": "L2", + "label": "Data Operations", + "role": "origin", + "notes": "The collection-database layer holds the document and metadata store a RAG pipeline queries" + }, + { + "layer": "L6", + "label": "Security & Compliance", + "role": "impact", + "notes": "The per-caller scoping the filter was meant to enforce is neutralised by the injected value" + } + ], + "attack_vector": "Attacker-controlled filter or lookup value reaching Library.block_lookup or Query.text_query_* and interpolated into a SQL WHERE clause", + "affected": "llmware 0.4.6 (latest release at the time of writing; no fixed version published)", + "impact": "Cross-document and cross-collection disclosure of rows the caller was scoped out of; boolean- and UNION-based SQL injection on the PostgreSQL backend", + "mitigations": [ + "Parameterise every filter and lookup value rather than interpolating it into SQL", + "Validate filter values, not only their keys — the allow-list covers keys alone", + "Scope database credentials per tenant so a neutralised filter cannot reach other collections" + ], + "external_refs": [ + { + "source": "CVE", + "id": "CVE-2026-85689", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-85689" + }, + { + "source": "research", + "id": "llmware-ai/llmware#1304", + "url": "https://github.com/llmware-ai/llmware/issues/1304" + } + ], + "references": [ + { + "title": "NVD — CVE-2026-85689", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-85689", + "type": "advisory" + }, + { + "title": "VulnCheck advisory — llmware 0.4.6 SQL injection via unescaped filter values", + "url": "https://www.vulncheck.com/advisories/llmware-0.4.6-sql-injection-via-unescaped-filter-values", + "type": "advisory" + }, + { + "title": "Upstream report llmware-ai/llmware#1304 (open, unfixed)", + "url": "https://github.com/llmware-ai/llmware/issues/1304", + "type": "disclosure" + } + ], + "tags": [ + "cve", + "tooling-cve", + "sql-injection", + "rag", + "llmware", + "cross-tenant-disclosure", + "unfixed", + "draft-mapping" ] } ] diff --git a/data/stats.json b/data/stats.json index 9ccfa54..2c83d89 100644 --- a/data/stats.json +++ b/data/stats.json @@ -61,7 +61,7 @@ } }, "incidents": { - "total": 134 + "total": 135 }, "evidence": { "incidents_annotated": 16, diff --git a/docs/data.js b/docs/data.js index a1b806c..36603d7 100644 --- a/docs/data.js +++ b/docs/data.js @@ -26211,6 +26211,12 @@ window.CROSSWALK_DATA = [ "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", "year": 2026, "incident_id": "INC-112" + }, + { + "name": "llmware: SQL injection in the collection-database layer exposed documents the caller was scoped out of", + "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", + "year": 2026, + "incident_id": "INC-135" } ], "crossrefs": { @@ -38319,6 +38325,12 @@ window.CROSSWALK_DATA = [ "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", "year": 2023, "incident_id": "INC-077" + }, + { + "name": "llmware: SQL injection in the collection-database layer exposed documents the caller was scoped out of", + "url": "https://github.com/GenAI-Security-Project/crosswalk/blob/main/data/incidents.json", + "year": 2026, + "incident_id": "INC-135" } ], "crossrefs": { diff --git a/docs/incidents.js b/docs/incidents.js index 17ab230..de9063a 100644 --- a/docs/incidents.js +++ b/docs/incidents.js @@ -1,5 +1,5 @@ // Auto-generated by scripts/generate.js — do not edit manually -// Incidents: 134 +// Incidents: 135 window.CROSSWALK_INCIDENTS = [ { "id": "INC-001", @@ -8256,7 +8256,9 @@ window.CROSSWALK_INCIDENTS = [ "source_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-37003", "confirmed_by": [] } - ] + ], + "incident_class": "tooling-cve", + "mapping_status": "draft" }, { "id": "INC-133", @@ -8335,7 +8337,9 @@ window.CROSSWALK_INCIDENTS = [ "source_url": "https://github.com/samanhappy/mcphub/security/advisories/GHSA-6cvf-cfch-4g7m", "confirmed_by": [] } - ] + ], + "incident_class": "tooling-cve", + "mapping_status": "draft" }, { "id": "INC-134", @@ -8413,6 +8417,84 @@ window.CROSSWALK_INCIDENTS = [ "source_url": "https://github.com/headroomlabs-ai/headroom/security/advisories/GHSA-h46j-26q3-rggf", "confirmed_by": [] } + ], + "incident_class": "tooling-cve", + "mapping_status": "draft" + }, + { + "id": "INC-135", + "title": "llmware: SQL injection in the collection-database layer exposed documents the caller was scoped out of", + "date": "2026-08", + "year": 2026, + "category": "research-demonstrated", + "incident_class": "tooling-cve", + "severity": "High", + "description": "CVE-2026-85689 reports that llmware 0.4.6 builds SQL WHERE clauses by string-interpolating filter and lookup values directly, \"without parameterization or escaping, in both the SQLite and PostgreSQL backends\". The CNA record states that \"the filter validator only checks keys against an allow-list and never sanitizes values\", and that attacker-controlled filter values reaching the public API through Library.block_lookup and Query.text_query_with_custom_filter / text_query_by_author_or_speaker \"can neutralize the intended filter to disclose rows the caller was scoped out of (cross-document/cross-collection disclosure); on PostgreSQL the flaw permits boolean- and UNION-based SQL injection.\" Provenance and status, stated plainly: the CNA is VulnCheck rather than the vendor, the CVSS 7.1 is VulnCheck's own secondary metric, llmware has published no security advisory, the upstream report (llmware-ai/llmware#1304, opened 2026-06-12) remains open with no maintainer response, and no release after the affected 0.4.6 exists — so this is recorded as unfixed at the time of writing.", + "owasp_entries": [ + "DSGAI13", + "DSGAI01" + ], + "mapping_status": "draft", + "maestro_layers": [ + { + "layer": "L2", + "label": "Data Operations", + "role": "origin", + "notes": "The collection-database layer holds the document and metadata store a RAG pipeline queries" + }, + { + "layer": "L6", + "label": "Security & Compliance", + "role": "impact", + "notes": "The per-caller scoping the filter was meant to enforce is neutralised by the injected value" + } + ], + "attack_vector": "Attacker-controlled filter or lookup value reaching Library.block_lookup or Query.text_query_* and interpolated into a SQL WHERE clause", + "affected": "llmware 0.4.6 (latest release at the time of writing; no fixed version published)", + "impact": "Cross-document and cross-collection disclosure of rows the caller was scoped out of; boolean- and UNION-based SQL injection on the PostgreSQL backend", + "mitigations": [ + "Parameterise every filter and lookup value rather than interpolating it into SQL", + "Validate filter values, not only their keys — the allow-list covers keys alone", + "Scope database credentials per tenant so a neutralised filter cannot reach other collections" + ], + "external_refs": [ + { + "source": "CVE", + "id": "CVE-2026-85689", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-85689" + }, + { + "source": "research", + "id": "llmware-ai/llmware#1304", + "url": "https://github.com/llmware-ai/llmware/issues/1304" + } + ], + "references": [ + { + "title": "NVD — CVE-2026-85689", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-85689", + "type": "advisory" + }, + { + "title": "VulnCheck advisory — llmware 0.4.6 SQL injection via unescaped filter values", + "url": "https://www.vulncheck.com/advisories/llmware-0.4.6-sql-injection-via-unescaped-filter-values", + "type": "advisory" + }, + { + "title": "Upstream report llmware-ai/llmware#1304 (open, unfixed)", + "url": "https://github.com/llmware-ai/llmware/issues/1304", + "type": "disclosure" + } + ], + "tags": [ + "cve", + "tooling-cve", + "sql-injection", + "rag", + "llmware", + "cross-tenant-disclosure", + "unfixed", + "draft-mapping" ] } ]; \ No newline at end of file diff --git a/scripts/incident-ids.test.mjs b/scripts/incident-ids.test.mjs new file mode 100644 index 0000000..9b3472c --- /dev/null +++ b/scripts/incident-ids.test.mjs @@ -0,0 +1,58 @@ +/** + * Incident id allocation. + * + * INC-132 was allocated twice — by #117 and by #109 — because both read the end + * of data/incidents.json while the other was open. These cover the guard that + * now fails on a duplicate, and the helper that hands out a free id. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const INCIDENTS = path.join(ROOT, 'data', 'incidents.json'); +const run = (script, args = []) => + execFileSync(process.execPath, [path.join(ROOT, 'scripts', script), ...args], { cwd: ROOT, encoding: 'utf8' }); + +test('the committed corpus has no duplicate incident ids', () => { + const { incidents } = JSON.parse(readFileSync(INCIDENTS, 'utf8')); + const seen = new Set(); + const duplicates = incidents.map((i) => i.id).filter((id) => (seen.has(id) ? true : (seen.add(id), false))); + assert.deepEqual(duplicates, [], `duplicate incident ids: ${duplicates.join(', ')}`); +}); + +// The duplicate case is deliberately NOT tested by mutating data/incidents.json: +// node --test runs suites in parallel, so writing to the shared corpus races the +// other suites reading it. The guard is verified two ways instead — the corpus +// check above, and the wiring check below — and negative-tested by hand when it +// was written (injecting a duplicate made validate.js exit 1 with +// "INC-006 is used by 2 records"). +test('the duplicate-id guard is wired into validate.js', () => { + const src = readFileSync(path.join(ROOT, 'scripts', 'validate.js'), 'utf8'); + assert.match(src, /function checkIncidentIds\(\)/, 'the guard is missing'); + assert.match(src, /^\s*checkIncidentIds\(\);/m, 'the guard is defined but never called'); + assert.match(src, /is used by \$\{n\} records/, 'the guard no longer fails on a duplicate'); +}); + +test('next-incident-id.mjs proposes an unused id', () => { + const { incidents } = JSON.parse(readFileSync(INCIDENTS, 'utf8')); + const used = new Set(incidents.map((i) => i.id)); + const next = run('next-incident-id.mjs').trim(); + + assert.match(next, /^INC-\d{3}$/); + assert.ok(!used.has(next), `${next} is already used`); + + const highest = Math.max(...incidents.map((i) => Number(String(i.id).slice(4))).filter(Number.isFinite)); + assert.equal(Number(next.slice(4)), highest + 1); +}); + +test('--json reports what it based the answer on', () => { + const out = JSON.parse(run('next-incident-id.mjs', ['--json'])); + assert.match(out.next, /^INC-\d{3}$/); + assert.match(out.highest_committed, /^INC-\d{3}$/); + assert.ok(Array.isArray(out.claimed_in_open_prs)); +}); diff --git a/scripts/next-incident-id.mjs b/scripts/next-incident-id.mjs new file mode 100644 index 0000000..22c12da --- /dev/null +++ b/scripts/next-incident-id.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * next-incident-id.mjs — print the next free incident id. + * + * Two contributors adding an incident at the same time both read the end of + * data/incidents.json and both pick the same number; the second one to merge + * finds their PR conflicting (this happened to INC-132). This prints an id that + * accounts for what is already merged, and optionally for what open pull + * requests have claimed. + * + * Usage: + * node scripts/next-incident-id.mjs # next id after data/incidents.json + * node scripts/next-incident-id.mjs --check-prs # also scan open PRs (needs gh) + * node scripts/next-incident-id.mjs --json + */ + +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CHECK_PRS = process.argv.includes('--check-prs'); +const AS_JSON = process.argv.includes('--json'); + +const ID = /INC-(\d{3})\b/g; +const format = (n) => `INC-${String(n).padStart(3, '0')}`; + +const db = JSON.parse(readFileSync(path.join(ROOT, 'data', 'incidents.json'), 'utf8')); +const committed = db.incidents.map((i) => Number(String(i.id).slice(4))).filter(Number.isFinite); +const highestCommitted = Math.max(0, ...committed); + +const claimed = new Map(); // id -> where it is claimed +if (CHECK_PRS) { + try { + const list = JSON.parse(execFileSync('gh', ['pr', 'list', '--state', 'open', '--json', 'number,title,headRefName'], { encoding: 'utf8' })); + for (const pr of list) { + // The title and branch name are cheap to read; a full diff per PR is not. + const text = `${pr.title} ${pr.headRefName}`; + for (const m of text.matchAll(ID)) claimed.set(Number(m[1]), `PR #${pr.number}`); + } + } catch (err) { + if (!AS_JSON) console.error(`(could not read open PRs: ${err.message.split('\n')[0]})`); + } +} + +const highestClaimed = Math.max(0, ...claimed.keys()); +const next = Math.max(highestCommitted, highestClaimed) + 1; + +if (AS_JSON) { + console.log(JSON.stringify({ + next: format(next), + highest_committed: format(highestCommitted), + claimed_in_open_prs: [...claimed].sort((a, b) => a[0] - b[0]).map(([n, where]) => ({ id: format(n), where })), + }, null, 2)); +} else { + console.log(format(next)); + if (highestCommitted) console.error(` highest in data/incidents.json: ${format(highestCommitted)}`); + for (const [n, where] of [...claimed].sort((a, b) => a[0] - b[0])) { + if (n > highestCommitted) console.error(` claimed by ${where}: ${format(n)}`); + } + if (!CHECK_PRS) console.error(' (pass --check-prs to account for ids claimed by open pull requests)'); +} diff --git a/scripts/validate.js b/scripts/validate.js index 3d4d50d..941ff64 100644 --- a/scripts/validate.js +++ b/scripts/validate.js @@ -943,6 +943,50 @@ function checkAtlasMappings() { return true; } +/** + * 22. Incident ids must be unique. + * + * Two contributors adding a record at the same time both read the end of + * data/incidents.json and both pick the same number. That is how INC-132 was + * allocated twice (#117 and #109), and the second one only found out when the + * merge conflicted. A duplicate id also silently breaks anything that resolves + * an incident by id — the webapp deep link, the evidence join, the reports. + * + * scripts/next-incident-id.mjs prints a free id, and --check-prs also accounts + * for ids claimed by open pull requests. + */ +function checkIncidentIds() { + const incPath = path.join(ROOT, 'data', 'incidents.json'); + if (!fs.existsSync(incPath)) return true; + const doc = JSON.parse(fs.readFileSync(incPath, 'utf8')); + const incidents = doc.incidents || []; + + const seen = new Map(); + const duplicates = new Map(); + for (const inc of incidents) { + const id = String(inc.id); + if (seen.has(id)) duplicates.set(id, (duplicates.get(id) || 1) + 1); + else seen.set(id, inc.title || ''); + } + + for (const [id, n] of duplicates) { + fail('Incident ids', `${id} is used by ${n} records — allocate a free id with scripts/next-incident-id.mjs`); + } + + // A gap is not an error (a record may be withdrawn), but a silent gap plus a + // duplicate is how a renumbering goes wrong, so the count is reported. + const numbers = incidents.map((i) => Number(String(i.id).slice(4))).filter(Number.isFinite).sort((a, b) => a - b); + const gaps = []; + for (let i = 1; i < numbers.length; i++) { + for (let n = numbers[i - 1] + 1; n < numbers[i]; n++) gaps.push(n); + } + + if (!duplicates.size) { + pass('Incident ids', `${incidents.length} incident ids are unique (highest INC-${String(numbers[numbers.length - 1] || 0).padStart(3, '0')}${gaps.length ? `, ${gaps.length} unused number(s)` : ''})`); + } + return duplicates.size === 0; +} + /** * Evidence guard (T-STRAT03). * @@ -1122,6 +1166,7 @@ function run() { checkMaestroLayers(); checkAtlasMappings(); checkControlIdShapes(); + checkIncidentIds(); checkEvidence(); }