mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(webapp): badge the credential the share gate already found (BEA-147) (#183)
* fix(webapp): badge the credential the share gate already found (BEA-147) The hub could identify an AWS access key on line 3 well enough to refuse to publish the file, and rendered that same key to every member as ordinary body text. scanSecrets had exactly one caller — share minting — so the strongest protection in the product sat on the rarest path and was absent from the path every file takes. The render response now carries the same finding, omitted when the file is clean, and the markdown file view shows an advisory strip above the content. Advisory only: nothing is blocked and nothing is redacted, because a member who can open the file could already read the key. The label vocabulary moves out of Browser.tsx into lib/secrets.ts, shared by the badge and the share dialog, so the two surfaces cannot drift apart on the wording of the same finding. The ?sha= history render is scanned too — two lines, and it stops the badge vanishing the moment you click into history on the file it was warning about. Rule ids and line numbers only. The matched text reaches no response body and no log line, pinned by a test on the new caller the way shares_test.go pins the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): the credential scan gains a render-path caller (BEA-147) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
039e350d79
commit
20f59352ca
@@ -47,7 +47,7 @@ flowchart LR
|
|||||||
srv --> store
|
srv --> store
|
||||||
srv --> meta
|
srv --> meta
|
||||||
eng -->|"scan: warn, never hold"| sec
|
eng -->|"scan: warn, never hold"| sec
|
||||||
srv -->|"share mint: refuse"| sec
|
srv -->|"share mint: refuse · markdown render: badge"| sec
|
||||||
cli -->|"init --template: seed locally"| tpl
|
cli -->|"init --template: seed locally"| tpl
|
||||||
srv -->|"POST /api/projects template:<br/>seed as ops under the hub's device"| tpl
|
srv -->|"POST /api/projects template:<br/>seed as ops under the hub's device"| tpl
|
||||||
fe -->|/api/config, /api/projects, viewer APIs| srv
|
fe -->|/api/config, /api/projects, viewer APIs| srv
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ classDiagram
|
|||||||
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
|
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
|
||||||
note for components "FileView's transformHTML resolves the server's `wiki:` marker against flatFiles into a real urlForPath() href (unresolvable ones lose the href and get .wiki-missing), so copy-link/middle-click/new-tab work and only a plain click reaches the delegated handler — resolution used to happen at click time, which left a dead `wiki:guide` string in the DOM (BEA-136). It also drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row"
|
note for components "FileView's transformHTML resolves the server's `wiki:` marker against flatFiles into a real urlForPath() href (unresolvable ones lose the href and get .wiki-missing), so copy-link/middle-click/new-tab work and only a plain click reaches the delegated handler — resolution used to happen at click time, which left a dead `wiki:guide` string in the DOM (BEA-136). It also drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row"
|
||||||
note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the "changed after this run" warning inline"
|
note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the "changed after this run" warning inline"
|
||||||
|
note for components "FileView's MarkdownView renders SecretBadge above the content when the render response carries findings — VersionBanner's shape (a strip, role=status, no actions), the red family rather than the accent because accent+glow already means 'you are looking at an old version' and the two strips stack on the ?sha= view. It phrases from lib/secrets so the badge and the share dialog name the rule and the line identically"
|
||||||
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
|
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
|
||||||
|
|
||||||
class lib {
|
class lib {
|
||||||
@@ -114,11 +115,13 @@ classDiagram
|
|||||||
+conflict.ts parseConflict Conflict
|
+conflict.ts parseConflict Conflict
|
||||||
+sniff.ts sniffBytes BlobText MAX_BYTES
|
+sniff.ts sniffBytes BlobText MAX_BYTES
|
||||||
+csv.ts parseDelimited Csv CSV_ROWS
|
+csv.ts parseDelimited Csv CSV_ROWS
|
||||||
|
+secrets.ts SecretFinding secretsMessage secretsBadge
|
||||||
+mermaid.ts hasMermaid renderMermaid Palette DARK LIGHT
|
+mermaid.ts hasMermaid renderMermaid Palette DARK LIGHT
|
||||||
+utils.ts
|
+utils.ts
|
||||||
}
|
}
|
||||||
note for lib "mermaid.ts is the one exception to 'pure, no React, unit-tested on node': it needs a DOM and a browser-only library, so its coverage is Playwright. html in → html out, so neither caller can be tempted to patch a live subtree. It imports mermaid only when hasMermaid() says a document has a fence — that gate is what keeps a diagram-free page from downloading any of it — and every failure (unparseable fence, render throw, chunk that never loads) returns the untouched <pre><code> instead of throwing"
|
note for lib "mermaid.ts is the one exception to 'pure, no React, unit-tested on node': it needs a DOM and a browser-only library, so its coverage is Playwright. html in → html out, so neither caller can be tempted to patch a live subtree. It imports mermaid only when hasMermaid() says a document has a fence — that gate is what keeps a diagram-free page from downloading any of it — and every failure (unparseable fence, render throw, chunk that never loads) returns the untouched <pre><code> instead of throwing"
|
||||||
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it. HEAT_DISCLOSURE sits beside that arithmetic for the same reason: a member's own views count toward the number, and four surfaces printing their own copy of that promise is four promises that can drift (BEA-61). The constant is NOT re-exported through useBrowse — surfaces import it straight from lib/heat, and a unit test asserts src/ holds exactly one copy of the sentence. The hot-and-stale VERDICT joined the totals for the same reason (BEA-119): HOT_READS/STALE_DAYS/isDanger were private to Insights.tsx, so the Dashboard was the only screen that could say a doc was hot and unmaintained — the file page and the folder listing showed the ingredients and no verdict. isDanger takes (reads, days) rather than a heat entry because only the Dashboard has a reader lens: it passes its lens-filtered count, the other two pass heatTotal. staleNote returns a STRING (empty when not flagged) so the badge stays pure and survives whichever component owns the meta line"
|
note for lib "pure, no React, unit-tested on node (npm test) — the line diff is ~40 lines, cheaper than auditing a diff package. heat.ts is the one read-count arithmetic: every surface (file header, folder listing, Dashboard bar) totals and splits through it, so they cannot disagree; useBrowse re-exports it. HEAT_DISCLOSURE sits beside that arithmetic for the same reason: a member's own views count toward the number, and four surfaces printing their own copy of that promise is four promises that can drift (BEA-61). The constant is NOT re-exported through useBrowse — surfaces import it straight from lib/heat, and a unit test asserts src/ holds exactly one copy of the sentence. The hot-and-stale VERDICT joined the totals for the same reason (BEA-119): HOT_READS/STALE_DAYS/isDanger were private to Insights.tsx, so the Dashboard was the only screen that could say a doc was hot and unmaintained — the file page and the folder listing showed the ingredients and no verdict. isDanger takes (reads, days) rather than a heat entry because only the Dashboard has a reader lens: it passes its lens-filtered count, the other two pass heatTotal. staleNote returns a STRING (empty when not flagged) so the badge stays pure and survives whichever component owns the meta line"
|
||||||
|
note for lib "secrets.ts is the six credential rules in words, mirroring internal/secrets' Label map. It lived in Browser.tsx under a comment saying one caller did not justify a file; BEA-147 gave it a second one, and the two callers are the two surfaces that report the SAME finding — the share dialog that refuses to mint, and FileView's badge that only warns. One map is what makes 'wording consistent with the share dialog' mechanical rather than a copy-editing promise"
|
||||||
note for lib "conflict.ts recognises a conflict copy from its NAME alone — syncer.conflictName is a pure function of the path, so the device and the moment come out of the string with no server route, no journal field and no request. The regex is an ANCHORED suffix and a strictly narrower match of the Go convention (sanitize's character class, clip's 32), and every mismatch — truncated suffix, impossible date — is null rather than a throw, so a stray filename can never break a listing. Two callers: FolderListing marks the row, ConflictBanner explains the file (BEA-128)"
|
note for lib "conflict.ts recognises a conflict copy from its NAME alone — syncer.conflictName is a pure function of the path, so the device and the moment come out of the string with no server route, no journal field and no request. The regex is an ANCHORED suffix and a strictly narrower match of the Go convention (sanitize's character class, clip's 32), and every mismatch — truncated suffix, impossible date — is null rather than a throw, so a stray filename can never break a listing. Two callers: FolderListing marks the row, ConflictBanner explains the file (BEA-128)"
|
||||||
note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"
|
note for lib "csv.ts parses .csv/.tsv for FileView's table view — ~50 lines against RFC 4180, so no papaparse. It NEVER throws: null means 'not a table' (unterminated quote, no delimiter) and the caller falls back to the plain-text preview, which is why the fallback is a type-level guarantee rather than a try/catch someone can forget"
|
||||||
|
|
||||||
@@ -133,7 +136,8 @@ classDiagram
|
|||||||
Browser --> components
|
Browser --> components
|
||||||
HubApp --> components
|
HubApp --> components
|
||||||
components --> nav : linkProps navigate
|
components --> nav : linkProps navigate
|
||||||
components --> lib : diffText groupRuns hotPathSplit placeLabels staleNote isDanger parseDelimited renderMermaid parseConflict
|
components --> lib : diffText groupRuns hotPathSplit placeLabels staleNote isDanger parseDelimited renderMermaid parseConflict secretsBadge
|
||||||
|
Browser --> lib : secretsMessage (the share dialog's half of lib/secrets)
|
||||||
hooks --> lib : re-exports heat.ts, sniffBytes
|
hooks --> lib : re-exports heat.ts, sniffBytes
|
||||||
shareMermaid --> lib : renderMermaid
|
shareMermaid --> lib : renderMermaid
|
||||||
hooks --> api
|
hooks --> api
|
||||||
|
|||||||
@@ -293,8 +293,15 @@ classDiagram
|
|||||||
+Rule string
|
+Rule string
|
||||||
+Line int
|
+Line int
|
||||||
}
|
}
|
||||||
note for secretScan "No longer a webapp file: internal/secrets is stdlib-only so internal/syncer can run the SAME rules on the path every file takes (the sync scan, warn-only — see cli-sync.md). The rule ids are a wire contract, keyed off by Browser.tsx's SECRET_LABELS and now by Label, whose test asserts it covers every rule"
|
class renderFindings {
|
||||||
|
<<Server, server.go>>
|
||||||
|
+renderFindings(src) []Finding
|
||||||
|
caps at ScanLimit, then Scan
|
||||||
|
findings on the render response, omitted when empty
|
||||||
|
}
|
||||||
|
note for secretScan "No longer a webapp file: internal/secrets is stdlib-only so internal/syncer can run the SAME rules on the path every file takes (the sync scan, warn-only — see cli-sync.md). The rule ids are a wire contract, keyed off by lib/secrets.ts's SECRET_LABELS and by Label, whose test asserts it covers every rule"
|
||||||
note for secretScan "Mint-time gate on handleShareCreate: the one place a member turns private bytes into a public URL is the one place the bytes are read first. It returns rule ids and LINE NUMBERS only — the matched text never reaches a response body, a log line, or a metric label, the same rule ReadLedger keeps for actor identity. Bypassed by confirm:true (bdrive share --force, the UI's Share anyway) and by Server.alreadyPublic, since a path that already has a live link is public already. Fails CLOSED: an unreadable blob is 503, not a silent pass"
|
note for secretScan "Mint-time gate on handleShareCreate: the one place a member turns private bytes into a public URL is the one place the bytes are read first. It returns rule ids and LINE NUMBERS only — the matched text never reaches a response body, a log line, or a metric label, the same rule ReadLedger keeps for actor identity. Bypassed by confirm:true (bdrive share --force, the UI's Share anyway) and by Server.alreadyPublic, since a path that already has a live link is public already. Fails CLOSED: an unreadable blob is 503, not a silent pass"
|
||||||
|
note for renderFindings "The SECOND caller, and the reason the gate is no longer the only one (BEA-147): minting is the rarest path in the product, so a hub that could name an AWS key on line 3 well enough to refuse to publish a file rendered that same key to every member as prose. handleRender and renderVersion already hold the bytes RenderMarkdown needs, so the cap is a slice rather than the LimitReader the two streaming callers use — same ScanLimit, so the badge and the share dialog can never disagree about one file. Advisory: findings ride along on the render response (omitted when empty), nothing is blocked and nothing is redacted, because a member who can open the file could already read the key"
|
||||||
|
|
||||||
class sandboxInline {
|
class sandboxInline {
|
||||||
<<Server, every bytes-out route>>
|
<<Server, every bytes-out route>>
|
||||||
@@ -426,6 +433,8 @@ classDiagram
|
|||||||
reservations ..> QuotaProvider : CheckWrite(size + outstanding), RecordUsage on landing
|
reservations ..> QuotaProvider : CheckWrite(size + outstanding), RecordUsage on landing
|
||||||
ShareDB ..> QuotaProvider : CheckRead before the stream, RecordEgress after
|
ShareDB ..> QuotaProvider : CheckRead before the stream, RecordEgress after
|
||||||
Server ..> secretScan : handleShareCreate scans the first 1 MiB unless confirmed or alreadyPublic
|
Server ..> secretScan : handleShareCreate scans the first 1 MiB unless confirmed or alreadyPublic
|
||||||
|
Server ..> renderFindings : handleRender + renderVersion, every markdown view
|
||||||
|
renderFindings ..> secretScan
|
||||||
secretScan ..> secretFinding
|
secretScan ..> secretFinding
|
||||||
Server ..> countingWriter : every bytes-out route that bills
|
Server ..> countingWriter : every bytes-out route that bills
|
||||||
Server ..> wireCodec : gzip on /store/ GET+list, inflate above spool on PUT
|
Server ..> wireCodec : gzip on /store/ GET+list, inflate above spool on PUT
|
||||||
|
|||||||
@@ -265,6 +265,29 @@ test("share mints a public link that serves the file, revoke kills it", async ({
|
|||||||
expect(gone.status()).toBe(404);
|
expect(gone.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// BEA-147: the same finding the share dialog names, on the path every file
|
||||||
|
// takes — the viewer used to render the key as ordinary prose.
|
||||||
|
test("a file holding a key carries a badge in the file view", async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const pid = await wikiId(page);
|
||||||
|
await page.goto(`/${pid}/deploy.md`);
|
||||||
|
|
||||||
|
const badge = page.locator(".sbadge");
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
// Same rule, same line, same words as the share dialog's modal below.
|
||||||
|
await expect(badge).toContainText("an AWS access key (line 3)");
|
||||||
|
// Advisory: the file still renders in full, nothing is redacted.
|
||||||
|
await expect(page.locator("#content h1")).toHaveText("Deploy");
|
||||||
|
await expect(page.locator("#content")).toContainText("AWS_ACCESS_KEY_ID");
|
||||||
|
// The badge itself must never echo the thing it found.
|
||||||
|
await expect(badge).not.toContainText("AKIA");
|
||||||
|
|
||||||
|
// A clean file gets no badge at all.
|
||||||
|
await page.goto(`/${pid}/index.md`);
|
||||||
|
await expect(page.locator("#content h1")).toHaveText("Wiki");
|
||||||
|
await expect(page.locator(".sbadge")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
// BEA-111: sharing a file that looks like it holds credentials asks first.
|
// BEA-111: sharing a file that looks like it holds credentials asks first.
|
||||||
// Cancel mints nothing; Share anyway mints the link it would have.
|
// Cancel mints nothing; Share anyway mints the link it would have.
|
||||||
test("share on a file holding a key asks before it mints, and Cancel mints nothing", async ({
|
test("share on a file holding a key asks before it mints, and Cancel mints nothing", async ({
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// The API is deliberately storage-blind: nothing here ever names a bucket,
|
// The API is deliberately storage-blind: nothing here ever names a bucket,
|
||||||
// remote URL, or credential, and heat responses carry no actor identities.
|
// remote URL, or credential, and heat responses carry no actor identities.
|
||||||
|
|
||||||
|
import type { SecretFinding } from "../lib/secrets";
|
||||||
|
|
||||||
// GET /api/config (handleConfig, server.go)
|
// GET /api/config (handleConfig, server.go)
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
mode: "volume" | "hub";
|
mode: "volume" | "hub";
|
||||||
@@ -154,6 +156,10 @@ export interface RenderDoc {
|
|||||||
user_name?: string;
|
user_name?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
device?: string;
|
device?: string;
|
||||||
|
// The share gate's credential scan, run on the render path too (BEA-147).
|
||||||
|
// Omitted by the server when the file is clean, so a truthiness test is
|
||||||
|
// the whole check. Rule ids and line numbers only — never the matched text.
|
||||||
|
findings?: SecretFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET .../heat (handleHeat, reads.go) — counts only, never who.
|
// GET .../heat (handleHeat, reads.go) — counts only, never who.
|
||||||
|
|||||||
@@ -32,36 +32,10 @@ import { Insights, useInsightsDevices } from "../components/Insights";
|
|||||||
import { HistoryView, historyTitle } from "../components/HistoryView";
|
import { HistoryView, historyTitle } from "../components/HistoryView";
|
||||||
import type { Run } from "../lib/runs";
|
import type { Run } from "../lib/runs";
|
||||||
import { VersionBanner } from "../components/VersionBanner";
|
import { VersionBanner } from "../components/VersionBanner";
|
||||||
|
import { secretsMessage } from "../lib/secrets";
|
||||||
import { ConflictBanner } from "../components/ConflictBanner";
|
import { ConflictBanner } from "../components/ConflictBanner";
|
||||||
import { parseConflict } from "../lib/conflict";
|
import { parseConflict } from "../lib/conflict";
|
||||||
|
|
||||||
// The hub's six share-time credential rules, in words. Only one caller
|
|
||||||
// (shareNow), so it lives here rather than in its own file.
|
|
||||||
const SECRET_LABELS: Record<string, string> = {
|
|
||||||
aws_access_key_id: "an AWS access key",
|
|
||||||
openai_api_key: "an OpenAI API key",
|
|
||||||
github_pat: "a GitHub token",
|
|
||||||
slack_token: "a Slack token",
|
|
||||||
private_key: "a private key",
|
|
||||||
gitlab_pat: "a GitLab token",
|
|
||||||
};
|
|
||||||
|
|
||||||
// secretsMessage phrases the 409 for the confirm dialog. The second sentence
|
|
||||||
// is not decoration: a link always serves the file's LATEST content, so the
|
|
||||||
// copy may only ever claim what was true at the moment of sharing — never
|
|
||||||
// that the file is clean.
|
|
||||||
function secretsMessage(findings: { rule: string; line: number }[] = []): string {
|
|
||||||
const parts = findings.map((f) => `${SECRET_LABELS[f.rule] ?? f.rule} (line ${f.line})`);
|
|
||||||
const what =
|
|
||||||
parts.length > 1
|
|
||||||
? parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1]
|
|
||||||
: parts[0] || "something credential-shaped";
|
|
||||||
return (
|
|
||||||
`BearDrive found ${what} in this file. The check covers the file at the moment you share it — ` +
|
|
||||||
`a link always serves the file's latest content, so later changes are never checked. Share anyway?`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The browsing surface shared by hub projects and single-volume mode: the
|
// The browsing surface shared by hub projects and single-volume mode: the
|
||||||
// file tree, folder listings, file views, and every topbar action. Sidebar
|
// file tree, folder listings, file views, and every topbar action. Sidebar
|
||||||
// chrome (vault header, project nav, org bar) is injected by the caller;
|
// chrome (vault header, project nav, org bar) is injected by the caller;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
import { urlForPath } from "../router";
|
import { urlForPath } from "../router";
|
||||||
import { CSV_ROWS, parseDelimited, type Csv } from "../lib/csv";
|
import { CSV_ROWS, parseDelimited, type Csv } from "../lib/csv";
|
||||||
import { hasMermaid, renderMermaid } from "../lib/mermaid";
|
import { hasMermaid, renderMermaid } from "../lib/mermaid";
|
||||||
|
import { secretsBadge, type SecretFinding } from "../lib/secrets";
|
||||||
|
import { Icon } from "./shell";
|
||||||
|
|
||||||
export function FileView(props: {
|
export function FileView(props: {
|
||||||
apiBase: string;
|
apiBase: string;
|
||||||
@@ -235,10 +237,36 @@ function MarkdownView(props: Parameters<typeof FileView>[0]) {
|
|||||||
// Server-rendered, server-sanitized markdown — same trust model as the
|
// Server-rendered, server-sanitized markdown — same trust model as the
|
||||||
// classic app assigning innerHTML.
|
// classic app assigning innerHTML.
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
dangerouslySetInnerHTML={{ __html: diagrams ?? html }}
|
<SecretBadge findings={doc.findings} />
|
||||||
onClick={(e) => handleLinkClick(e, path, onOpenFile)}
|
<div
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: diagrams ?? html }}
|
||||||
|
onClick={(e) => handleLinkClick(e, path, onOpenFile)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The share gate could already name the rule and the line well enough to
|
||||||
|
refuse to publish this file, while the file view rendered the same key as
|
||||||
|
ordinary prose (BEA-147). Advisory only, in VersionBanner's shape: a strip
|
||||||
|
above the content, role="status", no actions. Nothing is blocked and
|
||||||
|
nothing is redacted — a reader who can open the file could already read
|
||||||
|
the key, and the point is that they now know it is in there. */
|
||||||
|
function SecretBadge({ findings }: { findings?: SecretFinding[] }) {
|
||||||
|
if (!findings?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="sbadge" role="status">
|
||||||
|
<span className="sb-icon">
|
||||||
|
<Icon name="shield" />
|
||||||
|
</span>
|
||||||
|
<div className="sb-text">
|
||||||
|
<b>{secretsBadge(findings)}</b>
|
||||||
|
<span>
|
||||||
|
Checked when this page loaded. Sharing the file asks you to confirm first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
|
||||||
|
// Excluded from tsconfig's include — it imports node: builtins, which the
|
||||||
|
// app's DOM-only lib set does not know about.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { secretsBadge, secretsMessage } from "./secrets.ts";
|
||||||
|
|
||||||
|
test("one finding names the rule in words and the line", () => {
|
||||||
|
const s = secretsBadge([{ rule: "aws_access_key_id", line: 3 }]);
|
||||||
|
assert.equal(s, "This file contains an AWS access key (line 3).");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("several findings read as a list", () => {
|
||||||
|
const s = secretsBadge([
|
||||||
|
{ rule: "aws_access_key_id", line: 3 },
|
||||||
|
{ rule: "github_pat", line: 9 },
|
||||||
|
{ rule: "private_key", line: 40 },
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
s,
|
||||||
|
"This file contains an AWS access key (line 3), a GitHub token (line 9) and a private key (line 40).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown rule id falls back to the raw id rather than reporting nothing", () => {
|
||||||
|
assert.match(secretsBadge([{ rule: "nomad_token", line: 1 }]), /nomad_token \(line 1\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the badge and the share dialog name the same finding identically", () => {
|
||||||
|
const f = [{ rule: "openai_api_key", line: 12 }];
|
||||||
|
assert.match(secretsBadge(f), /an OpenAI API key \(line 12\)/);
|
||||||
|
assert.match(secretsMessage(f), /an OpenAI API key \(line 12\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the share dialog never claims the file stays clean", () => {
|
||||||
|
const s = secretsMessage([{ rule: "aws_access_key_id", line: 3 }]);
|
||||||
|
assert.match(s, /latest content/);
|
||||||
|
assert.match(s, /Share anyway\?$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no findings still phrases something rather than an empty sentence", () => {
|
||||||
|
assert.match(secretsMessage(), /something credential-shaped/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/* The hub's six credential rules, in words — shared by the two surfaces that
|
||||||
|
report the same finding: the share dialog's confirm (it refuses to mint)
|
||||||
|
and the file view's badge (it only warns). One map, so the two wordings
|
||||||
|
cannot drift; that shared vocabulary is the whole reason this lives in
|
||||||
|
lib/ rather than beside either caller.
|
||||||
|
|
||||||
|
Mirrors `labels` in internal/secrets/secrets.go, which is what `bdrive
|
||||||
|
share` and `bdrive status` print. */
|
||||||
|
|
||||||
|
export interface SecretFinding {
|
||||||
|
rule: string;
|
||||||
|
line: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_LABELS: Record<string, string> = {
|
||||||
|
aws_access_key_id: "an AWS access key",
|
||||||
|
openai_api_key: "an OpenAI API key",
|
||||||
|
github_pat: "a GitHub token",
|
||||||
|
slack_token: "a Slack token",
|
||||||
|
private_key: "a private key",
|
||||||
|
gitlab_pat: "a GitLab token",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A rule with no label renders as its bare id: ugly, but a seventh rule that
|
||||||
|
// reported nothing would be worse.
|
||||||
|
function phrase(f: SecretFinding): string {
|
||||||
|
return `${SECRET_LABELS[f.rule] ?? f.rule} (line ${f.line})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(findings: SecretFinding[]): string {
|
||||||
|
const parts = findings.map(phrase);
|
||||||
|
if (parts.length > 1) return parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1];
|
||||||
|
return parts[0] || "something credential-shaped";
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretsMessage phrases the 409 for the confirm dialog. The second sentence
|
||||||
|
// is not decoration: a link always serves the file's LATEST content, so the
|
||||||
|
// copy may only ever claim what was true at the moment of sharing — never
|
||||||
|
// that the file is clean.
|
||||||
|
export function secretsMessage(findings: SecretFinding[] = []): string {
|
||||||
|
return (
|
||||||
|
`BearDrive found ${list(findings)} in this file. The check covers the file at the moment you share it — ` +
|
||||||
|
`a link always serves the file's latest content, so later changes are never checked. Share anyway?`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretsBadge phrases the same finding for the file view, where nothing is
|
||||||
|
// blocked and nothing is redacted — a reader who can open the file could
|
||||||
|
// already read the key. Same map, so the badge and the dialog name the rule
|
||||||
|
// and the line identically.
|
||||||
|
export function secretsBadge(findings: SecretFinding[] = []): string {
|
||||||
|
return `This file contains ${list(findings)}.`;
|
||||||
|
}
|
||||||
@@ -1177,6 +1177,18 @@ input[type="checkbox"] { accent-color: var(--accent); }
|
|||||||
.vbanner .vb-actions { flex: none; display: flex; gap: 8px; }
|
.vbanner .vb-actions { flex: none; display: flex; gap: 8px; }
|
||||||
.vbanner .vb-actions .ai-btn { display: inline-flex; align-items: center; text-decoration: none; }
|
.vbanner .vb-actions .ai-btn { display: inline-flex; align-items: center; text-decoration: none; }
|
||||||
|
|
||||||
|
/* Credential badge on the file view (BEA-147) — the share gate's finding on
|
||||||
|
the path every file takes. Same strip geometry as .vbanner above; the red
|
||||||
|
family rather than the accent, because accent+glow already means "you are
|
||||||
|
looking at an old version" and the two strips can appear together. The hue
|
||||||
|
is .meta-stale's (#e07070, style.css:385), which is the warning colour this
|
||||||
|
same view already speaks. */
|
||||||
|
.sbadge { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 22px; padding: 11px 14px; border: 1px solid rgba(242,109,109,.32); border-radius: var(--r-card); background: rgba(242,109,109,.08); }
|
||||||
|
.sbadge .sb-icon { flex: none; display: flex; color: #e07070; }
|
||||||
|
.sbadge .sb-text { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; gap: 1px; font-size: 12.5px; line-height: 1.45; }
|
||||||
|
.sbadge .sb-text b { color: #e07070; font-weight: 600; }
|
||||||
|
.sbadge .sb-text span { color: var(--text-dim); }
|
||||||
|
|
||||||
/* plain file / binary views */
|
/* plain file / binary views */
|
||||||
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; }
|
pre.plain { background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; overflow-x: auto; font: 12.5px/1.6 var(--mono); color: #c6cbd3; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
/* long unbreakable lines wrap instead of blowing out the column: .page has min-width: 0 */
|
/* long unbreakable lines wrap instead of blowing out the column: .page has min-width: 0 */
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package webapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/runbear-io/beardrive/internal/secrets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getAs fetches as a signed-in member: the render route is membership-gated,
|
||||||
|
// and the badge is for the people who can already read the file.
|
||||||
|
func getAs(t *testing.T, srv *Server, h http.Handler, url string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("GET", url, nil)
|
||||||
|
authAs(t, srv, req)
|
||||||
|
return doHTTP(h, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDoc(t *testing.T, srv *Server, h http.Handler, url string) (struct {
|
||||||
|
HTML string `json:"html"`
|
||||||
|
Findings []secrets.Finding `json:"findings"`
|
||||||
|
}, *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
var doc struct {
|
||||||
|
HTML string `json:"html"`
|
||||||
|
Findings []secrets.Finding `json:"findings"`
|
||||||
|
}
|
||||||
|
rec := getAs(t, srv, h, url)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("render %s: %d %s", url, rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||||
|
t.Fatalf("decode render body %q: %v", rec.Body, err)
|
||||||
|
}
|
||||||
|
return doc, rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderSecretFindings: the scan that refuses to publish the file also
|
||||||
|
// runs on the path every file takes, so the viewer can say what the share
|
||||||
|
// dialog would say (BEA-147).
|
||||||
|
func TestRenderSecretFindings(t *testing.T) {
|
||||||
|
srv, p, _, f, h := shareHub(t)
|
||||||
|
f.put("dev1", "deploy.md", "# Deploy\n\nexport AWS_ACCESS_KEY_ID="+planted+"\n")
|
||||||
|
f.put("dev1", "clean.md", "# Clean\n\nnothing to see\n")
|
||||||
|
base := "/api/p/" + p.ID + "/"
|
||||||
|
|
||||||
|
doc, _ := renderDoc(t, srv, h, base+"render?path=deploy.md")
|
||||||
|
want := []secrets.Finding{{Rule: "aws_access_key_id", Line: 3}}
|
||||||
|
if !reflect.DeepEqual(doc.Findings, want) {
|
||||||
|
t.Fatalf("findings = %+v, want %+v", doc.Findings, want)
|
||||||
|
}
|
||||||
|
// Advisory, not a redaction: the file still renders in full.
|
||||||
|
if !strings.Contains(doc.HTML, "Deploy") {
|
||||||
|
t.Fatalf("the render was suppressed: %q", doc.HTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clean file carries no field at all — omitted, not empty, so the
|
||||||
|
// frontend's check is a plain truthiness test.
|
||||||
|
doc, rec := renderDoc(t, srv, h, base+"render?path=clean.md")
|
||||||
|
if len(doc.Findings) != 0 {
|
||||||
|
t.Fatalf("clean file has findings: %+v", doc.Findings)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "findings") {
|
||||||
|
t.Fatalf("clean render sent an empty findings field: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderSecretNeverEchoed mirrors TestShareSecretNeverEchoed for the new
|
||||||
|
// caller: rule ids and line numbers only, never the matched text.
|
||||||
|
func TestRenderSecretNeverEchoed(t *testing.T) {
|
||||||
|
srv, p, _, f, h := shareHub(t)
|
||||||
|
f.put("dev1", "creds.md", "key = "+planted+"\n")
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
prev := log.Writer()
|
||||||
|
log.SetOutput(&logs)
|
||||||
|
t.Cleanup(func() { log.SetOutput(prev) })
|
||||||
|
|
||||||
|
rec := getAs(t, srv, h, "/api/p/"+p.ID+"/render?path=creds.md")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("render: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
// The rendered HTML is the file, so the key is in the body by design —
|
||||||
|
// what must never appear is a SECOND copy carried by the finding.
|
||||||
|
var doc struct {
|
||||||
|
HTML string `json:"html"`
|
||||||
|
Findings json.RawMessage `json:"findings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(doc.Findings), planted) {
|
||||||
|
t.Errorf("the finding echoed the secret: %s", doc.Findings)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), planted) {
|
||||||
|
t.Errorf("the secret reached the log: %s", logs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderSecretScanLimit: the 1 MiB boundary is a decision, pinned for
|
||||||
|
// minting in shares_test.go and pinned here so the two surfaces cannot
|
||||||
|
// disagree about the same file.
|
||||||
|
func TestRenderSecretScanLimit(t *testing.T) {
|
||||||
|
srv, p, _, f, h := shareHub(t)
|
||||||
|
f.put("dev1", "big.md", strings.Repeat("filler\n", secrets.ScanLimit/7+1)+planted+"\n")
|
||||||
|
base := "/api/p/" + p.ID + "/"
|
||||||
|
|
||||||
|
doc, _ := renderDoc(t, srv, h, base+"render?path=big.md")
|
||||||
|
if len(doc.Findings) != 0 {
|
||||||
|
t.Fatalf("a key past the scan limit was badged: %+v", doc.Findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderVersionSecretFindings: clicking into history on the file the
|
||||||
|
// badge was warning about must not make the warning disappear.
|
||||||
|
func TestRenderVersionSecretFindings(t *testing.T) {
|
||||||
|
srv, p, _, f, h := shareHub(t)
|
||||||
|
f.put("dev1", "deploy.md", "# Deploy\n\nexport AWS_ACCESS_KEY_ID="+planted+"\n")
|
||||||
|
f.put("dev1", "deploy.md", "# Deploy\n\ncleaned up\n")
|
||||||
|
base := "/api/p/" + p.ID + "/"
|
||||||
|
|
||||||
|
rec := getAs(t, srv, h, base+"history?path=deploy.md")
|
||||||
|
var hist struct {
|
||||||
|
Entries []HistoryEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(hist.Entries) < 2 {
|
||||||
|
t.Fatalf("history has %d entries, want the pre-cleanup version too", len(hist.Entries))
|
||||||
|
}
|
||||||
|
old := hist.Entries[len(hist.Entries)-1].Blob
|
||||||
|
|
||||||
|
// Current version is clean...
|
||||||
|
doc, _ := renderDoc(t, srv, h, base+"render?path=deploy.md")
|
||||||
|
if len(doc.Findings) != 0 {
|
||||||
|
t.Fatalf("cleaned-up file has findings: %+v", doc.Findings)
|
||||||
|
}
|
||||||
|
// ...the version that held the key still says so.
|
||||||
|
doc, _ = renderDoc(t, srv, h, base+"render?path=deploy.md&sha="+old)
|
||||||
|
want := []secrets.Finding{{Rule: "aws_access_key_id", Line: 3}}
|
||||||
|
if !reflect.DeepEqual(doc.Findings, want) {
|
||||||
|
t.Fatalf("version findings = %+v, want %+v", doc.Findings, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import (
|
|||||||
|
|
||||||
"github.com/runbear-io/beardrive/internal/journal"
|
"github.com/runbear-io/beardrive/internal/journal"
|
||||||
"github.com/runbear-io/beardrive/internal/remote"
|
"github.com/runbear-io/beardrive/internal/remote"
|
||||||
|
"github.com/runbear-io/beardrive/internal/secrets"
|
||||||
"github.com/runbear-io/beardrive/internal/templates"
|
"github.com/runbear-io/beardrive/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1413,9 +1414,33 @@ func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request)
|
|||||||
if fi.UserName != "" {
|
if fi.UserName != "" {
|
||||||
doc["user_name"] = fi.UserName
|
doc["user_name"] = fi.UserName
|
||||||
}
|
}
|
||||||
|
if f := renderFindings(src); len(f) > 0 {
|
||||||
|
doc["findings"] = f
|
||||||
|
}
|
||||||
writeJSON(w, doc)
|
writeJSON(w, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderFindings is the share gate's credential scan on the path every file
|
||||||
|
// takes. The gate could name the rule and the line well enough to refuse to
|
||||||
|
// publish a file while the viewer rendered the same key as ordinary prose
|
||||||
|
// (BEA-147), so the render response carries the finding too — advisory only,
|
||||||
|
// nothing is blocked and nothing is redacted, since a member who can open the
|
||||||
|
// file could already read the key.
|
||||||
|
//
|
||||||
|
// Rule ids and line numbers only. The matched text must never reach a
|
||||||
|
// response body; see the doc comment on secrets.Scan.
|
||||||
|
//
|
||||||
|
// The cap is a slice rather than the LimitReader the two streaming callers
|
||||||
|
// use: the render path already holds the whole file, because that is what
|
||||||
|
// RenderMarkdown needs. Same ScanLimit either way, so the badge and the share
|
||||||
|
// dialog can never disagree about the same file.
|
||||||
|
func renderFindings(src []byte) []secrets.Finding {
|
||||||
|
if len(src) > secrets.ScanLimit {
|
||||||
|
src = src[:secrets.ScanLimit]
|
||||||
|
}
|
||||||
|
return secrets.Scan(src)
|
||||||
|
}
|
||||||
|
|
||||||
// renderVersion renders one exact past version by content hash — the
|
// renderVersion renders one exact past version by content hash — the
|
||||||
// markdown counterpart of /blob?sha=, so opening an old .md from history
|
// markdown counterpart of /blob?sha=, so opening an old .md from history
|
||||||
// shows a rendered page instead of raw source. Provenance is not returned:
|
// shows a rendered page instead of raw source. Provenance is not returned:
|
||||||
@@ -1446,9 +1471,16 @@ func (s *Server) renderVersion(v *volume, w http.ResponseWriter, r *http.Request
|
|||||||
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("render: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, map[string]any{
|
doc := map[string]any{
|
||||||
"path": r.URL.Query().Get("path"), "html": html, "size": len(src),
|
"path": r.URL.Query().Get("path"), "html": html, "size": len(src),
|
||||||
})
|
}
|
||||||
|
// The history view goes through this same endpoint, so scanning here too
|
||||||
|
// is what stops the badge vanishing the moment you click into history on
|
||||||
|
// the very file it was warning about.
|
||||||
|
if f := renderFindings(src); len(f) > 0 {
|
||||||
|
doc["findings"] = f
|
||||||
|
}
|
||||||
|
writeJSON(w, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// inlineMarkup reports whether a Content-Type names something the browser
|
// inlineMarkup reports whether a Content-Type names something the browser
|
||||||
|
|||||||
+30
-30
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>BearDrive</title>
|
<title>BearDrive</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||||
<script type="module" crossorigin src="/assets/index-BysAiMHJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-C17b7d2I.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
|
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
|
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bg9aVdbo.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DISTZ6FW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user