Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / assets / artifact.svg
Author[]Andy Green <andy@warmcat.com> 2026-10-01 05:13 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-04 20:05 UTC
Treefae17a5f28a38df1e39614ebe0783d8f39824a60   Raw Patch
 
findings: group pool findings into bugs, for admins and CI
findings: group pool findings into bugs, for admins and CI

Tasks can now report findings through their pool: an input, eg,
crash-<sha1>, and the sanitizer report about it, <name>.log, left in
SAI_POOL_FINDINGS/<sub>/.  When sai-server has both, it works out which
bug it is from the report: the SUMMARY kind and the first three
functions of the first stack trace in the code being tested, skipping
the sanitizer runtime, libFuzzer and allocator / libc frames, and line
numbers, so a bug stays the same bug as unrelated code changes.  Each
group keeps its hits, when and at which commits and on which platforms
it was found, and its smallest input.

A new group, or one marked fixed that's found again, is news: it's
flagged in sai-web until an admin acknowledges it, and mailed to the
new "findings-notify" sai-server option through the vhost's lws SMTP
client, when lws has LWS_WITH_EMAIL.  The mail only says what and
where.  It counts as sent only when the relay accepted it, and mail
that didn't go is tried again every five minutes.

Each group's smallest input is published in the pool's known
namespace, so builders have them to replay: a CI job can fail while a
known bug still crashes, and report one that doesn't any more with an
ok-<sha1> file, which records the commit.  Groups marked won't fix stop
being published.

