├── CNAME
├── CODEOWNERS
├── src
├── __mocks__
│ ├── styleMock.ts
│ ├── fileMock.ts
│ ├── prismMock.ts
│ ├── next
│ │ └── router.ts
│ ├── decodex509Mock.ts
│ └── atobMock.ts
├── modules
│ ├── utils
│ │ └── date.ts
│ ├── x509
│ │ ├── constants.ts
│ │ ├── decode.ts
│ │ ├── decode.test.ts
│ │ └── extensions.ts
│ ├── components
│ │ ├── SearchForm.test.tsx
│ │ ├── Intoto001.test.tsx
│ │ ├── Settings.test.tsx
│ │ ├── Intoto002.test.tsx
│ │ ├── DSSE.test.tsx
│ │ ├── HashedRekord.test.tsx
│ │ ├── DSSE.tsx
│ │ ├── Intoto001.tsx
│ │ ├── HashedRekord.tsx
│ │ ├── Intoto002.tsx
│ │ ├── Settings.tsx
│ │ ├── Explorer.test.tsx
│ │ ├── Entry.test.tsx
│ │ ├── SearchForm.tsx
│ │ ├── Explorer.tsx
│ │ └── Entry.tsx
│ ├── theme
│ │ └── theme.ts
│ └── api
│ │ ├── rekor_api.test.ts
│ │ ├── context.test.tsx
│ │ ├── context.tsx
│ │ └── rekor_api.ts
└── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ └── index.tsx
├── .eslintrc.json
├── .prettierrc.yaml
├── public
├── logo.png
├── github.svg
├── chainguard-logo.svg
└── sigstore_rekor-horizontal-color.svg
├── assets
└── screenshot2.png
├── .prettierignore
├── next.config.js
├── jest.setup.js
├── next-env.d.ts
├── tsconfig.test.json
├── .gitignore
├── .github
├── workflows
│ ├── unit-tests.yaml
│ ├── format.yaml
│ ├── reviewdog.yaml
│ └── nextjs.yml
└── dependabot.yml
├── tsconfig.json
├── README.md
├── jest.config.js
├── package.json
└── LICENSE
/CNAME:
--------------------------------------------------------------------------------
1 | search.sigstore.dev
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @sigstore/codeowners-rekor-search-ui
2 |
--------------------------------------------------------------------------------
/src/__mocks__/styleMock.ts:
--------------------------------------------------------------------------------
1 | module.exports = {};
2 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/src/__mocks__/fileMock.ts:
--------------------------------------------------------------------------------
1 | module.exports = "test-file-stub";
2 |
--------------------------------------------------------------------------------
/.prettierrc.yaml:
--------------------------------------------------------------------------------
1 | useTabs: true
2 | arrowParens: avoid
3 | singleAttributePerLine: true
4 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sigstore/rekor-search-ui/HEAD/public/logo.png
--------------------------------------------------------------------------------
/src/__mocks__/prismMock.ts:
--------------------------------------------------------------------------------
1 | jest.mock("react-syntax-highlighter/dist/cjs/styles/prism", () => ({}));
2 |
--------------------------------------------------------------------------------
/assets/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sigstore/rekor-search-ui/HEAD/assets/screenshot2.png
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | build*
3 | node_modules*
4 | .next*
5 | **/generated/**
6 | LICENSE
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | output: "export",
5 | };
6 |
7 | module.exports = nextConfig;
8 |
--------------------------------------------------------------------------------
/src/modules/utils/date.ts:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 |
3 | export function toRelativeDateString(date: Date) {
4 | return `${moment().to(date)} (${moment(date).format()})`;
5 | }
6 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import type { AppProps } from "next/app";
2 |
3 | function App({ Component, pageProps }: AppProps) {
4 | return ;
5 | }
6 |
7 | export default App;
8 |
--------------------------------------------------------------------------------
/src/__mocks__/next/router.ts:
--------------------------------------------------------------------------------
1 | const useRouter = jest.fn(() => ({
2 | push: jest.fn(),
3 | pathname: "/",
4 | query: {},
5 | asPath: "",
6 | }));
7 |
8 | module.exports = {
9 | useRouter,
10 | };
11 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | // learn more: https://github.com/testing-library/jest-dom
2 | import "@testing-library/jest-dom";
3 | import { TextEncoder, TextDecoder } from "util";
4 |
5 | Object.assign(global, { TextDecoder, TextEncoder });
6 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/src/__mocks__/decodex509Mock.ts:
--------------------------------------------------------------------------------
1 | const decodex509Mock = jest.fn().mockReturnValue({
2 | publicKey:
3 | "-----BEGIN CERTIFICATE-----Mocked Certificate-----END CERTIFICATE-----",
4 | subject: "Mocked Subject",
5 | });
6 |
7 | export default decodex509Mock;
8 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "isolatedModules": false
5 | },
6 | "include": [
7 | "next-env.d.ts",
8 | "**/*.ts",
9 | "**/*.tsx",
10 | ".next/types/**/*.ts",
11 | "jest.setup.js"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from "next/document";
2 |
3 | class AppDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 | }
16 |
17 | export default AppDocument;
18 |
--------------------------------------------------------------------------------
/src/__mocks__/atobMock.ts:
--------------------------------------------------------------------------------
1 | const atobMock = () => {
2 | window.atob = jest.fn().mockImplementation(str => {
3 | const base64Pattern =
4 | /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/;
5 |
6 | if (!base64Pattern.test(str)) {
7 | // return if string is not base64 encoded
8 | return str;
9 | }
10 |
11 | return Buffer.from(str, "base64").toString("utf-8");
12 | });
13 | };
14 |
15 | export default atobMock;
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 | /.next
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | **/.idea/
27 |
28 | **/.vscode/
29 |
30 | **/.DS_Store
31 |
--------------------------------------------------------------------------------
/.github/workflows/unit-tests.yaml:
--------------------------------------------------------------------------------
1 | name: 🧪 Unit Tests (Jest)
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | build:
15 | name: 🧪 Unit Tests (Jest)
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
19 | with:
20 | persist-credentials: false
21 | - name: 📦 Install modules
22 | run: npm install
23 | - name: ⚙️ Run tests
24 | run: npm run test --coverage
25 |
--------------------------------------------------------------------------------
/src/modules/x509/constants.ts:
--------------------------------------------------------------------------------
1 | import { KeyUsageFlags } from "@peculiar/x509";
2 |
3 | export const KEY_USAGE_NAMES: Record = {
4 | [KeyUsageFlags.digitalSignature]: "Digital Signature",
5 | [KeyUsageFlags.nonRepudiation]: "Non Repudiation",
6 | [KeyUsageFlags.keyEncipherment]: "Key Encipherment",
7 | [KeyUsageFlags.dataEncipherment]: "Data Encipherment",
8 | [KeyUsageFlags.keyAgreement]: "Key Agreement",
9 | [KeyUsageFlags.keyCertSign]: "Key Certificate Sign",
10 | [KeyUsageFlags.cRLSign]: "Certificate Revocation List Sign",
11 | [KeyUsageFlags.encipherOnly]: "Encipher Only",
12 | [KeyUsageFlags.decipherOnly]: "Decipher Only",
13 | };
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": false,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "noImplicitAny": true,
18 | "noImplicitThis": true,
19 | "strictNullChecks": true,
20 | "downlevelIteration": true
21 | },
22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
23 | "exclude": ["node_modules"]
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/format.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2022 Chainguard, Inc.
2 | # SPDX-License-Identifier: Apache-2.0
3 |
4 | name: Format
5 |
6 | on:
7 | pull_request:
8 | branches: ["main"]
9 |
10 | permissions: {}
11 |
12 | jobs:
13 | prettier:
14 | name: Prettier
15 | runs-on: ubuntu-latest
16 | permissions:
17 | contents: read
18 |
19 | steps:
20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
21 | with:
22 | persist-credentials: false
23 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
24 | with:
25 | node-version-file: "package.json"
26 | - name: Check prettify code
27 | run: |
28 | npm ci
29 | npx prettier --check .
30 |
--------------------------------------------------------------------------------
/src/modules/components/SearchForm.test.tsx:
--------------------------------------------------------------------------------
1 | import { SearchForm } from "./SearchForm";
2 | import { render, screen } from "@testing-library/react";
3 | import { RekorClientProvider } from "../api/context";
4 |
5 | describe("SearchForm", () => {
6 | it("should render form with default values", () => {
7 | render(
8 |
9 |
14 | ,
15 | );
16 |
17 | expect(screen.getByLabelText("Attribute")).toBeInTheDocument();
18 | expect(screen.getByRole("textbox", { name: /email/i })).toBeInTheDocument();
19 | expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("");
20 | expect(screen.getByRole("button", { name: "Search" })).toBeInTheDocument();
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/modules/theme/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from "@mui/material/styles";
2 |
3 | export const REKOR_SEARCH_THEME = createTheme({
4 | typography: {
5 | fontFamily: ["Helvetica Neue", "Arial", "sans-serif"].join(","),
6 | h4: {
7 | fontWeight: 300,
8 | fontSize: "1.8rem",
9 | color: "#2e2f71",
10 | },
11 | h5: {
12 | fontWeight: 300,
13 | fontSize: "0.9rem",
14 | color: "#444444",
15 | },
16 | },
17 | palette: {
18 | background: {
19 | default: "#F8F7FD",
20 | },
21 | primary: {
22 | main: "#444CE4",
23 | },
24 | secondary: {
25 | main: "#B938B4",
26 | },
27 | info: {
28 | main: "#2e2f71",
29 | },
30 | error: {
31 | main: "#B81E22",
32 | },
33 | },
34 | components: {
35 | MuiCssBaseline: {
36 | styleOverrides: `
37 | pre {
38 | font-size: 0.8rem;
39 | margin: 0 !important;
40 | }
41 | `,
42 | },
43 | MuiPaper: {
44 | defaultProps: {
45 | variant: "outlined",
46 | },
47 | },
48 | },
49 | });
50 |
--------------------------------------------------------------------------------
/public/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/modules/api/rekor_api.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react";
2 | import { useRekorSearch } from "./rekor_api";
3 | import { useRekorClient } from "./context";
4 |
5 | jest.mock("./context", () => ({
6 | useRekorClient: jest.fn(),
7 | }));
8 |
9 | Object.defineProperty(global.self, "crypto", {
10 | value: {
11 | subtle: {
12 | digest: jest.fn().mockImplementation(async () => {
13 | const hashBuffer = new ArrayBuffer(32);
14 | const hashArray = new Uint8Array(hashBuffer);
15 | hashArray.fill(0);
16 | return hashBuffer;
17 | }),
18 | },
19 | },
20 | });
21 |
22 | describe("useRekorSearch", () => {
23 | it("searches by logIndex", async () => {
24 | const mockGetLogEntryByIndex = jest.fn().mockResolvedValue(0);
25 |
26 | (useRekorClient as jest.Mock).mockReturnValue({
27 | entries: { getLogEntryByIndex: mockGetLogEntryByIndex },
28 | });
29 |
30 | const { result } = renderHook(() => useRekorSearch());
31 |
32 | await result.current({ attribute: "logIndex", query: 123 });
33 |
34 | expect(mockGetLogEntryByIndex).toHaveBeenCalledWith({ logIndex: 123 });
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | #
2 | # Copyright 2024 The Sigstore Authors.
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 |
16 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
17 | version: 2
18 | updates:
19 | - package-ecosystem: "github-actions"
20 | directory: "/"
21 | schedule:
22 | interval: "weekly"
23 | groups:
24 | all:
25 | update-types:
26 | - "minor"
27 | - "patch"
28 | - package-ecosystem: "npm"
29 | directory: "/"
30 | schedule:
31 | interval: "weekly"
32 | groups:
33 | all:
34 | update-types:
35 | - "minor"
36 | - "patch"
37 |
--------------------------------------------------------------------------------
/src/modules/components/Intoto001.test.tsx:
--------------------------------------------------------------------------------
1 | import atobMock from "../../__mocks__/atobMock";
2 | import decodex509Mock from "../../__mocks__/decodex509Mock";
3 |
4 | import { render, screen } from "@testing-library/react";
5 | import { IntotoViewer001 } from "./Intoto001";
6 |
7 | const pemCertificate = `-----BEGIN CERTIFICATE-----\n${Buffer.from(
8 | "Mocked Public Key",
9 | ).toString("base64")}\n-----END CERTIFICATE-----`;
10 |
11 | jest.mock("../x509/decode", () => ({
12 | decodex509: decodex509Mock,
13 | }));
14 |
15 | describe("IntotoViewer001", () => {
16 | beforeAll(() => {
17 | atobMock();
18 | });
19 |
20 | afterAll(() => {
21 | jest.restoreAllMocks();
22 | });
23 |
24 | it("should render the payload hash and provide a link to the hash page", () => {
25 | const intoto = {
26 | content: {
27 | payloadHash: {
28 | algorithm: "sha256",
29 | value: "abc123",
30 | },
31 | },
32 | publicKey: "123",
33 | };
34 |
35 | // @ts-ignore
36 | render( );
37 |
38 | const hashLink = screen.getByText("Hash");
39 | expect(hashLink).toBeInTheDocument();
40 |
41 | const hashValue = screen.getByText("sha256:abc123");
42 | expect(hashValue).toBeInTheDocument();
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This repo contains a simple UI for searching Search the Rekor public transparency log.
2 |
3 | https://search.sigstore.dev/
4 |
5 | 
6 |
7 | ## Getting Started
8 |
9 | First, run the development server:
10 |
11 | ```bash
12 | npm run dev
13 | ```
14 |
15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
16 |
17 | ## Tests
18 |
19 | ### Unit Tests
20 |
21 | Run all [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro) unit tests:
22 |
23 | ```bash
24 | npm run test
25 | ```
26 |
27 | Launches the test runner in the interactive watch mode.
28 |
29 | Tests are co-located and live as closely to corresponding code as possible.
30 |
31 | ## Deploy
32 |
33 | The app is based on [Next.JS](https://nextjs.org/) and is automatically built & deployed to GitHub Pages when pushing to the `main` branch.
34 |
35 | ## Internal Server Configuration
36 |
37 | This app supports overriding of the default rekor server instance for those running private instances of the the sigstore stack.
38 | Create a `.env.local` file at the root and include in it this environment variable
39 |
40 | ```properties
41 | NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN=https://privaterekor.sigstore.dev
42 | ```
43 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const nextJest = require("next/jest");
2 |
3 | const createJestConfig = nextJest({
4 | dir: "./",
5 | });
6 |
7 | /** @type {import('jest').Config} */
8 | const config = {
9 | // automatically clear mock calls and instances between every test
10 | clearMocks: true,
11 |
12 | // whether the coverage information should be collected while executing the test
13 | collectCoverage: true,
14 |
15 | // directory where Jest should output its coverage files
16 | coverageDirectory: "coverage",
17 | coverageProvider: "v8",
18 |
19 | globals: {
20 | "ts-jest": {
21 | tsconfig: "/tsconfig.test.json",
22 | },
23 | },
24 |
25 | moduleNameMapper: {
26 | // handle module aliases
27 | "^@/components/(.*)$": "/components/$1",
28 | },
29 |
30 | // add more setup options before each test is run
31 | setupFilesAfterEnv: ["/jest.setup.js"],
32 |
33 | testEnvironment: "jest-environment-jsdom",
34 |
35 | testPathIgnorePatterns: ["/node_modules/", "/.next/"],
36 |
37 | transformIgnorePatterns: [
38 | "/node_modules/",
39 | "^.+\\.module\\.(css|sass|scss)$",
40 | ],
41 | verbose: true,
42 | };
43 |
44 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
45 | module.exports = createJestConfig(config);
46 |
--------------------------------------------------------------------------------
/src/modules/components/Settings.test.tsx:
--------------------------------------------------------------------------------
1 | jest.mock("../api/context", () => ({
2 | useRekorBaseUrl: jest.fn(),
3 | }));
4 |
5 | jest.mock("next/config", () => () => ({
6 | publicRuntimeConfig: {
7 | NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN: "https://default.rekor.domain",
8 | },
9 | }));
10 |
11 | import { render, screen } from "@testing-library/react";
12 | import { Settings } from "./Settings";
13 | import { useRekorBaseUrl } from "../api/context";
14 |
15 | describe("Settings Component", () => {
16 | const mockOnClose = jest.fn();
17 | const mockSetBaseUrl = jest.fn();
18 |
19 | beforeEach(() => {
20 | jest.clearAllMocks();
21 | // mock initial state & updater function returned by useRekorBaseUrl
22 | (useRekorBaseUrl as jest.Mock).mockReturnValue([
23 | "https://initial.rekor.domain",
24 | mockSetBaseUrl,
25 | ]);
26 | });
27 |
28 | afterEach(() => {
29 | jest.restoreAllMocks();
30 | });
31 |
32 | it("renders correctly with initial context value", () => {
33 | render(
34 | ,
38 | );
39 | expect(screen.getByText("Override rekor endpoint")).toBeInTheDocument();
40 | expect(
41 | screen.getByPlaceholderText("https://initial.rekor.domain"),
42 | ).toBeInTheDocument();
43 | expect(screen.getByText("Save")).toBeInTheDocument();
44 | expect(screen.getByText("Cancel")).toBeInTheDocument();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/modules/api/context.test.tsx:
--------------------------------------------------------------------------------
1 | const originalEnv = process.env;
2 |
3 | beforeEach(() => {
4 | jest.resetModules();
5 | process.env = {
6 | ...originalEnv,
7 | NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN: "https://example.com",
8 | };
9 | });
10 |
11 | afterEach(() => {
12 | process.env = originalEnv;
13 | });
14 |
15 | import { fireEvent, render, screen } from "@testing-library/react";
16 | import {
17 | RekorClientProvider,
18 | useRekorClient,
19 | useRekorBaseUrl,
20 | } from "./context";
21 |
22 | const TestConsumerComponent = () => {
23 | useRekorClient();
24 | const [baseUrl, setBaseUrl] = useRekorBaseUrl();
25 |
26 | return (
27 |
28 |
setBaseUrl("https://new.example.com")}>
29 | Change Base URL
30 |
31 |
Base URL: {baseUrl}
32 |
33 | );
34 | };
35 |
36 | describe("RekorClientContext", () => {
37 | beforeAll(() => jest.clearAllMocks());
38 |
39 | it("provides a RekorClient instance and manages base URL", async () => {
40 | render(
41 |
42 |
43 | ,
44 | );
45 |
46 | expect(
47 | screen.getByText(/Base URL: https:\/\/example.com/),
48 | ).toBeInTheDocument();
49 |
50 | fireEvent.click(screen.getByText(/Change Base URL/));
51 |
52 | expect(
53 | screen.getByText(/Base URL: https:\/\/new.example.com/),
54 | ).toBeInTheDocument();
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/modules/x509/decode.ts:
--------------------------------------------------------------------------------
1 | import { X509Certificate } from "@peculiar/x509";
2 | import { toRelativeDateString } from "../utils/date";
3 | import { EXTENSIONS_CONFIG } from "./extensions";
4 |
5 | function bufferToHex(buffer: ArrayBuffer): string {
6 | return [...new Uint8Array(buffer)]
7 | .map(x => x.toString(16).padStart(2, "0"))
8 | .join(":");
9 | }
10 |
11 | export function decodex509(rawCertificate: string) {
12 | const cert = new X509Certificate(rawCertificate);
13 |
14 | const decodedExtensions: Record = {};
15 | for (const extension of cert.extensions) {
16 | const criticalLabel = extension.critical ? " (critical)" : "";
17 |
18 | const config = EXTENSIONS_CONFIG[extension.type];
19 | if (config) {
20 | decodedExtensions[`${config.name}${criticalLabel}`] =
21 | config.toJSON(extension);
22 | } else {
23 | const text = bufferToHex(extension.value);
24 | decodedExtensions[`${extension.type}${criticalLabel}`] = text;
25 | }
26 | }
27 |
28 | const decodedCert = {
29 | data: {
30 | "Serial Number": `0x${cert.serialNumber}`,
31 | },
32 | Signature: {
33 | Issuer: cert.issuer,
34 | Validity: {
35 | "Not Before": toRelativeDateString(cert.notBefore),
36 | "Not After": toRelativeDateString(cert.notAfter),
37 | },
38 | Algorithm: cert.publicKey.algorithm,
39 | Subject: cert.subjectName,
40 | },
41 | "X509v3 extensions": decodedExtensions,
42 | };
43 | return decodedCert;
44 | }
45 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rekor-search-ui",
3 | "version": "0.1.0",
4 | "engines": {
5 | "node": "18.x"
6 | },
7 | "scripts": {
8 | "dev": "next dev",
9 | "build": "next build",
10 | "start": "next start",
11 | "lint": "next lint",
12 | "generate": "./hack/generate_types.sh",
13 | "test": "jest",
14 | "test:watch": "jest --watch",
15 | "test:ci": "jest --ci"
16 | },
17 | "dependencies": {
18 | "@emotion/react": "^11.14.0",
19 | "@emotion/styled": "^11.14.0",
20 | "@mui/icons-material": "^5.4.4",
21 | "@mui/lab": "5.0.0-alpha.177",
22 | "@mui/material": "^5.5.0",
23 | "@peculiar/x509": "1.14.2",
24 | "js-yaml": "^4.1.1",
25 | "moment": "^2.30.1",
26 | "next": "^15.4.8",
27 | "pvtsutils": "^1.3.2",
28 | "react": "^18.0.0-rc.1",
29 | "react-dom": "^18.0.0-rc.1",
30 | "react-error-boundary": "^3.1.4",
31 | "react-hook-form": "^7.66.1",
32 | "react-syntax-highlighter": "^15.6.1",
33 | "rekor": "^0.2.0",
34 | "sass": "^1.94.2"
35 | },
36 | "devDependencies": {
37 | "@babel/core": "^7.28.5",
38 | "@codecov/webpack-plugin": "^1.9.1",
39 | "@testing-library/jest-dom": "^6.9.1",
40 | "@testing-library/react": "^14.2.1",
41 | "@testing-library/user-event": "^14.5.2",
42 | "@types/jest": "^29.5.14",
43 | "@types/js-yaml": "^4.0.9",
44 | "@types/node": "^18.11.18",
45 | "@types/react": "18.0.9",
46 | "@types/react-syntax-highlighter": "^15.5.13",
47 | "caniuse-lite": "^1.0.30001756",
48 | "eslint": "8.10.0",
49 | "eslint-config-next": "13.0.0",
50 | "jest": "^29.7.0",
51 | "jest-environment-jsdom": "^29.7.0",
52 | "prettier": "^3.4.2",
53 | "typescript": "^5.9.3"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/modules/components/Intoto002.test.tsx:
--------------------------------------------------------------------------------
1 | import atobMock from "../../__mocks__/atobMock";
2 | import decodex509Mock from "../../__mocks__/decodex509Mock";
3 |
4 | import { render, screen } from "@testing-library/react";
5 | import { IntotoViewer002 } from "./Intoto002";
6 | import { IntotoV002Schema } from "rekor";
7 |
8 | const pemCertificate = `-----BEGIN CERTIFICATE-----\n${Buffer.from(
9 | "Mocked Public Key",
10 | ).toString("base64")}\n-----END CERTIFICATE-----`;
11 |
12 | jest.mock("../x509/decode", () => ({
13 | decodex509: decodex509Mock,
14 | }));
15 |
16 | describe("IntotoViewer", () => {
17 | beforeAll(() => {
18 | atobMock();
19 | });
20 |
21 | afterAll(() => {
22 | jest.restoreAllMocks();
23 | });
24 |
25 | const mockIntoto: IntotoV002Schema = {
26 | content: {
27 | envelope: {
28 | payloadType: "application/vnd.in-toto+json",
29 | signatures: [
30 | {
31 | publicKey: pemCertificate,
32 | sig: Buffer.from("signature content", "utf-8").toString("base64"),
33 | },
34 | ],
35 | },
36 | payloadHash: {
37 | algorithm: "sha256",
38 | value: "hashValue",
39 | },
40 | },
41 | };
42 |
43 | it("renders the component with payload hash, signature, and certificate", () => {
44 | render( );
45 |
46 | // verify the hash link is rendered correctly
47 | expect(screen.getByText("Hash")).toBeInTheDocument();
48 | expect(screen.getByText("sha256:hashValue")).toBeInTheDocument();
49 |
50 | // verify the signature is rendered & decoded
51 | expect(screen.getByText("signature content")).toBeInTheDocument();
52 | expect(screen.getByText(/BEGIN CERTIFICATE/)).toBeInTheDocument();
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/modules/components/DSSE.test.tsx:
--------------------------------------------------------------------------------
1 | import atobMock from "../../__mocks__/atobMock";
2 | import decodex509Mock from "../../__mocks__/decodex509Mock";
3 |
4 | jest.mock("next/router");
5 |
6 | jest.mock("../x509/decode", () => ({
7 | decodex509: decodex509Mock,
8 | }));
9 |
10 | import { render, screen } from "@testing-library/react";
11 | import "@testing-library/jest-dom";
12 | import { DSSEViewer } from "./DSSE";
13 | import { DSSEV001Schema } from "rekor";
14 |
15 | describe("DSSEViewer Component", () => {
16 | beforeAll(() => {
17 | jest.clearAllMocks();
18 | atobMock();
19 | });
20 |
21 | afterAll(() => {
22 | jest.restoreAllMocks();
23 | });
24 |
25 | const mockDSSE: DSSEV001Schema = {
26 | payloadHash: {
27 | algorithm: "sha256",
28 | value: "exampleHashValue",
29 | },
30 | signatures: [
31 | {
32 | signature: "exampleSignature",
33 | verifier:
34 | "-----BEGIN CERTIFICATE-----\nexamplePublicKey\n-----END CERTIFICATE-----",
35 | },
36 | ],
37 | };
38 |
39 | it("renders without crashing", () => {
40 | render( );
41 | expect(screen.getByText("Hash")).toBeInTheDocument();
42 | });
43 |
44 | it("displays the payload hash correctly", () => {
45 | render( );
46 | expect(
47 | screen.getByText(
48 | `${mockDSSE.payloadHash?.algorithm}:${mockDSSE.payloadHash?.value}`,
49 | ),
50 | ).toBeInTheDocument();
51 | });
52 |
53 | it("displays the signature correctly", () => {
54 | render( );
55 | expect(
56 | screen.getByText(mockDSSE.signatures![0].signature),
57 | ).toBeInTheDocument();
58 | });
59 |
60 | it("displays the public key certificate title and content correctly", () => {
61 | render( );
62 | expect(screen.getByText("Public Key Certificate")).toBeInTheDocument();
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/src/modules/components/HashedRekord.test.tsx:
--------------------------------------------------------------------------------
1 | jest.mock("next/router");
2 | jest.mock("react-syntax-highlighter/dist/cjs/styles/prism");
3 |
4 | import decodex509Mock from "../../__mocks__/decodex509Mock";
5 |
6 | jest.mock("../x509/decode", () => ({
7 | decodex509: decodex509Mock,
8 | }));
9 |
10 | import { HashedRekordViewer } from "./HashedRekord";
11 | import { render, screen } from "@testing-library/react";
12 | import { HashedRekorV001Schema } from "rekor";
13 |
14 | describe("HashedRekordViewer", () => {
15 | it("renders the component with a public key", () => {
16 | const mockedRekord: HashedRekorV001Schema = {
17 | data: {
18 | hash: {
19 | algorithm: "sha256",
20 | value: "mockedHashValue",
21 | },
22 | },
23 | signature: {
24 | content: "mockedSignatureContent",
25 | publicKey: {
26 | content: window.btoa("mockedPublicKeyContent"), // base64 encode
27 | },
28 | },
29 | };
30 |
31 | render( );
32 |
33 | expect(screen.getByText("Hash")).toBeInTheDocument();
34 | expect(screen.getByText("sha256:mockedHashValue")).toBeInTheDocument();
35 | expect(screen.getByText("mockedSignatureContent")).toBeInTheDocument();
36 | expect(screen.getByText("mockedPublicKeyContent")).toBeInTheDocument();
37 | });
38 |
39 | it("renders the component with a public key certificate", () => {
40 | const mockedRekordWithCert = {
41 | // simulate a certificate
42 | data: {},
43 | signature: {
44 | publicKey: {
45 | content: window.btoa(
46 | "-----BEGIN CERTIFICATE-----certContent-----END CERTIFICATE-----",
47 | ), // base64 encode
48 | },
49 | },
50 | };
51 |
52 | render( );
53 |
54 | expect(
55 | screen.getByText(
56 | /'-----BEGIN CERTIFICATE-----Mocked Certificate-----END CERTIFICATE-----'/,
57 | ),
58 | ).toBeInTheDocument();
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/modules/components/DSSE.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Link, Typography } from "@mui/material";
2 | import { dump } from "js-yaml";
3 | import NextLink from "next/link";
4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5 | import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
6 | import { DSSEV001Schema } from "rekor";
7 | import { decodex509 } from "../x509/decode";
8 |
9 | export function DSSEViewer({ dsse }: { dsse: DSSEV001Schema }) {
10 | const sig = dsse.signatures?.[0];
11 | const certContent = window.atob(sig?.verifier || "");
12 |
13 | const publicKey = {
14 | title: "Public Key",
15 | content: certContent,
16 | };
17 | if (certContent.includes("BEGIN CERTIFICATE")) {
18 | publicKey.title = "Public Key Certificate";
19 | publicKey.content = dump(decodex509(certContent), {
20 | noArrayIndent: true,
21 | lineWidth: -1,
22 | });
23 | }
24 |
25 | return (
26 |
27 |
31 |
35 | Hash
36 |
37 |
38 |
39 |
43 | {`${dsse.payloadHash?.algorithm}:${dsse.payloadHash?.value}`}
44 |
45 |
46 |
50 | Signature
51 |
52 |
56 | {sig?.signature || ""}
57 |
58 |
62 | {publicKey.title}
63 |
64 |
68 | {publicKey.content}
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/modules/components/Intoto001.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Link, Typography } from "@mui/material";
2 | import { dump } from "js-yaml";
3 | import NextLink from "next/link";
4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5 | import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
6 | import { IntotoV001Schema } from "rekor";
7 | import { decodex509 } from "../x509/decode";
8 |
9 | export function IntotoViewer001({ intoto }: { intoto: IntotoV001Schema }) {
10 | const certContent = window.atob(intoto.publicKey || "");
11 |
12 | const publicKey = {
13 | title: "Public Key",
14 | content: certContent,
15 | };
16 | if (certContent.includes("BEGIN CERTIFICATE")) {
17 | publicKey.title = "Public Key Certificate";
18 | publicKey.content = dump(decodex509(certContent), {
19 | noArrayIndent: true,
20 | lineWidth: -1,
21 | });
22 | }
23 |
24 | return (
25 |
26 |
30 |
34 | Hash
35 |
36 |
37 |
38 |
42 | {`${intoto.content.payloadHash?.algorithm}:${intoto.content.payloadHash?.value}`}
43 |
44 |
45 |
49 | Signature
50 |
51 |
55 | {"Missing for intoto v0.0.1 entries"}
56 |
57 |
61 | {publicKey.title}
62 |
63 |
67 | {publicKey.content}
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/modules/components/HashedRekord.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Link, Typography } from "@mui/material";
2 | import { dump } from "js-yaml";
3 | import NextLink from "next/link";
4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5 | import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
6 | import { RekorSchema } from "rekor";
7 | import { decodex509 } from "../x509/decode";
8 |
9 | export function HashedRekordViewer({
10 | hashedRekord,
11 | }: {
12 | hashedRekord: RekorSchema;
13 | }) {
14 | const certContent = window.atob(
15 | hashedRekord.signature.publicKey?.content || "",
16 | );
17 |
18 | const publicKey = {
19 | title: "Public Key",
20 | content: certContent,
21 | };
22 | if (certContent.includes("BEGIN CERTIFICATE")) {
23 | publicKey.title = "Public Key Certificate";
24 | publicKey.content = dump(decodex509(certContent), {
25 | noArrayIndent: true,
26 | lineWidth: -1,
27 | });
28 | }
29 |
30 | return (
31 |
32 |
36 |
41 | Hash
42 |
43 |
44 |
45 |
49 | {`${hashedRekord.data.hash?.algorithm}:${hashedRekord.data.hash?.value}`}
50 |
51 |
52 |
56 | Signature
57 |
58 |
62 | {hashedRekord.signature.content || ""}
63 |
64 |
68 | {publicKey.title}
69 |
70 |
74 | {publicKey.content}
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/modules/components/Intoto002.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Link, Typography } from "@mui/material";
2 | import { dump } from "js-yaml";
3 | import NextLink from "next/link";
4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5 | import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
6 | import { IntotoV002Schema } from "rekor";
7 | import { decodex509 } from "../x509/decode";
8 |
9 | export function IntotoViewer002({ intoto }: { intoto: IntotoV002Schema }) {
10 | const signature = intoto.content.envelope?.signatures[0];
11 | const certContent = window.atob(signature?.publicKey || "");
12 |
13 | const publicKey = {
14 | title: "Public Key",
15 | content: certContent,
16 | };
17 | if (certContent.includes("BEGIN CERTIFICATE")) {
18 | publicKey.title = "Public Key Certificate";
19 | publicKey.content = dump(decodex509(certContent), {
20 | noArrayIndent: true,
21 | lineWidth: -1,
22 | });
23 | }
24 |
25 | return (
26 |
27 |
31 |
35 | Hash
36 |
37 |
38 |
39 |
43 | {`${intoto.content.payloadHash?.algorithm}:${intoto.content.payloadHash?.value}`}
44 |
45 |
46 |
50 | Signature
51 |
52 |
56 | {window.atob(signature?.sig || "")}
57 |
58 |
62 | {publicKey.title}
63 |
64 |
68 | {publicKey.content}
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/modules/components/Settings.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Divider,
5 | Drawer,
6 | TextField,
7 | Typography,
8 | } from "@mui/material";
9 | import { ChangeEventHandler, useCallback, useState } from "react";
10 | import { useRekorBaseUrl } from "../api/context";
11 |
12 | export function Settings({
13 | open,
14 | onClose,
15 | }: {
16 | open: boolean;
17 | onClose: () => void;
18 | }) {
19 | const [baseUrl, setBaseUrl] = useRekorBaseUrl();
20 | const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
21 |
22 | const handleChangeBaseUrl: ChangeEventHandler<
23 | HTMLTextAreaElement | HTMLInputElement
24 | > = useCallback(e => {
25 | if (e.target.value.length === 0) {
26 | setLocalBaseUrl(undefined);
27 | } else {
28 | setLocalBaseUrl(e.target.value);
29 | }
30 | }, []);
31 |
32 | const onSave = useCallback(() => {
33 | if (
34 | localBaseUrl === undefined &&
35 | process.env.NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN
36 | ) {
37 | setLocalBaseUrl(process.env.NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN);
38 | }
39 |
40 | setBaseUrl(localBaseUrl);
41 | onClose();
42 | }, [localBaseUrl, setBaseUrl, onClose]);
43 |
44 | return (
45 |
50 |
51 |
52 | Settings
53 |
54 |
55 |
56 | Override rekor endpoint
57 |
65 |
66 |
67 |
68 |
72 | Save
73 |
74 | Cancel
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/modules/api/context.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | FunctionComponent,
4 | PropsWithChildren,
5 | useContext,
6 | useMemo,
7 | useState,
8 | } from "react";
9 | import { RekorClient } from "rekor";
10 |
11 | export interface RekorClientContext {
12 | client: RekorClient;
13 | baseUrl?: string;
14 | setBaseUrl: (base: string | undefined) => void;
15 | }
16 |
17 | export const RekorClientContext = createContext(
18 | undefined,
19 | );
20 |
21 | export const RekorClientProvider: FunctionComponent> = ({
22 | children,
23 | }) => {
24 | const [baseUrl, setBaseUrl] = useState();
25 |
26 | const context: RekorClientContext = useMemo(() => {
27 | /*
28 | Using the Next.js framework, the NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN env variable requires
29 | a NEXT_PUBLIC_* prefix to make the value of the variable accessible to the browser.
30 | Variables missing this prefix are only accessible in the Node.js environment.
31 | https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
32 | */
33 | if (baseUrl === undefined && process.env.NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN) {
34 | setBaseUrl(process.env.NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN);
35 | }
36 |
37 | return {
38 | client: new RekorClient({ BASE: baseUrl }),
39 | baseUrl,
40 | setBaseUrl,
41 | };
42 | }, [baseUrl]);
43 |
44 | return (
45 |
46 | {children}
47 |
48 | );
49 | };
50 |
51 | export function useRekorClient(): RekorClient {
52 | const ctx = useContext(RekorClientContext);
53 |
54 | if (!ctx) {
55 | throw new Error("Hook useRekorClient requires RekorClientContext.");
56 | }
57 |
58 | return ctx.client;
59 | }
60 |
61 | export function useRekorBaseUrl(): [
62 | RekorClientContext["baseUrl"],
63 | RekorClientContext["setBaseUrl"],
64 | ] {
65 | const ctx = useContext(RekorClientContext);
66 |
67 | if (!ctx) {
68 | throw new Error("Hook useRekorBaseUrl requires RekorClientContext.");
69 | }
70 |
71 | return [ctx.baseUrl, ctx.setBaseUrl];
72 | }
73 |
--------------------------------------------------------------------------------
/src/modules/components/Explorer.test.tsx:
--------------------------------------------------------------------------------
1 | import { NextRouter, useRouter } from "next/router";
2 |
3 | jest.mock("next/router", () => ({
4 | useRouter: jest.fn(),
5 | }));
6 |
7 | beforeEach(() => {
8 | jest.resetAllMocks();
9 |
10 | (useRouter as jest.Mock).mockImplementation(
11 | (): Partial => ({
12 | query: {},
13 | pathname: "/",
14 | asPath: "/",
15 | }),
16 | );
17 | });
18 |
19 | import { fireEvent, render, screen, waitFor } from "@testing-library/react";
20 | import { RekorClientProvider } from "../api/context";
21 | import { Explorer } from "./Explorer";
22 | import userEvent from "@testing-library/user-event";
23 |
24 | describe("Explorer", () => {
25 | it("should render search form and display search button", () => {
26 | render(
27 |
28 |
29 | ,
30 | );
31 |
32 | expect(screen.getByLabelText("Attribute")).toBeInTheDocument();
33 | expect(screen.getByRole("textbox", { name: "Email" })).toBeInTheDocument();
34 | expect(screen.getByRole("button", { name: "Search" })).toBeInTheDocument();
35 | });
36 |
37 | it("should handle invalid logIndex query parameter", () => {
38 | const mockRouter = {
39 | query: {
40 | logIndex: "invalid",
41 | },
42 | push: jest.fn(),
43 | };
44 |
45 | (useRouter as jest.Mock).mockImplementation(
46 | (): Partial => mockRouter,
47 | );
48 |
49 | render(
50 |
51 |
52 | ,
53 | );
54 |
55 | expect(mockRouter.push).not.toHaveBeenCalled();
56 | });
57 |
58 | it("displays loading indicator when fetching data", async () => {
59 | render(
60 |
61 |
62 | ,
63 | );
64 |
65 | const button = screen.getByText("Search");
66 | fireEvent.click(button);
67 |
68 | await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
69 |
70 | expect(
71 | screen.findByLabelText("Showing").then(res => {
72 | screen.debug();
73 | console.log(res);
74 | expect(res).toBeInTheDocument();
75 | }),
76 | );
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/src/modules/components/Entry.test.tsx:
--------------------------------------------------------------------------------
1 | jest.mock("react-syntax-highlighter/dist/cjs/styles/prism", () => ({}));
2 | jest.mock("../utils/date", () => ({
3 | toRelativeDateString: jest.fn().mockReturnValue("Some Date"),
4 | }));
5 | jest.mock("./HashedRekord", () => ({
6 | HashedRekordViewer: () => MockedHashedRekordViewer
,
7 | }));
8 |
9 | import atobMock from "../../__mocks__/atobMock";
10 |
11 | import { fireEvent, render, screen } from "@testing-library/react";
12 | import { Entry, Card } from "./Entry";
13 |
14 | describe("Entry", () => {
15 | beforeAll(() => {
16 | atobMock();
17 | });
18 |
19 | afterAll(() => {
20 | jest.restoreAllMocks();
21 | });
22 |
23 | const mockEntry = {
24 | someUuid: {
25 | body: Buffer.from(
26 | JSON.stringify({ kind: "hashedrekord", apiVersion: "v1", spec: {} }),
27 | ).toString("base64"),
28 | attestation: { data: Buffer.from("{}").toString("base64") },
29 | logID: "123",
30 | logIndex: 123,
31 | integratedTime: 1618886400,
32 | publicKey: "mockedPublicKey",
33 | signature: {
34 | publicKey: {
35 | content: window.btoa(
36 | "-----BEGIN CERTIFICATE-----certContent-----END CERTIFICATE-----",
37 | ), // base64 encode
38 | },
39 | },
40 | },
41 | };
42 |
43 | it("renders and toggles the accordion content", () => {
44 | render( );
45 |
46 | expect(screen.getByText("apiVersion")).not.toBeVisible();
47 |
48 | // check if UUID link is rendered
49 | expect(screen.getByText("someUuid")).toBeInTheDocument();
50 |
51 | // simulate clicking the accordion toggle
52 | const toggleButton = screen.getByText("Raw Body");
53 | fireEvent.click(toggleButton);
54 |
55 | // now the accordion content should be visible
56 | expect(screen.getByText("apiVersion")).toBeVisible();
57 | });
58 | });
59 |
60 | describe("Card", () => {
61 | it("renders the title and content", () => {
62 | render(
63 | ,
67 | );
68 | expect(screen.getByText("Test Title")).toBeInTheDocument();
69 | expect(screen.getByText("Test Content")).toBeInTheDocument();
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/.github/workflows/reviewdog.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2022 Chainguard, Inc.
2 | # SPDX-License-Identifier: Apache-2.0
3 |
4 | name: 🐶 reviewdog
5 |
6 | on:
7 | pull_request:
8 | branches: ["main"]
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | eslint:
15 | name: ESLint
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
20 | with:
21 | persist-credentials: false
22 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
23 | with:
24 | node-version-file: "package.json"
25 | - uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0
26 | with:
27 | reporter: github-check
28 | eslint_flags: "src/"
29 |
30 | action-lint:
31 | name: Action lint
32 | runs-on: ubuntu-latest
33 |
34 | steps:
35 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
36 | with:
37 | persist-credentials: false
38 | - name: Find yamls
39 | id: get_yamls
40 | run: |
41 | yamls=$(find .github -name "*.y*ml" | grep -v dependabot. | tr '\n' ' ')
42 | echo "files=${yamls}" >> "$GITHUB_OUTPUT"
43 | - name: Action lint
44 | uses: reviewdog/action-actionlint@83e4ed25b168066ad8f62f5afbb29ebd8641d982 # v1.69.1
45 | with:
46 | actionlint_flags: ${{ steps.get_yamls.outputs.files }}
47 |
48 | spacing:
49 | name: Spacing
50 | runs-on: ubuntu-latest
51 |
52 | steps:
53 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
54 | with:
55 | persist-credentials: false
56 | - uses: chainguard-dev/actions/trailing-space@3e8a2a226fad9e1ecbf2d359b8a7697554a4ac6d # v1.5.10
57 | - uses: chainguard-dev/actions/eof-newline@3e8a2a226fad9e1ecbf2d359b8a7697554a4ac6d # v1.5.10
58 |
59 | donotsubmit:
60 | name: Do not submit
61 | runs-on: ubuntu-latest
62 |
63 | steps:
64 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
65 | with:
66 | persist-credentials: false
67 | - uses: chainguard-dev/actions/donotsubmit@3e8a2a226fad9e1ecbf2d359b8a7697554a4ac6d # v1.5.10
68 |
--------------------------------------------------------------------------------
/src/modules/x509/decode.test.ts:
--------------------------------------------------------------------------------
1 | import { decodex509 } from "./decode";
2 | import { X509Certificate } from "@peculiar/x509";
3 | import { toRelativeDateString } from "../utils/date";
4 | import { EXTENSIONS_CONFIG } from "./extensions";
5 |
6 | jest.mock("@peculiar/x509", () => ({
7 | X509Certificate: jest.fn(),
8 | }));
9 |
10 | jest.mock("../utils/date", () => ({
11 | toRelativeDateString: jest.fn(),
12 | }));
13 |
14 | jest.mock("./extensions", () => ({
15 | EXTENSIONS_CONFIG: {
16 | "1.2.3.4.5": {
17 | name: "Mock Extension",
18 | toJSON: jest.fn().mockReturnValue({ mockKey: "mockValue" }),
19 | },
20 | },
21 | }));
22 |
23 | describe("decodex509", () => {
24 | beforeEach(() => {
25 | jest.clearAllMocks();
26 |
27 | (toRelativeDateString as jest.Mock).mockImplementation(
28 | date => `Relative date for ${date}`,
29 | );
30 | (X509Certificate as unknown as jest.Mock).mockImplementation(() => ({
31 | serialNumber: "123456",
32 | issuer: "Issuer Name",
33 | notBefore: new Date("2020-01-01"),
34 | notAfter: new Date("2022-01-01"),
35 | publicKey: {
36 | algorithm: "rsaEncryption",
37 | },
38 | subjectName: "Subject Name",
39 | extensions: [
40 | {
41 | type: "1.2.3.4.5",
42 | critical: false,
43 | value: new ArrayBuffer(8),
44 | },
45 | ],
46 | }));
47 | });
48 |
49 | it("should decode a raw X.509 certificate", () => {
50 | const rawCertificate = "rawCertificateString";
51 | const decoded = decodex509(rawCertificate);
52 | expect(decoded).toBeDefined();
53 | expect(decoded.Signature.Issuer).toBe("Issuer Name");
54 | });
55 |
56 | // simulate an extension not found
57 | // @ts-ignore
58 | EXTENSIONS_CONFIG["unknownExtensionType"] = undefined;
59 |
60 | it("converts ArrayBuffer to hex string for unknown extension types", () => {
61 | (X509Certificate as unknown as jest.Mock).mockImplementation(() => ({
62 | // mock certificate fields as above, adjusting for this specific test
63 | serialNumber: "654321",
64 | issuer: "New Issuer",
65 | notBefore: new Date("2021-01-01"),
66 | notAfter: new Date("2023-01-01"),
67 | publicKey: {
68 | algorithm: "ecdsa",
69 | },
70 | subjectName: "New Subject",
71 | extensions: [
72 | {
73 | type: "unknownExtensionType",
74 | critical: true,
75 | value: new Uint8Array([1, 2, 3, 4]).buffer, // tests bufferToHex
76 | },
77 | ],
78 | }));
79 |
80 | const rawCertificate = "anotherRawCertificateString";
81 | const decoded = decodex509(rawCertificate);
82 |
83 | // asserts the hex string format of the ArrayBuffer
84 | expect(
85 | decoded["X509v3 extensions"]["unknownExtensionType (critical)"],
86 | ).toBe("01:02:03:04");
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/src/modules/api/rekor_api.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { LogEntry, RekorClient, SearchIndex } from "rekor";
3 | import { useRekorClient } from "./context";
4 |
5 | const PAGE_SIZE = 20;
6 |
7 | export const ATTRIBUTES = [
8 | "email",
9 | "hash",
10 | "commitSha",
11 | "uuid",
12 | "logIndex",
13 | ] as const;
14 | const ATTRIBUTES_SET = new Set(ATTRIBUTES);
15 |
16 | export type Attribute = (typeof ATTRIBUTES)[number];
17 |
18 | export function isAttribute(input: string): input is Attribute {
19 | return ATTRIBUTES_SET.has(input);
20 | }
21 |
22 | export type SearchQuery =
23 | | {
24 | attribute: "email" | "hash" | "commitSha" | "uuid";
25 | query: string;
26 | }
27 | | {
28 | attribute: "logIndex";
29 | query: number;
30 | };
31 |
32 | export interface RekorEntries {
33 | totalCount: number;
34 | entries: LogEntry[];
35 | }
36 |
37 | export function useRekorSearch() {
38 | const client = useRekorClient();
39 |
40 | return useCallback(
41 | async (search: SearchQuery, page: number = 1): Promise => {
42 | switch (search.attribute) {
43 | case "logIndex":
44 | return {
45 | totalCount: 1,
46 | entries: [
47 | await client.entries.getLogEntryByIndex({
48 | logIndex: search.query,
49 | }),
50 | ],
51 | };
52 | case "uuid":
53 | return {
54 | totalCount: 1,
55 | entries: [
56 | await client.entries.getLogEntryByUuid({
57 | entryUuid: search.query,
58 | }),
59 | ],
60 | };
61 | case "email":
62 | return queryEntries(
63 | client,
64 | {
65 | email: search.query,
66 | },
67 | page,
68 | );
69 | case "hash":
70 | let query = search.query;
71 | if (!query.startsWith("sha256:")) {
72 | query = `sha256:${query}`;
73 | }
74 | return queryEntries(
75 | client,
76 | {
77 | hash: query,
78 | },
79 | page,
80 | );
81 | case "commitSha":
82 | const hash = await digestMessage(search.query);
83 | return queryEntries(client, { hash }, page);
84 | }
85 | },
86 | [client],
87 | );
88 | }
89 |
90 | async function queryEntries(
91 | client: RekorClient,
92 | query: SearchIndex,
93 | page: number,
94 | ): Promise {
95 | const logIndexes = await client.index.searchIndex({ query });
96 |
97 | // Preventing entries from jumping between pages on refresh
98 | logIndexes.sort();
99 |
100 | const startIndex = (page - 1) * PAGE_SIZE;
101 | const endIndex = startIndex + PAGE_SIZE;
102 | const uuidToRetrieve = logIndexes.slice(startIndex, endIndex);
103 |
104 | const entries = await Promise.all(
105 | uuidToRetrieve.map(entryUuid =>
106 | client.entries.getLogEntryByUuid({ entryUuid }),
107 | ),
108 | );
109 | return {
110 | totalCount: logIndexes.length,
111 | entries,
112 | };
113 | }
114 |
115 | async function digestMessage(message: string): Promise {
116 | const msgUint8 = new TextEncoder().encode(message);
117 | const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
118 | const hashArray = Array.from(new Uint8Array(hashBuffer));
119 | const hash = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
120 | return `sha256:${hash}`;
121 | }
122 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import SettingsIcon from "@mui/icons-material/Settings";
2 | import {
3 | Box,
4 | Container,
5 | CssBaseline,
6 | IconButton,
7 | Link,
8 | ThemeProvider,
9 | Typography,
10 | } from "@mui/material";
11 | import type { NextPage } from "next";
12 | import Head from "next/head";
13 | import Image from "next/image";
14 | import { useState } from "react";
15 | import { RekorClientProvider } from "../modules/api/context";
16 | import { Explorer } from "../modules/components/Explorer";
17 | import { Settings } from "../modules/components/Settings";
18 | import { REKOR_SEARCH_THEME } from "../modules/theme/theme";
19 |
20 | const Home: NextPage = () => {
21 | const [settingsOpen, setSettingsOpen] = useState(false);
22 |
23 | return (
24 | <>
25 |
26 | Rekor Search
27 |
31 |
35 |
36 |
37 |
38 |
39 |
40 |
51 |
52 |
57 |
62 |
63 |
64 |
65 | Rekor Search
66 |
67 |
75 | setSettingsOpen(true)}
81 | >
82 |
83 |
84 |
90 |
97 |
98 |
99 |
100 |
101 | setSettingsOpen(false)}
104 | />
105 |
106 |
114 |
115 |
116 |
126 |
127 |
128 | >
129 | );
130 | };
131 |
132 | const Page: NextPage = () => (
133 |
134 |
135 |
136 | );
137 | export default Page;
138 |
--------------------------------------------------------------------------------
/.github/workflows/nextjs.yml:
--------------------------------------------------------------------------------
1 | # Sample workflow for building and deploying a Next.js site to GitHub Pages
2 | #
3 | # To get started with Next.js see: https://nextjs.org/docs/getting-started
4 | #
5 | name: Deploy Next.js site to Pages
6 |
7 | on:
8 | # Runs on pushes targeting the default branch
9 | push:
10 | branches: ["main"]
11 |
12 | # Allows you to run this workflow manually from the Actions tab
13 | workflow_dispatch:
14 |
15 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
16 | permissions:
17 | contents: read
18 |
19 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
20 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
21 | concurrency:
22 | group: "pages"
23 | cancel-in-progress: false
24 |
25 | jobs:
26 | # Build job
27 | build:
28 | name: Build site
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
33 | with:
34 | persist-credentials: false
35 | - name: Detect package manager
36 | id: detect-package-manager
37 | run: |
38 | if [ -f "${{ github.workspace }}/yarn.lock" ]; then
39 | {
40 | echo "manager=yarn"
41 | echo "command=install"
42 | echo "runner=yarn"
43 | } >> "$GITHUB_OUTPUT"
44 | exit 0
45 | elif [ -f "${{ github.workspace }}/package.json" ]; then
46 | {
47 | echo "manager=npm"
48 | echo "command=ci"
49 | echo "runner=npx --no-install"
50 | } >> "$GITHUB_OUTPUT"
51 | exit 0
52 | else
53 | echo "Unable to determine packager manager"
54 | exit 1
55 | fi
56 | - name: Setup Node
57 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
58 | with:
59 | node-version-file: "package.json"
60 | cache: ${{ steps.detect-package-manager.outputs.manager }}
61 | - name: Setup Pages
62 | uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
63 | with:
64 | # Automatically inject basePath in your Next.js configuration file and disable
65 | # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
66 | #
67 | # You may remove this line if you want to manage the configuration yourself.
68 | static_site_generator: next
69 | - name: Restore cache
70 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
71 | with:
72 | path: |
73 | .next/cache
74 | # Generate a new cache whenever packages or source files change.
75 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
76 | # If source files changed but packages didn't, rebuild from a prior cache.
77 | restore-keys: |
78 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
79 | - name: Install dependencies
80 | run: |
81 | "${STEPS_DETECT_PACKAGE_MANAGER_OUTPUTS_MANAGER}" "${STEPS_DETECT_PACKAGE_MANAGER_OUTPUTS_COMMAND}"
82 | env:
83 | STEPS_DETECT_PACKAGE_MANAGER_OUTPUTS_MANAGER: ${{ steps.detect-package-manager.outputs.manager }}
84 | STEPS_DETECT_PACKAGE_MANAGER_OUTPUTS_COMMAND: ${{ steps.detect-package-manager.outputs.command }}
85 | - name: Build with Next.js
86 | run: |
87 | "${STEPS_DETECT_PACKAGE_MANAGER_OUTPUTS_RUNNER}" next build
88 | env:
89 | STEPS_DETECT_PACKAGE_MANAGER_OUTPUTS_RUNNER: ${{ steps.detect-package-manager.outputs.runner }}
90 | - name: Upload artifact
91 | uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
92 | with:
93 | path: ./out
94 |
95 | # Deployment job
96 | deploy:
97 | permissions:
98 | contents: read
99 | pages: write
100 | id-token: write
101 | environment:
102 | name: github-pages
103 | url: ${{ steps.deployment.outputs.page_url }}
104 | runs-on: ubuntu-latest
105 | needs: build
106 | steps:
107 | - name: Deploy to GitHub Pages
108 | id: deployment
109 | uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
110 |
--------------------------------------------------------------------------------
/src/modules/x509/extensions.ts:
--------------------------------------------------------------------------------
1 | import { AsnAnyConverter, AsnUtf8StringConverter } from "@peculiar/asn1-schema";
2 | import {
3 | AuthorityKeyIdentifierExtension,
4 | BasicConstraintsExtension,
5 | ExtendedKeyUsageExtension,
6 | Extension,
7 | KeyUsageFlags,
8 | KeyUsagesExtension,
9 | SubjectAlternativeNameExtension,
10 | SubjectKeyIdentifierExtension,
11 | } from "@peculiar/x509";
12 | import { KEY_USAGE_NAMES } from "./constants";
13 |
14 | interface ExtensionConfig {
15 | name: string;
16 | toJSON: (rawExtension: Extension) => {};
17 | }
18 |
19 | const UTF_8_DECODER = new TextDecoder("utf-8");
20 | function textDecoder(rawExtension: Extension): string {
21 | return UTF_8_DECODER.decode(rawExtension.value);
22 | }
23 |
24 | function utf8StringDecoder(rawExtension: Extension): string {
25 | return AsnUtf8StringConverter.fromASN(
26 | AsnAnyConverter.toASN(rawExtension.value),
27 | );
28 | }
29 |
30 | /**
31 | * Map from OID to Extension
32 | *
33 | * TODO: Migrate to x509 textual representation introduced in PeculiarVentures/x509#48.
34 | */
35 | export const EXTENSIONS_CONFIG: Record = {
36 | "2.5.29.14": {
37 | name: "Subject Key Identifier",
38 | toJSON(rawExtension: Extension) {
39 | const ext = new SubjectKeyIdentifierExtension(rawExtension.rawData);
40 | return [
41 | ext.keyId
42 | .match(/.{1,2}/g)
43 | ?.join(":")
44 | .toUpperCase(),
45 | ];
46 | },
47 | },
48 | "2.5.29.15": {
49 | name: "Key Usage",
50 | toJSON(rawExtension: Extension) {
51 | const ext = new KeyUsagesExtension(rawExtension.rawData);
52 | const usages: string[] = [];
53 |
54 | const keys = Object.keys(KEY_USAGE_NAMES) as unknown as KeyUsageFlags[];
55 | for (const key of keys) {
56 | if (ext.usages & key) {
57 | usages.push(KEY_USAGE_NAMES[key]);
58 | }
59 | }
60 | return usages;
61 | },
62 | },
63 | "2.5.29.17": {
64 | name: "Subject Alternative Name",
65 | toJSON(rawExtension: Extension) {
66 | return new SubjectAlternativeNameExtension(rawExtension.rawData).toJSON();
67 | },
68 | },
69 | "2.5.29.19": {
70 | name: "Basic Constraints",
71 | toJSON(rawExtension: Extension) {
72 | const ext = new BasicConstraintsExtension(rawExtension.rawData);
73 | return {
74 | CA: ext.ca,
75 | };
76 | },
77 | },
78 | "2.5.29.35": {
79 | name: "Authority Key Identifier",
80 | toJSON(rawExtension: Extension) {
81 | const ext = new AuthorityKeyIdentifierExtension(rawExtension.rawData);
82 | return {
83 | keyid: ext.keyId
84 | ?.match(/.{1,2}/g)
85 | ?.join(":")
86 | .toUpperCase(),
87 | certid: ext.certId,
88 | };
89 | },
90 | },
91 | "2.5.29.37": {
92 | name: "Extended Key Usage",
93 | toJSON(rawExtension: Extension) {
94 | const ext = new ExtendedKeyUsageExtension(rawExtension.rawData);
95 | return ext.usages.map(code => {
96 | switch (code) {
97 | case "1.3.6.1.5.5.7.3.3":
98 | return "Code Signing";
99 | default:
100 | return code;
101 | }
102 | });
103 | },
104 | },
105 | /**
106 | * Fulcio OIDs are based on https://github.com/sigstore/fulcio/blob/main/pkg/ca/extensions.go
107 | */
108 | "1.3.6.1.4.1.57264.1.1": {
109 | name: "OIDC Issuer",
110 | toJSON: textDecoder,
111 | },
112 | "1.3.6.1.4.1.57264.1.2": {
113 | name: "GitHub Workflow Trigger",
114 | toJSON: textDecoder,
115 | },
116 | "1.3.6.1.4.1.57264.1.3": {
117 | name: "GitHub Workflow SHA",
118 | toJSON: textDecoder,
119 | },
120 | "1.3.6.1.4.1.57264.1.4": {
121 | name: "GitHub Workflow Name",
122 | toJSON: textDecoder,
123 | },
124 | "1.3.6.1.4.1.57264.1.5": {
125 | name: "GitHub Workflow Repository",
126 | toJSON: textDecoder,
127 | },
128 | "1.3.6.1.4.1.57264.1.6": {
129 | name: "GitHub Workflow Ref",
130 | toJSON: textDecoder,
131 | },
132 | "1.3.6.1.4.1.57264.1.8": {
133 | name: "OIDC Issuer (v2)",
134 | toJSON: utf8StringDecoder,
135 | },
136 | "1.3.6.1.4.1.57264.1.9": {
137 | name: "Build Signer URI",
138 | toJSON: utf8StringDecoder,
139 | },
140 | "1.3.6.1.4.1.57264.1.10": {
141 | name: "Build Signer Digest",
142 | toJSON: utf8StringDecoder,
143 | },
144 | "1.3.6.1.4.1.57264.1.11": {
145 | name: "Runner Environment",
146 | toJSON: utf8StringDecoder,
147 | },
148 | "1.3.6.1.4.1.57264.1.12": {
149 | name: "Source Repository URI",
150 | toJSON: utf8StringDecoder,
151 | },
152 | "1.3.6.1.4.1.57264.1.13": {
153 | name: "Source Repository Digest",
154 | toJSON: utf8StringDecoder,
155 | },
156 | "1.3.6.1.4.1.57264.1.14": {
157 | name: "Source Repository Ref",
158 | toJSON: utf8StringDecoder,
159 | },
160 | "1.3.6.1.4.1.57264.1.15": {
161 | name: "Source Repository Identifier",
162 | toJSON: utf8StringDecoder,
163 | },
164 | "1.3.6.1.4.1.57264.1.16": {
165 | name: "Source Repository Owner URI",
166 | toJSON: utf8StringDecoder,
167 | },
168 | "1.3.6.1.4.1.57264.1.17": {
169 | name: "Source Repository Owner Identifier",
170 | toJSON: utf8StringDecoder,
171 | },
172 | "1.3.6.1.4.1.57264.1.18": {
173 | name: "Build Config URI",
174 | toJSON: utf8StringDecoder,
175 | },
176 | "1.3.6.1.4.1.57264.1.19": {
177 | name: "Build Config Digest",
178 | toJSON: utf8StringDecoder,
179 | },
180 | "1.3.6.1.4.1.57264.1.20": {
181 | name: "Build Trigger",
182 | toJSON: utf8StringDecoder,
183 | },
184 | "1.3.6.1.4.1.57264.1.21": {
185 | name: "Run Invocation URI",
186 | toJSON: utf8StringDecoder,
187 | },
188 | "1.3.6.1.4.1.57264.1.22": {
189 | name: "Source Repository Visibility At Signing",
190 | toJSON: utf8StringDecoder,
191 | },
192 | };
193 |
--------------------------------------------------------------------------------
/src/modules/components/SearchForm.tsx:
--------------------------------------------------------------------------------
1 | import LoadingButton from "@mui/lab/LoadingButton";
2 | import {
3 | FormControl,
4 | Grid,
5 | InputLabel,
6 | MenuItem,
7 | Select,
8 | TextField,
9 | } from "@mui/material";
10 | import Paper from "@mui/material/Paper";
11 | import { ReactNode, useEffect } from "react";
12 | import { Controller, RegisterOptions, useForm } from "react-hook-form";
13 | import { Attribute, ATTRIBUTES } from "../api/rekor_api";
14 |
15 | export interface FormProps {
16 | defaultValues?: FormInputs;
17 | isLoading: boolean;
18 | onSubmit: (query: FormInputs) => void;
19 | }
20 |
21 | export interface FormInputs {
22 | attribute: Attribute;
23 | value: string;
24 | }
25 |
26 | type Rules = Omit<
27 | RegisterOptions,
28 | "valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
29 | >;
30 |
31 | interface InputConfig {
32 | name: string;
33 | helperText?: ReactNode;
34 | rules: Rules;
35 | }
36 |
37 | const inputConfigByAttribute: Record = {
38 | email: {
39 | name: "Email",
40 | rules: {
41 | pattern: {
42 | value: /\S+@\S+\.\S+/,
43 | message: "Entered value does not match the email format: 'S+@S+.S+'",
44 | },
45 | },
46 | },
47 | hash: {
48 | name: "Hash",
49 | rules: {
50 | pattern: {
51 | value: /^(sha256:)?[0-9a-fA-F]{64}$|^(sha1:)?[0-9a-fA-F]{40}$/,
52 | message:
53 | "Entered value does not match the hash format: '^(sha256:)?[0-9a-fA-F]{64}$|^(sha1:)?[0-9a-fA-F]{40}$'",
54 | },
55 | },
56 | },
57 | commitSha: {
58 | name: "Commit SHA",
59 | helperText: (
60 | <>
61 | Only compatible with{" "}
62 |
70 | sigstore/gitsign
71 | {" "}
72 | entries
73 | >
74 | ),
75 | rules: {
76 | pattern: {
77 | value: /^[0-9a-fA-F]{40}$/,
78 | message:
79 | "Entered value does not match the commit SHA format: '^[0-9a-fA-F]{40}$'",
80 | },
81 | },
82 | },
83 | uuid: {
84 | name: "Entry UUID",
85 | rules: {
86 | pattern: {
87 | value: /^[0-9a-fA-F]{64}|[0-9a-fA-F]{80}$/,
88 | message:
89 | "Entered value does not match the entry UUID format: '^[0-9a-fA-F]{64}|[0-9a-fA-F]{80}$'",
90 | },
91 | },
92 | },
93 | logIndex: {
94 | name: "Log Index",
95 | rules: {
96 | min: {
97 | value: 0,
98 | message: "Entered value must be larger than 0",
99 | },
100 | pattern: {
101 | value: /^\d+$/,
102 | message: "Entered value must be of type int64",
103 | },
104 | },
105 | },
106 | };
107 |
108 | export function SearchForm({ defaultValues, onSubmit, isLoading }: FormProps) {
109 | const { handleSubmit, control, watch, setValue, trigger } =
110 | useForm({
111 | mode: "all",
112 | reValidateMode: "onChange",
113 | defaultValues: {
114 | attribute: "email",
115 | value: "",
116 | },
117 | });
118 |
119 | useEffect(() => {
120 | if (defaultValues) {
121 | setValue("attribute", defaultValues.attribute);
122 | setValue("value", defaultValues.value);
123 | }
124 | }, [defaultValues, setValue]);
125 |
126 | const watchAttribute = watch("attribute");
127 |
128 | useEffect(() => {
129 | if (control.getFieldState("attribute").isTouched) {
130 | trigger();
131 | }
132 | }, [watchAttribute, trigger, control]);
133 |
134 | const rules = Object.assign(
135 | {
136 | required: {
137 | value: true,
138 | message: "A value is required",
139 | },
140 | pattern: undefined,
141 | min: undefined,
142 | deps: undefined,
143 | },
144 | inputConfigByAttribute[watchAttribute].rules,
145 | );
146 |
147 | return (
148 |
229 | );
230 | }
231 |
--------------------------------------------------------------------------------
/src/modules/components/Explorer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Alert,
3 | AlertTitle,
4 | Box,
5 | CircularProgress,
6 | Pagination,
7 | Typography,
8 | } from "@mui/material";
9 | import { useRouter } from "next/router";
10 | import { useCallback, useEffect, useState } from "react";
11 | import { ApiError, RekorError } from "rekor";
12 | import {
13 | Attribute,
14 | isAttribute,
15 | RekorEntries,
16 | SearchQuery,
17 | useRekorSearch,
18 | } from "../api/rekor_api";
19 | import { Entry } from "./Entry";
20 | import { FormInputs, SearchForm } from "./SearchForm";
21 |
22 | const PAGE_SIZE = 20;
23 |
24 | function isApiError(error: unknown): error is ApiError {
25 | return !!error && typeof error === "object" && Object.hasOwn(error, "body");
26 | }
27 |
28 | function isRekorError(error: unknown): error is RekorError {
29 | return !!error && typeof error === "object";
30 | }
31 |
32 | function Error({ error }: { error: unknown }) {
33 | let title = "Unknown error";
34 | let detail: string | undefined;
35 |
36 | if (isApiError(error)) {
37 | if (isRekorError(error.body)) {
38 | title = `Code ${error.body.code}: ${error.body.message}`;
39 | }
40 | detail = `${error.url}: ${error.status}`;
41 | } else if (typeof error == "string") {
42 | title = error;
43 | } else if (error instanceof TypeError) {
44 | title = error.message;
45 | detail = error.stack;
46 | }
47 |
48 | return (
49 |
54 | {title}
55 | {detail}
56 |
57 | );
58 | }
59 |
60 | function RekorList({
61 | rekorEntries,
62 | page,
63 | onPageChange,
64 | }: {
65 | rekorEntries?: RekorEntries;
66 | page: number;
67 | onPageChange: (event: React.ChangeEvent, page: number) => void;
68 | }) {
69 | if (!rekorEntries) {
70 | return <>>;
71 | }
72 |
73 | if (rekorEntries.entries.length === 0) {
74 | return (
75 |
80 | No matching entries found
81 |
82 | );
83 | }
84 |
85 | const pageCount = Math.ceil(rekorEntries.totalCount / PAGE_SIZE);
86 |
87 | const firstItem = (page - 1) * PAGE_SIZE + 1;
88 | const lastItem = firstItem + rekorEntries.entries.length - 1;
89 |
90 | return (
91 | <>
92 |
93 | Showing {firstItem} - {lastItem} of {rekorEntries.totalCount}
94 |
95 |
96 | {rekorEntries.entries.map(entry => (
97 |
101 | ))}
102 |
103 | {pageCount > 1 && (
104 |
105 |
113 |
114 | )}
115 | >
116 | );
117 | }
118 |
119 | function LoadingIndicator() {
120 | return (
121 |
129 |
130 |
131 | );
132 | }
133 |
134 | export function Explorer() {
135 | const router = useRouter();
136 | const [formInputs, setFormInputs] = useState();
137 | const [query, setQuery] = useState();
138 | const search = useRekorSearch();
139 |
140 | const [data, setData] = useState();
141 | const [error, setError] = useState();
142 | const [loading, setLoading] = useState(false);
143 | const [page, setPage] = useState(1);
144 |
145 | useEffect(() => {
146 | async function fetch() {
147 | if (!query) {
148 | return;
149 | }
150 | setError(undefined);
151 | setLoading(true);
152 | try {
153 | setData(await search(query, page));
154 | } catch (e) {
155 | setError(e);
156 | }
157 | setLoading(false);
158 | }
159 | fetch();
160 | }, [query, page, search]);
161 |
162 | const setQueryParams = useCallback(
163 | (formInputs: FormInputs) => {
164 | setPage(1);
165 |
166 | router.push(
167 | {
168 | pathname: router.pathname,
169 | query: {
170 | [formInputs.attribute]: formInputs.value,
171 | },
172 | },
173 | `/?${formInputs.attribute}=${formInputs.value}`,
174 | { shallow: true },
175 | );
176 | },
177 | [router],
178 | );
179 |
180 | useEffect(() => {
181 | const attribute = Object.keys(router.query).find(key =>
182 | isAttribute(key),
183 | ) as Attribute | undefined;
184 | const value = attribute && router.query[attribute];
185 |
186 | if (!value || Array.isArray(value)) {
187 | return;
188 | }
189 | setFormInputs({ attribute, value });
190 | }, [router.query]);
191 |
192 | useEffect(() => {
193 | if (formInputs) {
194 | setPage(1);
195 |
196 | switch (formInputs.attribute) {
197 | case "logIndex":
198 | const query = parseInt(formInputs.value);
199 | if (!isNaN(query)) {
200 | // Ignore invalid numbers.
201 | setQuery({
202 | attribute: formInputs.attribute,
203 | query,
204 | });
205 | }
206 | break;
207 | default:
208 | setQuery({
209 | attribute: formInputs.attribute,
210 | query: formInputs.value,
211 | });
212 | }
213 | }
214 | }, [formInputs]);
215 |
216 | const handlePageChange = (
217 | _event: React.ChangeEvent,
218 | newPage: number,
219 | ) => {
220 | setPage(newPage);
221 | };
222 |
223 | return (
224 |
225 |
230 |
231 | {error ? (
232 |
233 | ) : loading ? (
234 |
235 | ) : (
236 |
241 | )}
242 |
243 | );
244 | }
245 |
--------------------------------------------------------------------------------
/src/modules/components/Entry.tsx:
--------------------------------------------------------------------------------
1 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
2 | import {
3 | Accordion,
4 | AccordionDetails as MuiAccordionDetails,
5 | AccordionSummary,
6 | Box,
7 | Divider,
8 | DividerProps,
9 | Grid,
10 | Link,
11 | Paper,
12 | styled,
13 | Typography,
14 | } from "@mui/material";
15 | import { dump, load } from "js-yaml";
16 | import NextLink from "next/link";
17 | import { Convert } from "pvtsutils";
18 | import { ReactNode } from "react";
19 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
20 | import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
21 | import {
22 | DSSEV001Schema,
23 | IntotoV001Schema,
24 | IntotoV002Schema,
25 | LogEntry,
26 | RekorSchema,
27 | } from "rekor";
28 | import { toRelativeDateString } from "../utils/date";
29 | import { DSSEViewer } from "./DSSE";
30 | import { HashedRekordViewer } from "./HashedRekord";
31 | import { IntotoViewer001 } from "./Intoto001";
32 | import { IntotoViewer002 } from "./Intoto002";
33 |
34 | const DUMP_OPTIONS: jsyaml.DumpOptions = {
35 | replacer: (key, value) => {
36 | if (Convert.isBase64(value)) {
37 | try {
38 | let decodedVal = window.atob(value);
39 | if (decodedVal.startsWith("-----BEGIN")) {
40 | return decodedVal;
41 | }
42 | return load(decodedVal);
43 | } catch (e) {
44 | return value;
45 | }
46 | }
47 | return value;
48 | },
49 | };
50 |
51 | const AccordionDetails = styled(MuiAccordionDetails)({
52 | padding: 0,
53 | });
54 |
55 | /**
56 | * Return a parsed JSON object of the provided content.
57 | * If an error occurs, the provided content is returned as a raw string.
58 | */
59 | function tryJSONParse(content?: string): unknown {
60 | if (!content) {
61 | return content;
62 | }
63 | try {
64 | return JSON.parse(content);
65 | } catch (e) {
66 | return content;
67 | }
68 | }
69 |
70 | export function Card({
71 | title,
72 | content,
73 | dividerSx = {},
74 | }: {
75 | title: ReactNode;
76 | content: ReactNode;
77 | dividerSx?: DividerProps["sx"];
78 | }) {
79 | return (
80 |
85 |
93 |
94 |
98 | {title}
99 |
100 |
111 | {content}
112 |
113 |
114 |
115 | );
116 | }
117 |
118 | export function Entry({ entry }: { entry: LogEntry }) {
119 | const [uuid, obj] = Object.entries(entry)[0];
120 |
121 | const body = JSON.parse(window.atob(obj.body)) as {
122 | kind: string;
123 | apiVersion: string;
124 | spec: unknown;
125 | };
126 |
127 | // Extract the JSON payload of the attestation. Some attestations appear to be
128 | // double Base64 encoded. This loop will attempt to extract the content, with
129 | // a max depth as a safety gap.
130 | let rawAttestation = obj.attestation?.data as string | undefined;
131 | for (let i = 0; Convert.isBase64(rawAttestation) && i < 3; i++) {
132 | rawAttestation = window.atob(rawAttestation);
133 | }
134 | const attestation = tryJSONParse(rawAttestation);
135 |
136 | let parsed: ReactNode | undefined;
137 | switch (body.kind) {
138 | case "hashedrekord":
139 | parsed = ;
140 | break;
141 | case "intoto":
142 | if (body.apiVersion == "0.0.1") {
143 | parsed = ;
144 | break;
145 | } else {
146 | parsed = ;
147 | break;
148 | }
149 | case "dsse":
150 | parsed = ;
151 | break;
152 | }
153 |
154 | return (
155 |
156 |
164 | Entry UUID:{" "}
165 |
170 | {uuid}
171 |
172 |
173 |
177 |
182 |
187 |
194 |
195 |
200 |
208 | {obj.logIndex}
209 |
210 | }
211 | />
212 |
213 |
218 |
228 |
229 |
230 |
234 | {parsed}
235 |
243 | <>
244 |
248 | }
250 | aria-controls="body-content"
251 | id="body-header"
252 | >
253 | Raw Body
254 |
255 |
256 |
260 | {dump(body, DUMP_OPTIONS)}
261 |
262 |
263 |
264 | {attestation && (
265 |
266 | }
268 | aria-controls="attestation-content"
269 | id="attestation-header"
270 | >
271 | Attestation
272 |
273 |
274 |
278 | {dump(attestation)}
279 |
280 |
281 |
282 | )}
283 | {obj.verification && (
284 |
285 | }
287 | aria-controls="verification-content"
288 | id="verification-header"
289 | >
290 | Verification
291 |
292 |
293 |
297 | {dump(obj.verification)}
298 |
299 |
300 |
301 | )}
302 | >
303 |
304 |
305 | );
306 | }
307 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/public/chainguard-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/public/sigstore_rekor-horizontal-color.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------