Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ touchstone scan --sbom vendor-sbom.json https://vendor.example
touchstone scan --sbom-url https://vendor.example/sbom.json https://vendor.example
```

The web page has the same two inputs: an upload field and a URL field.
The web page has the same two inputs: an upload field and a URL field. The
form rejects an uploaded document over 2 MiB before sending it, and a
component built from the supplied SBOM/VEX shows as High confidence with
`SBOM_SUPPLIED` evidence in the report.

### Web page: `touchstone serve`

Expand Down
26 changes: 21 additions & 5 deletions web/src/components/scan-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import { useState, type FormEvent } from "react";
import type { ScanInput } from "../lib/api";
import { isScannableUrl } from "../lib/url";

/** The server's cap on an uploaded SBOM/VEX document (D1). */
export const MAX_SBOM_DOCUMENT_BYTES = 2 * 1024 * 1024;

/**
* One URL input, plus an optional SBOM/VEX the reviewer already has, by URL or
* upload. A value that is not an absolute http(s) URL is refused here, before
* any request.
* upload. A value that is not an absolute http(s) URL, or a file over the
* cap, is refused here, before any request.
*/
export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void; busy: boolean }) {
const [value, setValue] = useState("");
const [sbomUrl, setSbomUrl] = useState("");
const [sbomFile, setSbomFile] = useState<File | undefined>(undefined);
const [invalid, setInvalid] = useState(false);
const [sbomUrlInvalid, setSbomUrlInvalid] = useState(false);
const [fileTooLarge, setFileTooLarge] = useState(false);
const [fileError, setFileError] = useState(false);

async function submit(event: FormEvent<HTMLFormElement>) {
Expand All @@ -23,10 +27,12 @@ export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void;
const supplied = sbomUrl.trim();
const ok = isScannableUrl(url);
const suppliedOk = supplied === "" || isScannableUrl(supplied);
const tooLarge = sbomFile !== undefined && sbomFile.size > MAX_SBOM_DOCUMENT_BYTES;
setInvalid(!ok);
setSbomUrlInvalid(!suppliedOk);
setFileTooLarge(tooLarge);
setFileError(false);
if (!ok || !suppliedOk) {
if (!ok || !suppliedOk || tooLarge) {
return;
}
const input: ScanInput = { url };
Expand Down Expand Up @@ -104,12 +110,22 @@ export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void;
name="sbomDocument"
type="file"
accept=".json,application/json"
onChange={(e) => setSbomFile(e.target.files?.[0])}
onChange={(e) => {
setSbomFile(e.target.files?.[0]);
setFileTooLarge(false);
}}
aria-invalid={fileTooLarge}
aria-describedby={fileTooLarge ? "sbom-file-error" : undefined}
className="text-sm"
/>
</div>
{fileTooLarge && (
<p id="sbom-file-error" className="text-sm text-red-700">
The SBOM/VEX file is too large; the limit is 2 MiB.
</p>
)}
{fileError && <p className="text-sm text-red-700">The SBOM/VEX file could not be read.</p>}
<p className="text-xs text-slate-500">CycloneDX, SPDX 2.3, OpenVEX or CSAF, as JSON.</p>
<p className="text-xs text-slate-500">CycloneDX, SPDX 2.3, OpenVEX or CSAF, as JSON, up to 2 MiB.</p>
</fieldset>
</form>
);
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/scan-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ describe("ScanPage", () => {
expect(fetchMock.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ url: "https://example.com", sbomDocument: "e30=" }));
});

it("refuses an SBOM/VEX file over 2 MiB without sending a request", () => {
const fetchMock = stubFetch(async () => new Response(goldenText));
render(<ScanPage />);

const big = new File([new Uint8Array(2 * 1024 * 1024 + 1)], "big-sbom.json", { type: "application/json" });
fireEvent.change(screen.getByLabelText("SBOM/VEX file"), { target: { files: [big] } });
scanFor("https://example.com");

expect(screen.getByText(/too large/)).toBeTruthy();
expect(fetchMock).not.toHaveBeenCalled();
});

it("shows the gateway's message as an error, not an empty report", async () => {
stubFetch(
async () => new Response('{"code":13,"message":"internal error","details":[]}', { status: 500 }),
Expand Down
Loading