sai-web gives admins a findings button, red with the count of
unacknowledged groups, and a list of every pool's groups, where they
can see a group's report, download its reproducer, acknowledge it, and
mark it fixed or won't fix, or reopen it.  sai-web reads the pool dbs
itself, from a new pools table sai-server keeps, and forwards changes
to sai-server.  Nothing about findings is shown to anyone else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/READMEs/README-findings.md b/READMEs/README-findings.md new file mode 100644 index 0000000..9381bcf --- /dev/null +++ b/READMEs/README-findings.md @@ -0,0 +1,99 @@ +# Findings + +Tasks that fuzz, or otherwise hunt for bugs, can report what they find to +sai-server through their pool (see [README-pool.md](README-pool.md)). +sai-server groups the findings into bugs, tells admins about new ones, and gives +the builders each bug's reproducer to replay, so CI keeps failing while a known +bug is still there. + +Findings may be unfixed security bugs. Their details are only ever shown to +admins in sai-web, and mail about them only says what and where, never the +report. A task reporting findings this way should keep sanitizer reports out +of its log, since task logs are public. + +## Reporting a finding + +A finding is a pair of files the task leaves in `$SAI_POOL_FINDINGS/<sub>/`, +where `<sub>` is, eg, the fuzz target: + + - the input, eg, `crash-<sha1>`, as libFuzzer names it + - the sanitizer's report about it, named the same plus `.log` + +As with any finding file, write each under a name starting with `.` and rename +it when it's complete. The builder sends them to sai-server at the next sync, +then deletes them. + +## Grouping into bugs + +When sai-server has both files of a finding, it works out which bug it is from +the report: + + - the kind, from the `SUMMARY:` line, eg, `AddressSanitizer: heap-buffer-overflow` + (any leak is `AddressSanitizer: leak`) + - the first three functions of the report's first stack trace that are in the + code being tested. The sanitizer runtime (names starting `__`), libFuzzer's + own frames (`fuzzer::`) and allocator / libc functions like `malloc` and + `memcpy` are skipped, and the trace stops at `LLVMFuzzerTestOneInput` + +Only function names count, not line numbers, so the same bug stays the same bug +while unrelated code around it changes. Findings in the same sub with the same +kind and functions are the same bug, a "group". + +Each group remembers how many times it was found, when, at which commits and on +which platforms, and the smallest input that reproduced it. + +## News + +A group is news when it's first found, and when a group that was marked fixed is +found again (a regression). News is: + + - flagged in sai-web until an admin acknowledges it + - mailed to `findings-notify`, if that's set on sai-server + +Further findings of an open group just count. + +## Replaying known bugs + +Each group's smallest reproducer is published in the pool's known namespace, at +`$SAI_POOL_KNOWN/<sub>/<sha1>`. A task can replay them, eg, a CI fuzzing job +can replay every one for a target before fuzzing it: + + - one that still crashes is reported as a finding again (it's the same group, + so it counts, or comes back as a regression if it was marked fixed), and the + job should fail, so CI keeps failing while a known bug is there + - for one that doesn't crash any more, the task leaves an empty + `$SAI_POOL_FINDINGS/<sub>/ok-<sha1>`, and sai-server notes at which commit + that was, so admins can see a bug is likely fixed + +Groups marked fixed are still replayed, to catch them coming back. Groups +marked "won't fix" aren't. + +## In sai-web + +Admins get a "findings" button under the login, which turns red with the count +of unacknowledged groups. It opens a list of every pool's groups, with what +and where each bug is, how often it was found and when, and when its +reproducer last didn't crash. For each group, admins can: + + - see the report and download the reproducer + - acknowledge it + - mark it fixed, won't fix, or reopen it + +## Mail + +Mail goes through the lws SMTP client of sai-server's vhost (lws needs to be +built with `LWS_WITH_EMAIL`), set up with sai-server's options: + +|option|meaning| +|---|---| +|`findings-notify`|the address to mail news about findings to; no mail without it| +|`findings-from`|the address the mail comes from, default `findings-notify`| +|`findings-url`|a link to sai-web for the mail to point admins at| + +and the vhost's `"lws-smtp-client"` options for the relay, eg, `smtp-host` and +`smtp-port`, default 127.0.0.1:25. + +A mail only counts as sent when the relay accepted it. Mail that couldn't be +sent, eg, because the relay was down or sai-server restarted, is tried again +every five minutes. Mail the relay refuses outright isn't tried again, but the +group stays flagged in sai-web until it's acknowledged. diff --git a/READMEs/README-pool.md b/READMEs/README-pool.md index 6b85f3e..9f67ef5 100644 --- a/READMEs/README-pool.md +++ b/READMEs/README-pool.md @@ -63,8 +63,8 @@ and `.`, not starting with `.`, and the file can be up to 8MiB. Write a finding under a name starting with `.` and rename it when it's complete, so it isn't sent half written. -The server keeps findings in the pool's db; what happens to them after that is -up to later work (see the idle fuzzing design). +The server groups findings into bugs, and publishes each bug's reproducer in +`SAI_POOL_KNOWN` for tasks to replay: see [README-findings.md](README-findings.md). ### Replacing a sub, eg, after minimizing a corpus diff --git a/assets/index.html b/assets/index.html index 3424d93..ec5d40e 100644 --- a/assets/index.html +++ b/assets/index.html @@ -2,10 +2,10 @@ <html lang="en"> <head> <meta charset=utf-8 http-equiv="Content-Language" content="en"/> - <link rel="stylesheet" type="text/css" href="sai.css?v=16"/> + <link rel="stylesheet" type="text/css" href="sai.css?v=17"/> <link rel="icon" href="sai-icon.svg" sizes="any" type="image/svg+xml"/> <link rel="alternate" type="application/rss+xml" title="Sai build events" href="rss.xml"/> - <script type='text/javascript' src='sai.js?v=15'></script> + <script type='text/javascript' src='sai.js?v=16'></script> <script type='text/javascript' src='terminal-core.js'></script> <script type='text/javascript' src='sai-terminal.js'></script> <script type='text/javascript' src='lws-login.js'></script> @@ -27,6 +27,7 @@ </div> </div> <div id="lws-login-status-container" class="login-status-container"></div> + <button type="button" id="sai_findings_btn" class="findings-btn hidden"></button> </div> <div class="sb-col sb-col-projects" id="sai_sb_projects"></div> <div class="sb-col sb-col-branches" id="sai_sb_branches"></div> diff --git a/assets/sai.css b/assets/sai.css index cc00c99..8d6eb19 100644 --- a/assets/sai.css +++ b/assets/sai.css @@ -2609,3 +2609,103 @@ tr.tt-row.selected td { .taskstate6 .tt-bar > span { background: #ffffff; } + +/* findings, admins only */ + +.findings-btn { + display: block; + margin: 4px 0 0 0; + font-size: 8pt; + padding: 2px 8px; + border: 1px solid #94a3b8; + border-radius: 3px; + background: #f1f5f9; + cursor: pointer; +} + +.findings-btn.hidden { + display: none; +} + +.findings-btn.unacked { + background: #df3030; + border-color: #b91c1c; + color: #ffffff; + font-weight: bold; +} + +.sai-modal.findings-modal { + max-width: 1200px; +} + +.findings-table { + border-collapse: collapse; + width: 100%; + font-size: 8pt; +} + +.findings-table th { + text-align: left; + color: #64748b; + font-weight: normal; + border-bottom: 1px solid #cbd5e1; + padding: 2px 4px; +} + +.findings-table td { + padding: 2px 4px; + border-bottom: 1px solid #f1f5f9; + vertical-align: top; +} + +.findings-row.status1 td, .findings-row.status2 td { + color: #94a3b8; +} + +.findings-row.unacked td.findings-status { + color: #ffffff; + background: #df3030; + font-weight: bold; +} + +.findings-row.unacked.regressed td.findings-status { + background: #ff851b; +} + +.findings-id, .findings-frames, .findings-ok { + font-family: monospace; +} + +.findings-frames { + max-width: 360px; + overflow-wrap: anywhere; +} + +.findings-num { + text-align: right; +} + +.findings-actions { + white-space: nowrap; +} + +.sai-modal-button.small { + font-size: 7.5pt; + padding: 1px 6px; + margin-right: 2px; +} + +.findings-detail.hidden { + display: none; +} + +.findings-report { + font-family: monospace; + font-size: 7.5pt; + max-height: 360px; + overflow: auto; + background: #f8fafc; + border: 1px solid #e2e8f0; + padding: 4px; + white-space: pre; +} diff --git a/assets/sai.js b/assets/sai.js index 94eed06..f9ea08f 100644 --- a/assets/sai.js +++ b/assets/sai.js @@ -2998,6 +2998,300 @@ function refresh_state(t) /* + * Findings (admins only, see READMEs/README-findings.md) + * + * The fuzzing findings sai-server collected in the repos' pools, grouped into + * bugs. They can be unfixed security bugs, so sai-web only answers admins, + * and nothing here is shown to anyone else. + */ + +var sai_findings = null; /* the last com.warmcat.sai.findings */ +var sai_findings_dialog = null; +var sai_findings_timer = null; + +var SAI_FINDINGS_STATUS = [ "open", "fixed", "won't fix" ]; + +function sai_findings_request() +{ + if (auth_state !== SaiAuthState.LOGGED_IN_GRANT_ADMIN) + return; + try { + sai.send(JSON.stringify({ schema: "com.warmcat.sai.findings" })); + } catch (e) {} +} + +/* the admin state became known or changed */ +function sai_findings_auth_changed() +{ + var btn = document.getElementById("sai_findings_btn"); + var admin = auth_state === SaiAuthState.LOGGED_IN_GRANT_ADMIN; + + if (btn) + btn.classList.toggle("hidden", !admin); + + if (!admin) { + sai_findings = null; + sai_findings_dialog_close(); + if (sai_findings_timer) { + clearInterval(sai_findings_timer); + sai_findings_timer = null; + } + return; + } + + sai_findings_request(); + if (!sai_findings_timer) + sai_findings_timer = setInterval(sai_findings_request, 60000); +} + +function sai_findings_unacked() +{ + var n = 0; + + if (sai_findings && sai_findings.pools) + sai_findings.pools.forEach(function(p) { + p.groups.forEach(function(g) { + if (!g.acked) + n++; + }); + }); + + return n; +} + +function sai_findings_update_button() +{ + var btn = document.getElementById("sai_findings_btn"); + var n = sai_findings_unacked(); + + if (!btn) + return; + btn.textContent = n ? "findings: " + n + " new" : "findings"; + btn.classList.toggle("unacked", n > 0); +} + +function sai_findings_dialog_close() +{ + if (!sai_findings_dialog) + return; + + document.removeEventListener("keydown", sai_findings_dialog.onkey, true); + if (document.body.contains(sai_findings_dialog.overlay)) + document.body.removeChild(sai_findings_dialog.overlay); + sai_findings_dialog = null; +} + +function sai_findings_set(p, g, op) +{ + sai.send(JSON.stringify({ + schema: "com.warmcat.sai.findingset", + repo: p.repo, pool: p.pool, group: g.id, op: op + })); + /* sai-server applies it, then we look again */ + setTimeout(sai_findings_request, 500); +} + +/* plain text, since it goes in textContent (agify() makes markup) */ +function sai_findings_age(t) +{ + var d = Math.round((new Date().getTime() / 1000)) - t; + + if (!t) + return ""; + if (d < 120) + return d + "s ago"; + if (d < 7200) + return Math.round(d / 60) + "m ago"; + if (d < 172800) + return Math.round(d / 3600) + "h ago"; + + return Math.round(d / 86400) + "d ago"; +} + +function sai_findings_group_row(p, g) +{ + var tr = sai_adhoc_el("tr", "findings-row" + + (g.acked ? "" : " unacked") + + (g.regressed ? " regressed" : "") + + " status" + g.status); + var st = !g.acked ? (g.regressed ? "regressed" : "new") : + SAI_FINDINGS_STATUS[g.status] || "?"; + var td, b; + + tr.appendChild(sai_adhoc_el("td", "findings-status", st)); + tr.appendChild(sai_adhoc_el("td", "findings-id", g.id)); + tr.appendChild(sai_adhoc_el("td", "", g.sub)); + td = sai_adhoc_el("td", "findings-kind", g.kind); + td.title = g.frames; + tr.appendChild(td); + tr.appendChild(sai_adhoc_el("td", "findings-frames", g.frames)); + tr.appendChild(sai_adhoc_el("td", "findings-num", String(g.hits))); + td = sai_adhoc_el("td", "findings-seen", sai_findings_age(g.last_seen)); + td.title = "first " + g.first_hash.substring(0, 12) + " " + + sai_findings_age(g.first_seen) + ", last " + + g.last_hash.substring(0, 12) + "\non " + g.platforms; + tr.appendChild(td); + td = sai_adhoc_el("td", "findings-ok", g.last_ok_hash ? + "ok at " + g.last_ok_hash.substring(0, 12) : ""); + if (g.last_ok_hash) + td.title = "its reproducer didn't crash at " + g.last_ok_hash + + ", " + sai_findings_age(g.last_ok_time); + tr.appendChild(td); + + td = sai_adhoc_el("td", "findings-actions"); + var add = function(label, fn) { + b = sai_adhoc_el("button", "sai-modal-button small", label); + b.type = "button"; + b.addEventListener("click", fn); + td.appendChild(b); + }; + add("details", function() { + sai.send(JSON.stringify({ + schema: "com.warmcat.sai.findingget", + repo: p.repo, pool: p.pool, group: g.id, op: "" + })); + }); + if (!g.acked) + add("ack", function() { sai_findings_set(p, g, "ack"); }); + if (g.status !== 1) + add("fixed", function() { sai_findings_set(p, g, "fixed"); }); + if (g.status !== 2) + add("won't fix", function() { sai_findings_set(p, g, "wontfix"); }); + if (g.status !== 0) + add("reopen", function() { sai_findings_set(p, g, "reopen"); }); + tr.appendChild(td); + + return tr; +} + +function sai_findings_render() +{ + var body, any = 0; + + if (!sai_findings_dialog) + return; + + body = sai_findings_dialog.body; + while (body.firstChild) + body.removeChild(body.firstChild); + + if (sai_findings && sai_findings.pools) + sai_findings.pools.forEach(function(p) { + var tab, hr; + + if (!p.groups.length) + return; + any = 1; + + body.appendChild(sai_adhoc_el("div", "sai-modal-label", + p.repo + " / pool " + p.pool)); + tab = sai_adhoc_el("table", "findings-table"); + hr = sai_adhoc_el("tr"); + [ "", "group", "target", "kind", "where", "hits", + "last seen", "replay", "" ].forEach(function(h) { + hr.appendChild(sai_adhoc_el("th", "", h)); + }); + tab.appendChild(hr); + p.groups.forEach(function(g) { + tab.appendChild(sai_findings_group_row(p, g)); + }); + body.appendChild(tab); + }); + + if (!any) + body.appendChild(sai_adhoc_el("div", "sai-modal-sub", + "No findings")); +} + +function sai_findings_dialog_open() +{ + sai_findings_dialog_close(); + + var overlay = sai_adhoc_el("div", "sai-modal-overlay"); + var dlg = sai_adhoc_el("div", "sai-modal findings-modal"); + var body = sai_adhoc_el("div", "findings-body"); + var detail = sai_adhoc_el("div", "findings-detail hidden"); + + dlg.appendChild(sai_adhoc_el("div", "sai-modal-title", "Findings")); + dlg.appendChild(sai_adhoc_el("div", "sai-modal-sub", + "Fuzzing findings in the repos' pools, grouped into bugs. " + + "These may be unfixed security bugs: admins only.")); + dlg.appendChild(body); + dlg.appendChild(detail); + + var btns = sai_adhoc_el("div", "sai-modal-buttons"); + var close = sai_adhoc_el("button", "sai-modal-button", "Close"); + close.type = "button"; + close.addEventListener("click", sai_findings_dialog_close); + btns.appendChild(close); + dlg.appendChild(btns); + + overlay.appendChild(dlg); + overlay.addEventListener("click", function(ev) { + if (ev.target === overlay) + sai_findings_dialog_close(); + }); + + var onkey = function(ev) { + if (ev.key === "Escape") { + ev.preventDefault(); + sai_findings_dialog_close(); + } + }; + document.addEventListener("keydown", onkey, true); + + sai_findings_dialog = { overlay: overlay, onkey: onkey, body: body, + detail: detail }; + document.body.appendChild(overlay); + + sai_findings_render(); + sai_findings_request(); +} + +/* com.warmcat.sai.finding: one group's report and reproducer */ +function sai_findings_show_detail(f) +{ + var d, pre, b; + + if (!sai_findings_dialog) + return; + + d = sai_findings_dialog.detail; + while (d.firstChild) + d.removeChild(d.firstChild); + d.classList.remove("hidden"); + + d.appendChild(sai_adhoc_el("div", "sai-modal-label", + "Group " + f.group + ": " + f.name)); + + if (f.repro) { + b = sai_adhoc_el("button", "sai-modal-button small", + "download reproducer"); + b.type = "button"; + b.addEventListener("click", function() { + var bin = atob(f.repro), u8 = new Uint8Array(bin.length); + var a = document.createElement("a"), i; + + for (i = 0; i < bin.length; i++) + u8[i] = bin.charCodeAt(i); + a.href = URL.createObjectURL(new Blob([ u8 ], + { type: "application/octet-stream" })); + a.download = f.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000); + }); + d.appendChild(b); + } else + d.appendChild(sai_adhoc_el("div", "sai-modal-sub", + "The reproducer is too big to download here")); + + pre = sai_adhoc_el("pre", "findings-report", f.report || "(no report)"); + d.appendChild(pre); +} + +/* * Ad-hoc build dialog * * Opened from the cloneinfo reply to the task context menu entry. Lets the @@ -4783,6 +5077,16 @@ function ws_open_sai() location.reload(); break; + case "com.warmcat.sai.findings": + sai_findings = jso; + sai_findings_update_button(); + sai_findings_render(); + break; + + case "com.warmcat.sai.finding": + sai_findings_show_detail(jso); + break; + case "com.warmcat.sai.auth_state": console.log("Backend auth_state:", jso.auth_state); if (jso.auth_state === 3) { @@ -4798,6 +5102,7 @@ function ws_open_sai() auth_state = SaiAuthState.NOT_LOGGED_IN; auth_is_admin = 0; } + sai_findings_auth_changed(); const statusContainer = document.getElementById('lws-login-status-container'); if (statusContainer) { statusContainer.classList.remove('grant-admin', 'grant-user', 'grant-none'); @@ -5121,6 +5426,11 @@ function ws_open_sai() /* stuff that has to be delayed until all the page assets are loaded */ window.addEventListener("load", function() { + var fbtn = document.getElementById("sai_findings_btn"); + + if (fbtn) + fbtn.addEventListener("click", sai_findings_dialog_open); + document.addEventListener('click', function(e) { var hdr = e.target.closest('.log-segment-header'); @@ -5295,6 +5605,8 @@ window.addEventListener("load", function() { auth_state = SaiAuthState.LOGGED_IN_NO_GRANT; } + sai_findings_auth_changed(); + const container = document.getElementById('lws-login-status-container'); if (container) { container.classList.remove('grant-admin', 'grant-user', 'grant-none'); diff --git a/etc-sai-EXAMPLE/server/conf.d/mydomain.com b/etc-sai-EXAMPLE/server/conf.d/mydomain.com index 1a3dee8..5931104 100644 --- a/etc-sai-EXAMPLE/server/conf.d/mydomain.com +++ b/etc-sai-EXAMPLE/server/conf.d/mydomain.com @@ -98,6 +98,17 @@ # "sockpath": "/var/run/sai-websrv", + # Optional: mail news about fuzzing findings in the + # repos' pools to this address (lws must have + # LWS_WITH_EMAIL; the relay is set by the vhost's + # "lws-smtp-client" options, default 127.0.0.1:25). The + # mail only says what and where, the details are for + # admins in sai-web. See READMEs/README-findings.md + # + # "findings-notify": "admin@mydomain.com", + # "findings-from": "sai@mydomain.com", + # "findings-url": "https://mydomain.com/sai/", + # auth jwk path # You can generate a suitable key like this # diff --git a/src/common/c-pool.c b/src/common/c-pool.c index 20840f6..f0e767d 100644 --- a/src/common/c-pool.c +++ b/src/common/c-pool.c @@ -212,3 +212,23 @@ sai_pool_u64_read(const uint8_t *p) return v; } + +/* + * Where sai-server keeps a repo's pool, which sai-web reads too. The repo name + * was checked at hook intake, but it's going in a path. + */ + +void +sai_pool_db_path(char *buf, size_t len, const char *lhs, const char *repo, + const char *pool) +{ + char saf[128], *p; + + lws_snprintf(saf, sizeof(saf), "%s-%s", repo, pool); + lws_filename_purify_inplace(saf); + p = saf; + while ((p = strchr(p, '/'))) + *p++ = '_'; + + lws_snprintf(buf, len, "%s-pool-%s.sqlite3", lhs, saf); +} diff --git a/src/common/c-sqlite3.c b/src/common/c-sqlite3.c index d5b682d..0fdff00 100644 --- a/src/common/c-sqlite3.c +++ b/src/common/c-sqlite3.c @@ -317,3 +317,23 @@ sai_sqlite3_statement(sqlite3 *pdb, const char *cmd, const char *desc) return 0; } + +/* + * Step a prepared statement that returns no rows, eg, an INSERT or UPDATE + * with bound params, and finalize it. Returns 0 if it completed. + */ + +int +sai_sqlite3_step_done(sqlite3 *pdb, sqlite3_stmt *sm, const char *desc) +{ + int n = sqlite3_step(sm); + + sqlite3_finalize(sm); + if (n == SQLITE_DONE) + return 0; + + lwsl_err("%s: %d: Unable to %s: %s\n", __func__, n, desc, + sqlite3_errmsg(pdb)); + + return 1; +} diff --git a/src/common/include/private.h b/src/common/include/private.h index 489ba1e..da1bdb0 100644 --- a/src/common/include/private.h +++ b/src/common/include/private.h @@ -1213,6 +1213,13 @@ sai_event_db_delete_database(const char *sqlite3_path_lhs, const char *event_uui int sai_sqlite3_statement(struct sqlite3 *pdb, const char *cmd, const char *desc); +/* not everyone including us includes sqlite3.h */ +struct sqlite3_stmt; + +int +sai_sqlite3_step_done(struct sqlite3 *pdb, struct sqlite3_stmt *sm, + const char *desc); + /* * Pools: a repo's named sets of files that the builders running its tasks keep * synced through sai-server, eg, fuzzing corpora. See READMEs/README-pool.md. @@ -1278,6 +1285,18 @@ enum { * REPLACE, that was stored */ }; +/* + * Browser -> sai-web -> sai-server: an admin changes a findings group, see + * s-findings.c. op is "ack", "fixed", "wontfix" or "reopen". + */ +typedef struct sai_findingset { + lws_dll2_t list; + char repo[65]; + char pool[33]; + char group[17]; + char op[16]; +} sai_findingset_t; + typedef struct sai_pool_hello { char task_uuid[65]; char nonce[33]; @@ -1290,7 +1309,8 @@ typedef struct sai_pool_rec_hdr { uint8_t ns; } sai_pool_rec_hdr_t; -extern const lws_struct_map_t lsm_pool_hello[2], lsm_schema_pool_hello[1]; +extern const lws_struct_map_t lsm_pool_hello[2], lsm_schema_pool_hello[1], + lsm_findingset[4]; /* src/common/c-pool.c */ @@ -1317,6 +1337,10 @@ size_t sai_pool_rec_max(int ns, int type); void +sai_pool_db_path(char *buf, size_t len, const char *lhs, const char *repo, + const char *pool); + +void sai_pool_u64_write(uint8_t *p, uint64_t v); uint64_t diff --git a/src/common/struct-metadata.c b/src/common/struct-metadata.c index e96c5c0..04dcd26 100644 --- a/src/common/struct-metadata.c +++ b/src/common/struct-metadata.c @@ -653,3 +653,12 @@ const lws_struct_map_t lsm_pool_hello[] = { const lws_struct_map_t lsm_schema_pool_hello[] = { LSM_SCHEMA (sai_pool_hello_t, NULL, lsm_pool_hello, SAI_POOL_SCHEMA), }; + +/* browser -> sai-web -> sai-server */ + +const lws_struct_map_t lsm_findingset[] = { + LSM_CARRAY (sai_findingset_t, repo, "repo"), + LSM_CARRAY (sai_findingset_t, pool, "pool"), + LSM_CARRAY (sai_findingset_t, group, "group"), + LSM_CARRAY (sai_findingset_t, op, "op"), +}; diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index cf8096e..65c5523 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -13,6 +13,7 @@ set(SRCS s-task-helpers.c s-idle.c s-pool.c + s-findings.c s-central.c s-ws-web.c s-webops.c diff --git a/src/server/s-central.c b/src/server/s-central.c index e23a42d..e242a8c 100644 --- a/src/server/s-central.c +++ b/src/server/s-central.c @@ -329,6 +329,9 @@ sais_central_cb(lws_sorted_usec_list_t *sul) sais_prune_inflight_list(vhd); sais_platforms_with_tasks_pending(vhd); + /* mail about findings that couldn't be mailed before */ + sais_findings_notify_retry(vhd); + if (!vhd->sul_gc_events.list.owner) lws_sul_schedule(context, 0, &vhd->sul_gc_events, sais_central_gc_deleted_events_cb, 10 * LWS_US_PER_MS); diff --git a/src/server/s-comms.c b/src/server/s-comms.c index 14ac426..91ba771 100644 --- a/src/server/s-comms.c +++ b/src/server/s-comms.c @@ -246,6 +246,21 @@ s_callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user, return -1; } + /* + * Optional: who to mail about new findings in pools, see + * s-findings.c. The relay is the vhost's "lws-smtp-client" + * pvos. + */ + lws_pvo_get_str(in, "findings-notify", &vhd->findings_notify); + lws_pvo_get_str(in, "findings-from", &vhd->findings_from); + lws_pvo_get_str(in, "findings-url", &vhd->findings_url); +#if !defined(LWS_WITH_EMAIL) + if (vhd->findings_notify) + lwsl_warn("%s: findings-notify is set, but lws was " + "built without LWS_WITH_EMAIL, so no mail " + "will be sent\n", __func__); +#endif + { const char *conf_dir = "/etc/sai/server"; if (lws_pvo_get_str(in, "config-dir", &conf_dir)) { @@ -373,6 +388,19 @@ s_callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user, return -1; } + /* + * The repos' pools, see s-pool.c: sai-web looks here for the + * pool dbs to show findings from + */ + if (sai_sqlite3_statement(vhd->server.pdb, + "CREATE TABLE IF NOT EXISTS pools (" + " repo varchar(64), pool varchar(32)," + " PRIMARY KEY (repo, pool));", + "create pools table")) { + lwsl_err("%s: unable to create pools table\n", __func__); + return -1; + } + if (lws_struct_sq3_create_table(vhd->server.pdb, lsm_schema_sq3_map_plat)) { lwsl_err("%s: unable to create builders table\n", __func__); diff --git a/src/server/s-findings.c b/src/server/s-findings.c new file mode 100644 index 0000000..8f91d94 --- /dev/null +++ b/src/server/s-findings.c @@ -0,0 +1,812 @@ +/* + * Sai server - ./src/server/s-findings.c + * + * Copyright (C) 2019 - 2026 Andy Green <andy@warmcat.com> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation: + * version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + * + * Findings: see READMEs/README-findings.md + * + * Tasks leave findings in their pool's findings dir, which the builder sends + * us (s-pool.c). A finding is a pair of files in a sub (eg, the fuzz target): + * the input, eg, "crash-<sha1>", and the sanitizer's report about it, + * "crash-<sha1>.log". When we have both, we work out which bug it is from the + * report, and group it with the other findings of the same bug. + * + * The first finding of a bug, or one of a bug that was marked fixed, is news: + * it's flagged until an admin acknowledges it in sai-web, and mailed to + * "findings-notify" if that's set. Later ones of an open bug just count. + * + * Each bug's smallest input is published in the pool's "known" namespace, so + * the builders have it to replay, eg, at the start of every CI fuzzing run, + * which then fails while it still crashes. A task that replays one that + * doesn't crash any more leaves "ok-<sha1>" in its findings, and we note at + * which commit that was. + */ + +#include <libwebsockets.h> +#include <string.h> + +#include "s-private.h" + +/* frames that decide which bug a finding is */ +#define SAIS_FINDINGS_FRAMES 3 +/* how often groups whose mail didn't go yet are tried again */ +#define SAIS_FINDINGS_RETRY_US (5 * 60 * LWS_US_PER_SEC) + +enum { + SAIS_FINDINGS_OPEN, + SAIS_FINDINGS_FIXED, + SAIS_FINDINGS_WONTFIX, +}; + +enum { + SAIS_FINDINGS_NOTIFY_NONE, + SAIS_FINDINGS_NOTIFY_PENDING, + SAIS_FINDINGS_NOTIFY_QUEUED, + SAIS_FINDINGS_NOTIFY_DELIVERED, + SAIS_FINDINGS_NOTIFY_FAILED, +}; + +/* keep only what's safe to show and store */ + +static void +sais_findings_clean(char *s) +{ + for (; *s; s++) + if (!((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z') || + (*s >= '0' && *s <= '9') || *s == '_' || *s == '-' || + *s == ':' || *s == '.' || *s == ' ')) + *s = '_'; +} + +/* frames that aren't the code being tested: the runtime, allocators */ + +static int +sais_findings_frame_skip(const char *f) +{ + static const char * const skip[] = { + "malloc", "calloc", "realloc", "free", "strdup", "strndup", + "memcpy", "memmove", "memset", "memcmp", "strlen", "strcmp", + "strncmp", "strcpy", "strncpy", "abort", "raise", + }; + size_t n; + + if ((f[0] == '_' && f[1] == '_') || + /* eg, libFuzzer's own alarm handler, at the top of a timeout */ + !strncmp(f, "fuzzer::", 8)) + return 1; + + for (n = 0; n < LWS_ARRAY_SIZE(skip); n++) + if (!strcmp(f, skip[n])) + return 1; + + return 0; +} + +/* frames from here on are the fuzzer driving the test, not the test */ + +static int +sais_findings_frame_stop(const char *f) +{ + return !strcmp(f, "LLVMFuzzerTestOneInput") || !strcmp(f, "main"); +} + +/* + * Work out what kind of bug a sanitizer report is about, and where: the + * SUMMARY line's kind, and the first few frames of the first stack that are in + * the code being tested. Function names only, not lines, so the same bug + * stays the same bug as unrelated code around it changes. + * + * The report came from a task, so it's treated as hostile: we only ever look + * at bounded tokens out of it. + */ + +static void +sais_findings_signature(const char *rep, size_t len, char *kind, size_t kind_len, + char frames[SAIS_FINDINGS_FRAMES][96]) +{ + const char *p = rep, *end = rep + len; + int nframes = 0, in_stack = 0, stack_done = 0, n; + + lws_strncpy(kind, "unknown", kind_len); + for (n = 0; n < SAIS_FINDINGS_FRAMES; n++) + frames[n][0] = '\0'; + + while (p < end) { + const char *nl = memchr(p, '\n', lws_ptr_diff_size_t(end, p)), + *e = nl ? nl : end, *q = p, *s; + char tool[48], type[48], fn[96]; + size_t l; + + while (q < e && (*q == ' ' || *q == '\t')) + q++; + + /* + * "#3 0x4f2a1c in lws_foo /src/lib/x.c:12:3"... but not + * libFuzzer's "#1024 pulse cov: ..." status lines + */ + + if (!stack_done && e - q > 4 && *q == '#' && + q[1] >= '0' && q[1] <= '9' && + (s = memchr(q, ' ', lws_ptr_diff_size_t(e, q))) && + e - s > 4 && s[1] == '0' && s[2] == 'x') { + const char *in = NULL, *x; + + in_stack = 1; + for (x = s; x + 4 <= e; x++) + if (!memcmp(x, " in ", 4)) { + in = x + 4; + break; + } + + if (in && nframes < SAIS_FINDINGS_FRAMES) { + l = 0; + while (in + l < e && in[l] != ' ' && + in[l] != '(' && in[l] != '.' && + l < sizeof(fn) - 1) + l++; + memcpy(fn, in, l); + fn[l] = '\0'; + + if (sais_findings_frame_stop(fn)) + stack_done = 1; + else + if (l && !sais_findings_frame_skip(fn)) { + sais_findings_clean(fn); + lws_strncpy(frames[nframes++], fn, + sizeof(frames[0])); + } + } + } else + if (in_stack) + /* only the first stack decides it */ + stack_done = 1; + + /* "SUMMARY: AddressSanitizer: heap-buffer-overflow ..." */ + + s = memchr(q, 'S', lws_ptr_diff_size_t(e, q)); + if (s && e - s > 9 && !memcmp(s, "SUMMARY: ", 9)) { + const char *t = s + 9; + + l = 0; + while (t + l < e && t[l] != ' ' && l < sizeof(tool) - 1) + l++; + memcpy(tool, t, l); + tool[l] = '\0'; + t += l; + while (t < e && *t == ' ') + t++; + l = 0; + while (t + l < e && t[l] != ' ' && t[l] != '(' && + l < sizeof(type) - 1) + l++; + memcpy(type, t, l); + type[l] = '\0'; + + /* "32 byte(s) leaked in 1 allocation(s)." */ + if (type[0] >= '0' && type[0] <= '9') + lws_strncpy(type, "leak", sizeof(type)); + + lws_snprintf(kind, kind_len, "%s %s", tool, type); + sais_findings_clean(kind); + } + + p = e + 1; + } +} + +static void +sais_findings_sha1_hex(const uint8_t *data, size_t len, char *hex41) +{ + uint8_t md[20]; + int n; + + lws_SHA1(data, len, md); + for (n = 0; n < 20; n++) + lws_snprintf(hex41 + (n * 2), 3, "%02x", md[n]); +} + +/* + * Mail + */ + +#if defined(LWS_WITH_EMAIL) + +typedef struct { + char dbpath[256]; + char id[17]; +} sais_findings_mail_t; + +static void +sais_findings_mail_done(void *opaque, const lws_smtp_email_t *email, + const lws_smtpc_result_t *res) +{ + sais_findings_mail_t *m = (sais_findings_mail_t *)opaque; + sqlite3_stmt *sm; + sqlite3 *pdb; + int st; + + switch (res->outcome) { + case LWS_SMTPC_DELIVERED: + st = SAIS_FINDINGS_NOTIFY_DELIVERED; + break; + case LWS_SMTPC_ABANDONED: + /* + * We're going away: it stays queued in the db, and the next + * sai-server tries it again + */ + free(m); + return; + default: + lwsl_err("%s: mail about finding %s failed: %d %s\n", __func__, + m->id, res->code, res->text); + st = SAIS_FINDINGS_NOTIFY_FAILED; + break; + } + + /* + * This can come after anything else we have open is gone, so it only + * uses what it was given + */ + + if (sqlite3_open_v2(m->dbpath, &pdb, SQLITE_OPEN_READWRITE, + NULL) == SQLITE_OK) { + sqlite3_busy_timeout(pdb, SAI_SQLITE3_BUSY_TIMEOUT_MS); + if (sqlite3_prepare_v2(pdb, "UPDATE groups SET notify = ? " + "WHERE id = ?", -1, &sm, + NULL) == SQLITE_OK) { + sqlite3_bind_int(sm, 1, st); + sqlite3_bind_text(sm, 2, m->id, -1, SQLITE_TRANSIENT); + sai_sqlite3_step_done(pdb, sm, "set group notify"); + } + } + sqlite3_close(pdb); + free(m); +} + +#endif + +/* + * Mail about the group, if we're set up to. It only says what and where, the + * details are behind the admin login. + */ + +static void +sais_findings_notify(struct vhd *vhd, sais_pool_db_t *db, const char *id) +{ +#if defined(LWS_WITH_EMAIL) + char subject[256], body[1024], kind[96] = "", sub[33] = "", + hash[65] = "", platforms[128] = ""; + sais_findings_mail_t *m; + lws_smtp_email_t email; + struct lws_smtpc *smtpc; + int regressed = 0; + sqlite3_stmt *sm; + + if (!vhd->findings_notify) + return; + + if (sqlite3_prepare_v2(db->pdb, "SELECT kind, sub, last_hash, " + "platforms, regressed FROM groups WHERE id = ?", + -1, &sm, NULL) != SQLITE_OK) + return; + sqlite3_bind_text(sm, 1, id, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + const char *c; + + if ((c = (const char *)sqlite3_column_text(sm, 0))) + lws_strncpy(kind, c, sizeof(kind)); + if ((c = (const char *)sqlite3_column_text(sm, 1))) + lws_strncpy(sub, c, sizeof(sub)); + if ((c = (const char *)sqlite3_column_text(sm, 2))) + lws_strncpy(hash, c, sizeof(hash)); + if ((c = (const char *)sqlite3_column_text(sm, 3))) + lws_strncpy(platforms, c, sizeof(platforms)); + regressed = sqlite3_column_int(sm, 4); + } + sqlite3_finalize(sm); + + lws_snprintf(subject, sizeof(subject), "[sai] %s: %s finding %s in %s", + db->repo, regressed ? "regressed" : "new", id, sub); + lws_snprintf(body, sizeof(body), + "%s %s finding in %s, pool %s:\n\n" + " group: %s\n" + " target: %s\n" + " kind: %s\n" + " commit: %s\n" + " platforms: %s\n\n" + "%s%s%s", + regressed ? "A bug marked fixed has come back, a" : "A new", + regressed ? "regressed" : "fuzzing", db->repo, db->pool, + id, sub, kind, hash, platforms, + vhd->findings_url ? "The details are for admins, at\n " : "", + vhd->findings_url ? vhd->findings_url : "", + vhd->findings_url ? "\n" : ""); + + memset(&email, 0, sizeof(email)); + email.from = vhd->findings_from ? vhd->findings_from : + vhd->findings_notify; + email.to = vhd->findings_notify; + email.subject = subject; + email.body = body; + + smtpc = lws_smtpc_vhost(vhd->vhost); + m = malloc(sizeof(*m)); + if (!smtpc || !m) { + free(m); + return; + } + sai_pool_db_path(m->dbpath, sizeof(m->dbpath), vhd->sqlite3_path_lhs, + db->repo, db->pool); + lws_strncpy(m->id, id, sizeof(m->id)); + + if (lws_smtpc_queue(smtpc, &email, sais_findings_mail_done, m)) { + lwsl_warn("%s: unable to queue mail about %s\n", __func__, id); + free(m); + return; + } + + if (sqlite3_prepare_v2(db->pdb, "UPDATE groups SET notify = ? WHERE " + "id = ?", -1, &sm, NULL) == SQLITE_OK) { + sqlite3_bind_int(sm, 1, SAIS_FINDINGS_NOTIFY_QUEUED); + sqlite3_bind_text(sm, 2, id, -1, SQLITE_TRANSIENT); + sai_sqlite3_step_done(db->pdb, sm, "set group notify"); + } +#else + (void)vhd; + (void)db; + (void)id; +#endif +} + +/* + * Every so often, mail about groups that weren't mailed yet, eg, the relay was + * down, or the mail was queued when sai-server went away + */ + +void +sais_findings_notify_retry(struct vhd *vhd) +{ + lws_usec_t now = lws_now_usecs(); + sqlite3_stmt *sm, *gsm; + + if (!vhd->findings_notify || + now - vhd->findings_last_retry < SAIS_FINDINGS_RETRY_US) + return; + vhd->findings_last_retry = now; + + if (sqlite3_prepare_v2(vhd->server.pdb, "SELECT repo, pool FROM pools", + -1, &sm, NULL) != SQLITE_OK) + return; + + while (sqlite3_step(sm) == SQLITE_ROW) { + const char *repo = (const char *)sqlite3_column_text(sm, 0), + *pool = (const char *)sqlite3_column_text(sm, 1); + sais_pool_db_t *db; + + if (!repo || !pool || !sai_pool_name_ok(pool)) + continue; + + db = sais_pool_db_get(vhd, repo, pool); + if (!db) + continue; + + if (!vhd->findings_reset_done) + /* nothing is really queued in a new sai-server */ + sai_sqlite3_statement(db->pdb, "UPDATE groups SET " + "notify = 1 WHERE notify = 2", + "findings requeue"); + + if (sqlite3_prepare_v2(db->pdb, "SELECT id FROM groups WHERE " + "notify = 1", -1, &gsm, + NULL) == SQLITE_OK) { + while (sqlite3_step(gsm) == SQLITE_ROW) { + char id[17]; + const char *c = (const char *) + sqlite3_column_text(gsm, 0); + + if (!c) + continue; + lws_strncpy(id, c, sizeof(id)); + sais_findings_notify(vhd, db, id); + } + sqlite3_finalize(gsm); + } + + sais_pool_db_put(db); + } + sqlite3_finalize(sm); + + vhd->findings_reset_done = 1; +} + +/* + * Make this input the bug's reproducer that builders replay, replacing the + * one we published before, if any + */ + +static void +sais_findings_publish(sais_pool_db_t *db, const char *sub, const char *old_sha1, + const char *sha1, const uint8_t *data, size_t len) +{ + size_t sl = strlen(sub); + + if (old_sha1 && old_sha1[0] && (!sha1 || strcmp(old_sha1, sha1))) + sais_pool_log_entry(db, SAI_POOL_NS_KNOWN, sub, sl, old_sha1, + 40, NULL, 0); + + if (!sha1) + return; + + if (len > SAI_POOL_ENTRY_MAX) { + lwsl_notice("%s: %s/%s too big to publish\n", __func__, sub, + sha1); + return; + } + + if (sais_pool_log_entry(db, SAI_POOL_NS_KNOWN, sub, sl, sha1, 40, + data, len)) + lwsl_err("%s: unable to publish %s/%s\n", __func__, sub, sha1); +} + +/* + * We have both the input and the report of a finding: which bug is it? + */ + +static void +sais_findings_group(struct vhd *vhd, sais_pool_db_t *db, const char *sub, + const char *input_name, const uint8_t *input, + size_t input_len, const char *rep, size_t rep_len, + const char *hash, const char *platform) +{ + char frames[SAIS_FINDINGS_FRAMES][96], kind[96], sig[512], fr[320], + id[17], sha1[41], hex[41], old_sha1[41] = "", platforms[512] = ""; + int exists = 0, status = SAIS_FINDINGS_OPEN, notify = 0, n; + uint64_t now = (uint64_t)lws_now_secs(); + size_t repro_len = 0; + sqlite3_stmt *sm; + + sais_findings_signature(rep, rep_len, kind, sizeof(kind), frames); + + n = lws_snprintf(sig, sizeof(sig), "%s\n%s", sub, kind); + fr[0] = '\0'; + for (int f = 0; f < SAIS_FINDINGS_FRAMES; f++) { + n += lws_snprintf(sig + n, sizeof(sig) - (size_t)n, "\n%s", + frames[f]); + if (frames[f][0]) + lws_snprintf(fr + strlen(fr), sizeof(fr) - strlen(fr), + "%s%s", fr[0] ? " < " : "", frames[f]); + } + sais_findings_sha1_hex((const uint8_t *)sig, (size_t)n, hex); + lws_strnncpy(id, hex, 16, sizeof(id)); + sais_findings_sha1_hex(input, input_len, sha1); + + if (sqlite3_prepare_v2(db->pdb, "SELECT status, repro_sha1, repro_len, " + "platforms FROM groups WHERE id = ?", -1, &sm, + NULL) != SQLITE_OK) + return; + sqlite3_bind_text(sm, 1, id, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + const char *c; + + exists = 1; + status = sqlite3_column_int(sm, 0); + if ((c = (const char *)sqlite3_column_text(sm, 1))) + lws_strncpy(old_sha1, c, sizeof(old_sha1)); + repro_len = (size_t)sqlite3_column_int64(sm, 2); + if ((c = (const char *)sqlite3_column_text(sm, 3))) + lws_strncpy(platforms, c, sizeof(platforms)); + } + sqlite3_finalize(sm); + + /* the platforms it's been seen on, as a comma-separated set */ + + if (platform && platform[0]) { + const char *p = platforms; + size_t pl = strlen(platform); + int found = 0; + + while (*p) { + const char *c = strchr(p, ','); + size_t l = c ? (size_t)(c - p) : strlen(p); + + if (l == pl && !memcmp(p, platform, pl)) + found = 1; + p += l + (c ? 1 : 0); + } + if (!found && strlen(platforms) + pl + 2 < sizeof(platforms)) + lws_snprintf(platforms + strlen(platforms), + sizeof(platforms) - strlen(platforms), + "%s%s", platforms[0] ? "," : "", platform); + } + + if (!exists) { + lwsl_notice("%s: %s: new group %s: %s %s\n", __func__, db->key, + id, kind, fr); + + if (sqlite3_prepare_v2(db->pdb, "INSERT INTO groups (id, sub, " + "kind, frames, notify, hits, first_seen, " + "last_seen, first_hash, last_hash, platforms, " + "repro_name, repro_sha1, repro_len) VALUES " + "(?,?,?,?,1,1,?,?,?,?,?,?,?,?)", -1, &sm, + NULL) != SQLITE_OK) + return; + sqlite3_bind_text(sm, 1, id, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, sub, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 3, kind, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 4, fr, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(sm, 5, (sqlite3_int64)now); + sqlite3_bind_int64(sm, 6, (sqlite3_int64)now); + sqlite3_bind_text(sm, 7, hash, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 8, hash, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 9, platforms, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 10, input_name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 11, sha1, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(sm, 12, (sqlite3_int64)input_len); + if (sai_sqlite3_step_done(db->pdb, sm, "add group")) + return; + + sais_findings_publish(db, sub, NULL, sha1, input, input_len); + notify = 1; + } else { + /* + * Another finding of a bug we know. If it was marked fixed, + * it's back: that's news again + */ + int regressed = status == SAIS_FINDINGS_FIXED; + + if (regressed) + lwsl_notice("%s: %s: group %s regressed\n", __func__, + db->key, id); + + if (sqlite3_prepare_v2(db->pdb, "UPDATE groups SET hits = " + "hits + 1, last_seen = ?, last_hash = ?, " + "platforms = ?, status = CASE WHEN status = 1 " + "THEN 0 ELSE status END, regressed = CASE " + "WHEN status = 1 THEN 1 ELSE regressed END, " + "acked = CASE WHEN status = 1 THEN 0 ELSE " + "acked END, notify = CASE WHEN status = 1 " + "THEN 1 ELSE notify END WHERE id = ?", -1, &sm, + NULL) != SQLITE_OK) + return; + sqlite3_bind_int64(sm, 1, (sqlite3_int64)now); + sqlite3_bind_text(sm, 2, hash, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 3, platforms, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 4, id, -1, SQLITE_TRANSIENT); + if (sai_sqlite3_step_done(db->pdb, sm, "update group")) + return; + + /* a smaller input is a better reproducer */ + + if (status != SAIS_FINDINGS_WONTFIX && input_len < repro_len) { + /* + * Only swap the published reproducer if the group + * now points at the new one + */ + if (sqlite3_prepare_v2(db->pdb, "UPDATE groups SET " + "repro_name = ?, repro_sha1 = ?, " + "repro_len = ? WHERE id = ?", -1, &sm, + NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, input_name, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, sha1, -1, + SQLITE_TRANSIENT); + sqlite3_bind_int64(sm, 3, + (sqlite3_int64)input_len); + sqlite3_bind_text(sm, 4, id, -1, + SQLITE_TRANSIENT); + if (!sai_sqlite3_step_done(db->pdb, sm, + "update reproducer")) + sais_findings_publish(db, sub, old_sha1, + sha1, input, + input_len); + } + } + + notify = regressed; + } + + /* the finding's files know their group */ + + if (sqlite3_prepare_v2(db->pdb, "UPDATE findings SET group_id = ? WHERE " + "sub = ? AND (name = ? OR name = ? || '.log')", + -1, &sm, NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, id, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, sub, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 3, input_name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 4, input_name, -1, SQLITE_TRANSIENT); + sai_sqlite3_step_done(db->pdb, sm, "set finding group"); + } + + if (notify) + sais_findings_notify(vhd, db, id); +} + +/* the blob of a finding file we have, or NULL; free() it after */ + +static uint8_t * +sais_findings_blob(sais_pool_db_t *db, const char *sub, const char *name, + size_t *len) +{ + uint8_t *b = NULL; + sqlite3_stmt *sm; + + if (sqlite3_prepare_v2(db->pdb, "SELECT blob FROM findings WHERE " + "sub = ? AND name = ?", -1, &sm, NULL) != SQLITE_OK) + return NULL; + sqlite3_bind_text(sm, 1, sub, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, name, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + *len = (size_t)sqlite3_column_bytes(sm, 0); + b = malloc(*len + 1); + if (b) { + if (*len) + memcpy(b, sqlite3_column_blob(sm, 0), *len); + b[*len] = '\0'; + } + } + sqlite3_finalize(sm); + + return b; +} + +/* + * The builder sent us a finding file (s-pool.c has stored it already) + */ + +void +sais_findings_received(struct vhd *vhd, sais_pool_db_t *db, const char *sub_in, + size_t sub_len, const char *name_in, size_t name_len, + const uint8_t *data, size_t len, const char *hash, + const char *platform) +{ + char sub[33], name[65], other[70]; + uint8_t *b; + size_t bl; + + lws_strnncpy(sub, sub_in, sub_len, sizeof(sub)); + lws_strnncpy(name, name_in, name_len, sizeof(name)); + + if (name_len == 43 && !strncmp(name, "ok-", 3)) { + sqlite3_stmt *sm; + + /* a known reproducer that doesn't crash at this commit */ + + if (sqlite3_prepare_v2(db->pdb, "UPDATE groups SET " + "last_ok_hash = ?, last_ok_time = ? WHERE " + "sub = ? AND repro_sha1 = ?", -1, &sm, + NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, hash, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(sm, 2, (sqlite3_int64)lws_now_secs()); + sqlite3_bind_text(sm, 3, sub, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 4, name + 3, -1, SQLITE_TRANSIENT); + sai_sqlite3_step_done(db->pdb, sm, "set group last ok"); + } + return; + } + + if (name_len > 4 && !strcmp(name + name_len - 4, ".log")) { + /* the report... do we have its input yet? */ + lws_strnncpy(other, name, name_len - 4, sizeof(other)); + b = sais_findings_blob(db, sub, other, &bl); + if (!b) + return; + sais_findings_group(vhd, db, sub, other, b, bl, + (const char *)data, len, hash, platform); + free(b); + return; + } + + /* the input... do we have its report yet? */ + + lws_snprintf(other, sizeof(other), "%s.log", name); + b = sais_findings_blob(db, sub, other, &bl); + if (!b) + return; + sais_findings_group(vhd, db, sub, name, data, len, (const char *)b, bl, + hash, platform); + free(b); +} + +/* + * An admin changed a group in sai-web + */ + +void +sais_findings_set(struct vhd *vhd, const char *repo, const char *pool, + const char *group, const char *op) +{ + char sub[33] = "", sha1[41] = "", rname[65] = ""; + sais_pool_db_t *db; + sqlite3_stmt *sm; + const char *q; + size_t n; + + if (!sai_pool_name_ok(pool) || sai_str_has_shell_metachars(repo) || + strlen(group) != 16) + return; + for (n = 0; n < 16; n++) + if (!((group[n] >= '0' && group[n] <= '9') || + (group[n] >= 'a' && group[n] <= 'f'))) + return; + + if (!strcmp(op, "ack")) + q = "UPDATE groups SET acked = 1 WHERE id = ?"; + else if (!strcmp(op, "fixed")) + q = "UPDATE groups SET status = 1, acked = 1, regressed = 0 " + "WHERE id = ?"; + else if (!strcmp(op, "wontfix")) + q = "UPDATE groups SET status = 2, acked = 1 WHERE id = ?"; + else if (!strcmp(op, "reopen")) + q = "UPDATE groups SET status = 0 WHERE id = ?"; + else { + lwsl_notice("%s: unknown op %s\n", __func__, op); + return; + } + + db = sais_pool_db_get(vhd, repo, pool); + if (!db) + return; + + if (sqlite3_prepare_v2(db->pdb, q, -1, &sm, NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, group, -1, SQLITE_TRANSIENT); + sai_sqlite3_step_done(db->pdb, sm, "change group"); + } + + lwsl_notice("%s: %s: %s %s\n", __func__, db->key, op, group); + + /* + * A bug nobody's going to fix isn't replayed; one that's reopened is + * again. Fixed ones still are, to catch them coming back. + */ + + if ((!strcmp(op, "wontfix") || !strcmp(op, "reopen")) && + sqlite3_prepare_v2(db->pdb, "SELECT sub, repro_sha1, repro_name " + "FROM groups WHERE id = ?", -1, &sm, + NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, group, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + const char *c; + + if ((c = (const char *)sqlite3_column_text(sm, 0))) + lws_strncpy(sub, c, sizeof(sub)); + if ((c = (const char *)sqlite3_column_text(sm, 1))) + lws_strncpy(sha1, c, sizeof(sha1)); + if ((c = (const char *)sqlite3_column_text(sm, 2))) + lws_strncpy(rname, c, sizeof(rname)); + } + sqlite3_finalize(sm); + + if (sub[0] && strlen(sha1) == 40) { + if (!strcmp(op, "wontfix")) + sais_findings_publish(db, sub, sha1, NULL, + NULL, 0); + else { + uint8_t *b; + size_t bl; + + b = sais_findings_blob(db, sub, rname, &bl); + if (b) + sais_findings_publish(db, sub, NULL, + sha1, b, bl); + free(b); + } + } + } + + sais_pool_db_put(db); +} diff --git a/src/server/s-pool.c b/src/server/s-pool.c index 7db981e..3ce3d52 100644 --- a/src/server/s-pool.c +++ b/src/server/s-pool.c @@ -44,14 +44,6 @@ /* the most we send in one ws message */ #define SAIS_POOL_TX_CHUNK (64 * 1024) -typedef struct sais_pool_db { - lws_dll2_t list; /* vhd->pool_dbs */ - sqlite3 *pdb; - int refcount; - unsigned int live; /* live entries */ - char key[128]; /* "<repo>/<pool>" */ -} sais_pool_db_t; - struct sais_pool_session { sais_pool_db_t *db; @@ -68,6 +60,10 @@ struct sais_pool_session { char pulling[2]; char task_uuid[65]; + + /* about the task the builder syncs for, for findings */ + char hash[65]; + char platform[96]; }; static const char * const pool_schema[] = { @@ -92,13 +88,34 @@ static const char * const pool_schema[] = { "peer VARCHAR(48), " "blob BLOB, " "UNIQUE(sub, name));", + /* the bugs findings are grouped into, see s-findings.c */ + "CREATE TABLE IF NOT EXISTS groups (" + "id VARCHAR(16) PRIMARY KEY, " + "sub VARCHAR(32) NOT NULL, " + "kind VARCHAR(96), " + "frames VARCHAR(512), " + "status INTEGER NOT NULL DEFAULT 0, " + "acked INTEGER NOT NULL DEFAULT 0, " + "regressed INTEGER NOT NULL DEFAULT 0, " + "notify INTEGER NOT NULL DEFAULT 0, " + "hits INTEGER NOT NULL DEFAULT 0, " + "first_seen INTEGER, " + "last_seen INTEGER, " + "first_hash VARCHAR(65), " + "last_hash VARCHAR(65), " + "last_ok_hash VARCHAR(65), " + "last_ok_time INTEGER, " + "platforms VARCHAR(512), " + "repro_name VARCHAR(64), " + "repro_sha1 VARCHAR(41), " + "repro_len INTEGER);", "PRAGMA journal_mode=WAL;", }; -static sais_pool_db_t * +sais_pool_db_t * sais_pool_db_get(struct vhd *vhd, const char *repo, const char *pool) { - char key[128], fn[256], saf[128], *p; + char key[128], fn[256]; sais_pool_db_t *db; sqlite3_stmt *sm; size_t n; @@ -115,16 +132,7 @@ sais_pool_db_get(struct vhd *vhd, const char *repo, const char *pool) } lws_end_foreach_dll(d); - /* the repo name was checked at hook intake, but it's going in a path */ - - lws_snprintf(saf, sizeof(saf), "%s-%s", repo, pool); - lws_filename_purify_inplace(saf); - p = saf; - while ((p = strchr(p, '/'))) - *p++ = '_'; - - lws_snprintf(fn, sizeof(fn), "%s-pool-%s.sqlite3", - vhd->sqlite3_path_lhs, saf); + sai_pool_db_path(fn, sizeof(fn), vhd->sqlite3_path_lhs, repo, pool); db = malloc(sizeof(*db)); if (!db) @@ -150,6 +158,23 @@ sais_pool_db_get(struct vhd *vhd, const char *repo, const char *pool) return NULL; } + /* which group each finding went to, added after the table was */ + sqlite3_exec(db->pdb, "ALTER TABLE findings ADD COLUMN group_id " + "VARCHAR(16);", NULL, NULL, NULL); + + lws_strncpy(db->repo, repo, sizeof(db->repo)); + lws_strncpy(db->pool, pool, sizeof(db->pool)); + + /* sai-web finds the pools to show findings from in here */ + + if (sqlite3_prepare_v2(vhd->server.pdb, "INSERT OR IGNORE INTO pools " + "(repo, pool) VALUES (?, ?)", -1, &sm, + NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, repo, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, pool, -1, SQLITE_TRANSIENT); + sai_sqlite3_step_done(vhd->server.pdb, sm, "list pool"); + } + if (sqlite3_prepare_v2(db->pdb, "SELECT count(*) FROM entries WHERE " "dead = 0", -1, &sm, NULL) == SQLITE_OK) { if (sqlite3_step(sm) == SQLITE_ROW) @@ -165,7 +190,7 @@ sais_pool_db_get(struct vhd *vhd, const char *repo, const char *pool) return db; } -static void +void sais_pool_db_put(sais_pool_db_t *db) { if (--db->refcount) @@ -225,7 +250,7 @@ sais_pool_live(sais_pool_db_t *db, int ns, const char *sub, size_t sub_len, * dead entry. */ -static int +int sais_pool_log_entry(sais_pool_db_t *db, int ns, const char *sub, size_t sub_len, const char *name, size_t name_len, const uint8_t *blob, size_t len) @@ -342,7 +367,12 @@ sais_pool_put(struct pss *pss, int ns, const char *name, size_t name_len, int r; if (ns == SAI_POOL_NS_FINDINGS) { - if (sqlite3_prepare_v2(ps->db->pdb, "INSERT OR IGNORE INTO " + /* + * The same name can come again, eg, a known reproducer that + * still crashes on a later push: the latest copy is the one + * we keep, and it counts again + */ + if (sqlite3_prepare_v2(ps->db->pdb, "INSERT OR REPLACE INTO " "findings (sub, name, len, received, " "task_uuid, peer, blob) VALUES (?,?,?,?,?,?,?)", -1, &sm, NULL) != SQLITE_OK) @@ -363,6 +393,10 @@ sais_pool_put(struct pss *pss, int ns, const char *name, size_t name_len, lwsl_notice("%s: %s: finding %.*s from %s\n", __func__, ps->db->key, (int)name_len, name, pss->peer_ip); + sais_findings_received(pss->vhd, ps->db, name, sub_len, + sl + 1, name_len - sub_len - 1, data, + len, ps->hash, ps->platform); + goto ack; } @@ -749,7 +783,8 @@ sais_pool_tx(struct vhd *vhd, struct pss *pss) int sais_pool_hello(struct vhd *vhd, struct pss *pss, const sai_pool_hello_t *hello) { - char event_uuid[33], esc[96], filt[128], repo[65], pool[33]; + char event_uuid[33], esc[96], filt[128], repo[65], pool[33], hash[65], + platform[96]; struct lwsac *ac = NULL; sais_pool_session_t *ps; sqlite3 *pdb = NULL; @@ -773,6 +808,7 @@ sais_pool_hello(struct vhd *vhd, struct pss *pss, const sai_pool_hello_t *hello) goto bail; e = lws_container_of(o.head, sai_event_t, list); lws_strncpy(repo, e->repo_name, sizeof(repo)); + lws_strncpy(hash, e->hash, sizeof(hash)); if (sai_event_db_ensure_open(vhd->context, &vhd->sqlite3_cache, vhd->sqlite3_path_lhs, event_uuid, 0, &pdb)) @@ -799,6 +835,7 @@ sais_pool_hello(struct vhd *vhd, struct pss *pss, const sai_pool_hello_t *hello) goto bail; } lws_strncpy(pool, t->pool, sizeof(pool)); + lws_strncpy(platform, t->platform, sizeof(platform)); lwsac_free(&ac); ps = malloc(sizeof(*ps)); @@ -816,6 +853,8 @@ sais_pool_hello(struct vhd *vhd, struct pss *pss, const sai_pool_hello_t *hello) return -1; } lws_strncpy(ps->task_uuid, hello->task_uuid, sizeof(ps->task_uuid)); + lws_strncpy(ps->hash, hash, sizeof(ps->hash)); + lws_strncpy(ps->platform, platform, sizeof(ps->platform)); pss->pool = ps; diff --git a/src/server/s-private.h b/src/server/s-private.h index e30a4af..1ad71e1 100644 --- a/src/server/s-private.h +++ b/src/server/s-private.h @@ -147,6 +147,17 @@ struct vhd; /* a pool sync connection's state, see s-pool.c */ typedef struct sais_pool_session sais_pool_session_t; +/* an open pool db, shared by everything using it, see s-pool.c */ +typedef struct sais_pool_db { + lws_dll2_t list; /* vhd->pool_dbs */ + sqlite3 *pdb; + int refcount; + unsigned int live; /* live entries */ + char key[128]; /* "<repo>/<pool>" */ + char repo[65]; + char pool[33]; +} sais_pool_db_t; + struct pss { struct vhd *vhd; struct lws *wsi; @@ -302,6 +313,13 @@ struct vhd { lws_dll2_owner_t watcher_services; /* sai_watcher_service_t from config */ + /* findings, see s-findings.c */ + const char *findings_notify; /* mail new findings to */ + const char *findings_from; + const char *findings_url; /* sai-web, for links in mail */ + lws_usec_t findings_last_retry; + char findings_reset_done; + /* pools, see s-pool.c */ lws_dll2_owner_t pool_dbs; /* sais_pool_db_t, open pool dbs */ lws_dll2_owner_t pool_conns; /* pss of pool sync connections */ @@ -495,6 +513,30 @@ sais_pool_tx(struct vhd *vhd, struct pss *pss); void sais_pool_session_destroy(struct pss *pss); +void +sais_findings_received(struct vhd *vhd, sais_pool_db_t *db, const char *sub, + size_t sub_len, const char *name, size_t name_len, + const uint8_t *data, size_t len, const char *hash, + const char *platform); + +void +sais_findings_set(struct vhd *vhd, const char *repo, const char *pool, + const char *group, const char *op); + +void +sais_findings_notify_retry(struct vhd *vhd); + +sais_pool_db_t * +sais_pool_db_get(struct vhd *vhd, const char *repo, const char *pool); + +void +sais_pool_db_put(sais_pool_db_t *db); + +int +sais_pool_log_entry(sais_pool_db_t *db, int ns, const char *sub, + size_t sub_len, const char *name, size_t name_len, + const uint8_t *blob, size_t len); + sai_plat_t * sais_builder_from_uuid(struct vhd *vhd, const char *hostname); sai_plat_t * diff --git a/src/server/s-ws-web.c b/src/server/s-ws-web.c index 00eaa41..da52688 100644 --- a/src/server/s-ws-web.c +++ b/src/server/s-ws-web.c @@ -124,6 +124,8 @@ static const lws_struct_map_t lsm_schema_json_map[] = { "com.warmcat.sai.ptydata"), LSM_SCHEMA (sai_browse_rx_taskclone_t, NULL, lsm_taskclone, "com.warmcat.sai.taskclone"), + LSM_SCHEMA (sai_findingset_t, NULL, lsm_findingset, + "com.warmcat.sai.findingset"), }; enum { @@ -147,6 +149,7 @@ enum { SAIS_WS_WEBSRV_RX_CLOSESHELL, SAIS_WS_WEBSRV_RX_PTYDATA, SAIS_WS_WEBSRV_RX_TASKCLONE, + SAIS_WS_WEBSRV_RX_FINDINGSET, }; /* @@ -703,6 +706,16 @@ websrvss_ws_rx_msg(websrvss_srv_t *m, const uint8_t *buf, size_t len, break; } + case SAIS_WS_WEBSRV_RX_FINDINGSET: + { + sai_findingset_t *fs = (sai_findingset_t *)a.dest; + + /* sai-web only forwards this from admins; checked in there */ + sais_findings_set(m->vhd, fs->repo, fs->pool, fs->group, + fs->op); + break; + } + case SAIS_WS_WEBSRV_RX_TASKCLONE: { sai_browse_rx_taskclone_t *tc = diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index 0aae516..f14432a 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -9,7 +9,9 @@ set(SRCS w-rss.c w-ws-server.c w-ws-browser.c + w-findings.c ../common/c-utils.c + ../common/c-pool.c ../common/c-conf.c ../common/c-sqlite3.c ../common/struct-metadata.c diff --git a/src/web/w-findings.c b/src/web/w-findings.c new file mode 100644 index 0000000..0a68310 --- /dev/null +++ b/src/web/w-findings.c @@ -0,0 +1,302 @@ +/* + * Sai web - ./src/web/w-findings.c + * + * Copyright (C) 2019 - 2026 Andy Green <andy@warmcat.com> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation: + * version 2.1 of the License. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, + * MA 02110-1301 USA + * + * Findings, for admins only: see READMEs/README-findings.md + * + * Like cloneinfo, we answer these from the databases sai-server keeps, which we + * read directly: the events db lists the repos' pools, and each pool's db has + * its findings grouped into bugs. Changes to a group go to sai-server. + * + * Findings can be unfixed security bugs, so the caller must only call these + * for admins. + */ + +#include <libwebsockets.h> +#include <string.h> + +#include "w-private.h" + +/* how many groups of each pool we list */ +#define SAIW_FINDINGS_GROUPS_MAX 200 +/* how big the list reply can get */ +#define SAIW_FINDINGS_LIST_MAX (256 * 1024) +/* the most of a report we send */ +#define SAIW_FINDINGS_REPORT_MAX (64 * 1024) +/* the biggest reproducer we send to the browser */ +#define SAIW_FINDINGS_REPRO_MAX (512 * 1024) + +static int +saiw_findings_open(struct vhd *vhd, const char *repo, const char *pool, + sqlite3 **ppdb) +{ + char fn[256]; + + if (!sai_pool_name_ok(pool) || sai_str_has_shell_metachars(repo)) + return 1; + + sai_pool_db_path(fn, sizeof(fn), vhd->sqlite3_path_lhs, repo, pool); + + /* sai-server makes it; if it isn't there, there's nothing to show */ + if (sqlite3_open_v2(fn, ppdb, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) { + sqlite3_close(*ppdb); + *ppdb = NULL; + return 1; + } + sqlite3_busy_timeout(*ppdb, SAI_SQLITE3_BUSY_TIMEOUT_MS); + + return 0; +} + +static const char * +saiw_col(sqlite3_stmt *sm, int col, char *esc, size_t len) +{ + const char *c = (const char *)sqlite3_column_text(sm, col); + + return lws_json_purify(esc, c ? c : "", (int)len - 1, NULL); +} + +/* + * com.warmcat.sai.findings: every pool's groups, unacknowledged and open + * ones first + */ + +int +saiw_browser_send_findings(struct vhd *vhd, struct pss *pss) +{ + char *buf, *start, *p, *end, e1[256], e2[256], q[384]; + sqlite3_stmt *sm = NULL, *gsm; + int first_pool = 1, ret = 1; + + buf = malloc(LWS_PRE + SAIW_FINDINGS_LIST_MAX); + if (!buf) + return 1; + start = p = buf + LWS_PRE; + end = start + SAIW_FINDINGS_LIST_MAX - 1024; + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "{\"schema\":\"com.warmcat.sai.findings\"," + "\"pools\":["); + + /* sai-server creates the table; without it, there are no pools yet */ + + if (sqlite3_prepare_v2(vhd->pdb, "SELECT repo, pool FROM pools ORDER " + "BY repo, pool", -1, &sm, NULL) != SQLITE_OK) + goto done; + + lws_snprintf(q, sizeof(q), "SELECT id, sub, kind, frames, status, " + "acked, regressed, hits, first_seen, last_seen, " + "first_hash, last_hash, last_ok_hash, last_ok_time, " + "platforms, repro_len FROM groups ORDER BY acked, status, " + "last_seen DESC LIMIT %d", SAIW_FINDINGS_GROUPS_MAX); + + while (sqlite3_step(sm) == SQLITE_ROW && p < end) { + const char *repo = (const char *)sqlite3_column_text(sm, 0), + *pool = (const char *)sqlite3_column_text(sm, 1); + int first = 1; + sqlite3 *pdb; + + if (!repo || !pool || saiw_findings_open(vhd, repo, pool, &pdb)) + continue; + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "%s{\"repo\":\"%s\",\"pool\":\"%s\"," + "\"groups\":[", first_pool ? "" : ",", + lws_json_purify(e1, repo, sizeof(e1) - 1, NULL), + lws_json_purify(e2, pool, sizeof(e2) - 1, NULL)); + first_pool = 0; + + if (sqlite3_prepare_v2(pdb, q, -1, &gsm, NULL) == SQLITE_OK) { + while (sqlite3_step(gsm) == SQLITE_ROW && + lws_ptr_diff_size_t(end, p) > 2048) { + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "%s{\"id\":\"%s\",", first ? "" : ",", + saiw_col(gsm, 0, e1, sizeof(e1))); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"sub\":\"%s\",", + saiw_col(gsm, 1, e1, sizeof(e1))); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"kind\":\"%s\",", + saiw_col(gsm, 2, e1, sizeof(e1))); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"frames\":\"%s\",", + saiw_col(gsm, 3, e1, sizeof(e1))); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"status\":%d,\"acked\":%d," + "\"regressed\":%d,\"hits\":%d," + "\"first_seen\":%lld,\"last_seen\":%lld,", + sqlite3_column_int(gsm, 4), + sqlite3_column_int(gsm, 5), + sqlite3_column_int(gsm, 6), + sqlite3_column_int(gsm, 7), + (long long)sqlite3_column_int64(gsm, 8), + (long long)sqlite3_column_int64(gsm, 9)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"first_hash\":\"%s\",", + saiw_col(gsm, 10, e1, sizeof(e1))); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"last_hash\":\"%s\",", + saiw_col(gsm, 11, e1, sizeof(e1))); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"last_ok_hash\":\"%s\"," + "\"last_ok_time\":%lld,", + saiw_col(gsm, 12, e1, sizeof(e1)), + (long long)sqlite3_column_int64(gsm, 13)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "\"platforms\":\"%s\",\"repro_len\":%lld}", + saiw_col(gsm, 14, e1, sizeof(e1)), + (long long)sqlite3_column_int64(gsm, 15)); + first = 0; + } + sqlite3_finalize(gsm); + } + + sqlite3_close(pdb); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "]}"); + } + sqlite3_finalize(sm); + +done: + p += lws_snprintf(p, lws_ptr_diff_size_t(end + 1024, p), "]}"); + + ret = saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss, start, + lws_ptr_diff_size_t(p, start), LWS_WRITE_TEXT); + free(buf); + + return ret; +} + +/* + * com.warmcat.sai.findingget: one group's report and reproducer + */ + +int +saiw_browser_send_finding(struct vhd *vhd, struct pss *pss, + const sai_findingset_t *fs) +{ + char *buf = NULL, *start, *p, *end, e1[256], sub[33] = "", + rname[65] = "", *esc = NULL; + sqlite3_stmt *sm; + sqlite3 *pdb; + size_t blen; + int ret = 1; + + if (saiw_findings_open(vhd, fs->repo, fs->pool, &pdb)) + return 1; + + if (sqlite3_prepare_v2(pdb, "SELECT sub, repro_name FROM groups WHERE " + "id = ?", -1, &sm, NULL) != SQLITE_OK) + goto bail; + sqlite3_bind_text(sm, 1, fs->group, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + const char *c; + + if ((c = (const char *)sqlite3_column_text(sm, 0))) + lws_strncpy(sub, c, sizeof(sub)); + if ((c = (const char *)sqlite3_column_text(sm, 1))) + lws_strncpy(rname, c, sizeof(rname)); + } + sqlite3_finalize(sm); + if (!sub[0] || !rname[0]) + goto bail; + + /* the report can JSON-escape to 6x, the reproducer b64s to 4/3 */ + + blen = LWS_PRE + 1024 + (SAIW_FINDINGS_REPORT_MAX * 6) + + ((SAIW_FINDINGS_REPRO_MAX * 4) / 3) + 16; + buf = malloc(blen); + esc = malloc((SAIW_FINDINGS_REPORT_MAX * 6) + 8); + if (!buf || !esc) + goto bail; + start = p = buf + LWS_PRE; + end = buf + blen - 8; + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "{\"schema\":\"com.warmcat.sai.finding\"," + "\"repo\":\"%s\",", + lws_json_purify(e1, fs->repo, sizeof(e1) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"pool\":\"%s\",", + lws_json_purify(e1, fs->pool, sizeof(e1) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"group\":\"%s\",", + lws_json_purify(e1, fs->group, sizeof(e1) - 1, NULL)); + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"name\":\"%s\",", + lws_json_purify(e1, rname, sizeof(e1) - 1, NULL)); + + /* the report on the group's reproducer */ + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"report\":\""); + if (sqlite3_prepare_v2(pdb, "SELECT blob FROM findings WHERE sub = ? " + "AND name = ? || '.log'", -1, &sm, + NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, sub, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, rname, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + int l = sqlite3_column_bytes(sm, 0); + char *rep; + + if (l > SAIW_FINDINGS_REPORT_MAX) + l = SAIW_FINDINGS_REPORT_MAX; + rep = malloc((size_t)l + 1); + if (rep) { + if (l) + memcpy(rep, sqlite3_column_blob(sm, 0), + (size_t)l); + rep[l] = '\0'; + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), + "%s", lws_json_purify(esc, rep, + (SAIW_FINDINGS_REPORT_MAX * 6) + 7, + NULL)); + free(rep); + } + } + sqlite3_finalize(sm); + } + + /* the reproducer itself, if it's not too big for this */ + + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\",\"repro\":\""); + if (sqlite3_prepare_v2(pdb, "SELECT blob FROM findings WHERE sub = ? " + "AND name = ?", -1, &sm, NULL) == SQLITE_OK) { + sqlite3_bind_text(sm, 1, sub, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(sm, 2, rname, -1, SQLITE_TRANSIENT); + if (sqlite3_step(sm) == SQLITE_ROW) { + int l = sqlite3_column_bytes(sm, 0); + + if (l <= SAIW_FINDINGS_REPRO_MAX && l > 0) { + int n = lws_b64_encode_string( + (const char *)sqlite3_column_blob(sm, 0), + l, p, (int)lws_ptr_diff_size_t(end, p)); + if (n > 0) + p += n; + } + } + sqlite3_finalize(sm); + } + p += lws_snprintf(p, lws_ptr_diff_size_t(end, p), "\"}"); + + ret = saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss, start, + lws_ptr_diff_size_t(p, start), LWS_WRITE_TEXT); + +bail: + free(esc); + free(buf); + sqlite3_close(pdb); + + return ret; +} diff --git a/src/web/w-private.h b/src/web/w-private.h index 89871cb..a6f067d 100644 --- a/src/web/w-private.h +++ b/src/web/w-private.h @@ -379,4 +379,11 @@ saiw_browser_broadcast_queue_power_history(struct vhd *vhd, struct pss *pss); extern const lws_struct_map_t lsm_schema_pcon_energy[]; +/* w-findings.c, for admins only */ +int +saiw_browser_send_findings(struct vhd *vhd, struct pss *pss); + +int +saiw_browser_send_finding(struct vhd *vhd, struct pss *pss, + const sai_findingset_t *fs); diff --git a/src/web/w-ws-browser.c b/src/web/w-ws-browser.c index 4b1849d..48daf3d 100644 --- a/src/web/w-ws-browser.c +++ b/src/web/w-ws-browser.c @@ -145,6 +145,16 @@ static const lws_struct_map_t lsm_schema_json_map_bwsrx[] = { /* shares struct */ "com.warmcat.sai.cloneinfo"), LSM_SCHEMA (sai_browse_rx_taskclone_t, NULL, lsm_taskclone, "com.warmcat.sai.taskclone"), + /* + * Findings (admin only): the list and one finding's details are + * answered locally, changes are forwarded to sai-server + */ + LSM_SCHEMA (sai_findingset_t, NULL, lsm_findingset, + /* shares struct */ "com.warmcat.sai.findings"), + LSM_SCHEMA (sai_findingset_t, NULL, lsm_findingset, + /* shares struct */ "com.warmcat.sai.findingget"), + LSM_SCHEMA (sai_findingset_t, NULL, lsm_findingset, + "com.warmcat.sai.findingset"), }; enum { @@ -171,6 +181,9 @@ enum { SAIM_WS_BROWSER_RX_BRANCHLIST, SAIM_WS_BROWSER_RX_CLONEINFO, SAIM_WS_BROWSER_RX_TASKCLONE, + SAIM_WS_BROWSER_RX_FINDINGS, + SAIM_WS_BROWSER_RX_FINDINGGET, + SAIM_WS_BROWSER_RX_FINDINGSET, }; /* nonzero if s is exactly len hex chars, as task / event uuids are */ @@ -923,7 +936,10 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf, a.top_schema_index == SAIM_WS_BROWSER_RX_CLOSESHELL || a.top_schema_index == SAIM_WS_BROWSER_RX_PTYDATA || a.top_schema_index == SAIM_WS_BROWSER_RX_CLONEINFO || - a.top_schema_index == SAIM_WS_BROWSER_RX_TASKCLONE)) { + a.top_schema_index == SAIM_WS_BROWSER_RX_TASKCLONE || + a.top_schema_index == SAIM_WS_BROWSER_RX_FINDINGS || + a.top_schema_index == SAIM_WS_BROWSER_RX_FINDINGGET || + a.top_schema_index == SAIM_WS_BROWSER_RX_FINDINGSET)) { uint8_t unauth_buf[LWS_PRE + 128]; int n1 = lws_snprintf((char *)unauth_buf + LWS_PRE, sizeof(unauth_buf) - LWS_PRE, "{\"schema\":\"com.warmcat.sai.unauthorized\"}"); @@ -1247,6 +1263,30 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf, saiw_browser_send_cloneinfo(vhd, pss, ei->event_hash); goto ok; + case SAIM_WS_BROWSER_RX_FINDINGS: + saiw_browser_send_findings(vhd, pss); + goto ok; + + case SAIM_WS_BROWSER_RX_FINDINGGET: + saiw_browser_send_finding(vhd, pss, + (sai_findingset_t *)a.dest); + goto ok; + + case SAIM_WS_BROWSER_RX_FINDINGSET: + { + sai_findingset_t *fs = (sai_findingset_t *)a.dest; + + /* sai-server checks these again */ + if (!sai_pool_name_ok(fs->pool) || strlen(fs->group) != 16 || + !sai_is_git_hash(fs->group) || + sai_str_has_shell_metachars(fs->repo)) { + lwsl_notice("%s: dropping malformed findingset\n", + __func__); + goto soft_error; + } + break; /* forward it to sai-server with the rest */ + } + case SAIM_WS_BROWSER_RX_TASKCLONE: { sai_browse_rx_taskclone_t *tc =
Page fetched 0s ago, creation time: 14ms (vhost etag hits: 0%, cache hits: 0%)