diff --git a/gcp/website/frontend3/src/go/base.html b/gcp/website/frontend3/src/go/base.html new file mode 100644 index 00000000000..8f1a32cea86 --- /dev/null +++ b/gcp/website/frontend3/src/go/base.html @@ -0,0 +1,126 @@ + + + + + + + + {{ if .DisableTurboCache }} + + {{ end }} + + + + + + + + + + + OSV - Open Source Vulnerabilities + + + + {{ block "extra_head" . }}{{ end }} + + + +
+ {{ block "top_bar" . }} +
+ + + + + + + + +
+ {{ end }} + {{ block "content" . }}{{ end }} +
+ + + + diff --git a/gcp/website/frontend3/src/go/templates/404.html b/gcp/website/frontend3/src/go/templates/404.html new file mode 100644 index 00000000000..041cae32020 --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/404.html @@ -0,0 +1,51 @@ +{{ define "extra_head" }} + + + +{{ end }} + +{{ define "top_bar" }}{{ end }} + +{{ define "content" }} +{{ if .FailedImportVulnID }} +
+
+
+

{{ .FailedImportVulnID }} Not Found!

+

+ The record has not been successfully imported from its source
+ Please see our + FAQ for more information. +

+ +
+
+ +
+{{ else }} +
+
+
+

Oops! Page Not Found!

+

+ While you're here, why not check out our + documentation or + FAQ? +

+ +
+
+ +
+{{ end }} +{{ end }} + diff --git a/gcp/website/frontend3/src/go/templates/blog.html b/gcp/website/frontend3/src/go/templates/blog.html new file mode 100644 index 00000000000..f0c05304cc4 --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/blog.html @@ -0,0 +1,12 @@ +{{ define "content" }} +
+
+
+
+

Blog

+ {{ .Index }} +
+
+
+
+{{ end }} diff --git a/gcp/website/frontend3/src/go/templates/blog_post.html b/gcp/website/frontend3/src/go/templates/blog_post.html new file mode 100644 index 00000000000..58a539c096b --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/blog_post.html @@ -0,0 +1,11 @@ +{{ define "content" }} +
+
+
+
+ {{ .Content }} +
+
+
+
+{{ end }} diff --git a/gcp/website/frontend3/src/go/templates/home.html b/gcp/website/frontend3/src/go/templates/home.html new file mode 100644 index 00000000000..22b8a8ede35 --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/home.html @@ -0,0 +1,341 @@ +{{ define "extra_head" }} + + + + + + +{{ end }} + +{{ define "content" }} +
+
+
+

A distributed vulnerability database for Open Source

+
+

An open, precise, and distributed approach to producing and consuming vulnerability information for + open + source. +

+
+ + +
+
+

Ecosystems

+
+
+
+
+
+
+
+ {{ range .Ecosystems }} +
{{ .Name }}
+
+ + + {{ .Count }} + + View {{ .Name }} vulnerabilities + + + +
+ {{ end }} +
+
+
+ +
+

OSV schema

+

+ All advisories in this database use the + OpenSSF OSV format, which + was developed in collaboration with open source communities. +

+

+ The OSV schema provides a human and machine readable data format to + describe vulnerabilities in a way that precisely maps to open source + package versions or commit hashes. +

+
+
+
{
+  "schema_version": "1.7.4",
+  "id": "GHSA-c3g4-w6cv-6v7h",
+  "modified": "2022-04-01T13:56:42Z",
+  "published": "2022-04-01T13:56:42Z",
+  "aliases": [ "CVE-2022-27651" ],
+  "summary": "Non-empty default inheritable capabilities for linux container in Buildah",
+  "details": "A bug was found in Buildah where containers were created ...",
+  "affected": [
+    {
+      "package": {
+        "ecosystem": "Go",
+        "name": "github.com/containers/buildah"
+      },
+      "ranges": [
+        {
+          "type": "SEMVER",
+          "events": [
+            {
+              "introduced": "0"
+            },
+            {
+              "fixed": "1.25.0"
+            }
+          ]
+        }
+      ]
+    }
+  ],
+  "references": [
+    {
+      "type": "WEB",
+      "url": "https://github.com/containers/buildah/commit/..."
+    },
+    {
+      "type": "PACKAGE",
+      "url": "https://github.com/containers/buildah"
+    }
+  ]
+}
+
+
+ +
+
+

Data sources

+

+ This infrastructure serves as an aggregator of vulnerability databases + that have adopted the OSV schema, including + GitHub Security Advisories, + PyPA, + RustSec, and + Global Security Database, and + more. +

+ +
+
+

Use the API

+

+ An easy-to-use API is available to query for all known vulnerabilities + by either a commit hash, or a package version. +

+
+
+

Query by commit hash

+
curl -d \
+  '{"commit": "6879efc2c1596d11a6a6ad296f80063b558d5e0f"}' \
+  "https://api.osv.dev/v1/query"
+ + + content_copy + + +
+
+

Query by version number

+
curl -d \
+  '{"version": "2.4.1",
+    "package": {"name": "jinja2", "ecosystem": "PyPI"}}' \
+  "https://api.osv.dev/v1/query"
+ + + content_copy + + +
+
+ +
+
+ +

Vulnerability Scanner

+
+
+
+

Install OSV‑Scanner

+
+go install github.com/google/osv-scanner/v2/cmd/osv-scanner@v2
+          
+ + + content_copy + + +
+
+
+

Scan SBOM or Lockfiles

+
+osv-scanner --sbom=cycloned-or-spdx-sbom.json
+osv-scanner --lockfile=package-lock.json
+          
+ + + content_copy + + +
+
+

Scan directory recursively

+
+osv-scanner -r path/to/your/project
+          
+ + + content_copy + + +
+
+
+ +
+
+
+ +

Remediation Tools

+
+
+

Guided Remediation (basic)

+
+osv-scanner fix --non-interactive --strategy=in-place -L path/to/package-lock.json
+osv-scanner fix --non-interactive --strategy=relock -M path/to/package.json -L path/to/package-lock.json
+          
+ + + content_copy + + +
+
+

Guided Remediation (interactive)

+
+osv-scanner fix -M path/to/package.json -L path/to/package-lock.json
+          
+ + + content_copy + + +
+ + +
+
+ +
+
+
+

Container Image Scanning

+

+ You can use + OSV-Scanner + to scan your container images for known vulnerabilities. +

+ + +
+
+
+

Scan container image

+
+osv-scanner scan image --serve alpine:3.12
+          
+ + + content_copy + + +
+
+
+ + +
+ Screenshot of container scan HTML output +
+ + +
+

GitHub Workflows

+

OSV-Scanner also provides reusable GitHub workflows that can be easily + integrated into CI/CD pipelines to provide continuous vulnerability scanning coverage. This can scan + newly added dependencies in pull requests for introduced vulnerabilities, as well as perform regular + vulnerability scans for the entire project.

+
Screenshot of OSV-Scanner GitHub Action
+ +
+ +
+

Open source

+

This project is open source. If you + have any ideas or questions, please feel free to reach out by creating an issue!

+ + +
+
+
+ +
+{{ end }} \ No newline at end of file diff --git a/gcp/website/frontend3/src/go/templates/linter/index.html b/gcp/website/frontend3/src/go/templates/linter/index.html new file mode 100644 index 00000000000..90f2721758f --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/linter/index.html @@ -0,0 +1,99 @@ +{{ define "top_bar" }}{{ end }} +{{ define "content" }} + + + + + + + Vulnerability Linter Report + + + + + + + +
+
+
+ +
+

Open Source Vulnerabilities

+
+ sync +
+
+ +
+
+
+
+ Linter Report +
+
+ +
+
+ +
+
+
+
+ Home Database +
+

+
+ keyboard_arrow_down +
+
+
+
+
+ Finding +
+

+
+ keyboard_arrow_down +
+
+
+
+
+ + + +
+ + + + + + + + + + + +
Bug IDFindings + Modified unfold_more +
+ +
+
+
+
+
+ + + + +{{ end }} \ No newline at end of file diff --git a/gcp/website/frontend3/src/go/templates/list.html b/gcp/website/frontend3/src/go/templates/list.html new file mode 100644 index 00000000000..77c2e20d99f --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/list.html @@ -0,0 +1,166 @@ +{% extends 'base.html' %} +{% set active_section = 'vulnerabilities' %} +{% set disable_turbo_cache = 'true' %} + +{% macro table_header_cell(column_id, column_name, is_sortable, is_sorted, is_descending) %} + +
+
+ {{ column_name }} +
+ {% if is_sorted %} + + arrow_upward + + + {% endif %} +
+
+{% endmacro %} + +{% block content %} +
+
+
+
+

Vulnerabilities

+ +
+
+
+ +
+
+
+ {{ table_header_cell('id', 'ID', is_sortable=False, is_sorted=False, is_descending=False) }} + {{ table_header_cell('package', 'Packages', is_sortable=False, is_sorted=False, is_descending=False) }} + {{ table_header_cell('summary', 'Summary', is_sortable=False, is_sorted=False, is_descending=False) }} + {{ table_header_cell('published', 'Published', is_sortable=True, is_sorted=True, is_descending=True) }} + {{ table_header_cell('attributes', 'Attributes', is_sortable=False, is_sorted=False, is_descending=False) }} +
+
+
+ + {% for vulnerability in vulnerabilities %} +
+ + {{ vulnerability.id + }} + + +
    + {% for package in vulnerability.packages[:5] %} +
  • {{ package }}
  • + {% endfor %} + {% if vulnerability.packages|length > 5 %} + {% set remainingPkgCount = vulnerability.packages|length - 5 %} +
  • ... {{ remainingPkgCount }} more
  • + {% elif vulnerability.packages|length == 0 %} +
  • Not specified
  • + {% endif %} +
+
+ + {% if vulnerability.summary %} + {{ vulnerability.summary | literal_backticks }} + {% else %} + See record for full details + {% endif %} + + + + {{ vulnerability.published | relative_time }} + + + +
    +
  • + {%- if vulnerability.is_fixed -%} + Fix available + {%- else -%} + No fix available + {%- endif -%} +
  • + {%- if vulnerability.severity_score and vulnerability.severity_rating -%} +
  • + Severity - {{ + vulnerability.severity_score }} ({{ vulnerability.severity_rating }}) +
  • + {%- endif -%} +
+
+
+ {%- endfor -%} + {%- if vulnerabilities | length == 0 -%} + No results (check our FAQ if this is unexpected) + {%- endif -%} + {%- if page < total_pages -%} + + + {%- endif -%} +
+
+
+ + + +
+
+{% endblock %} diff --git a/gcp/website/frontend3/src/go/templates/triage.html b/gcp/website/frontend3/src/go/templates/triage.html new file mode 100644 index 00000000000..49965c3ea00 --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/triage.html @@ -0,0 +1,72 @@ +{{ define "extra_head" }} + + + + +{{ end }} + +{{ define "content" }} +
+
+

CVE Conversion Triager

+ +
+ + Load +
+
+
+ {{ range $i := .Columns }} +
+
+ +
+ +
+
+
+ +
+
Select a source to view content
+
+ + + + +
+
+ {{ end }} +
+
+{{ end }} diff --git a/gcp/website/frontend3/src/go/templates/vulnerability.html b/gcp/website/frontend3/src/go/templates/vulnerability.html new file mode 100644 index 00000000000..d1faee2eecb --- /dev/null +++ b/gcp/website/frontend3/src/go/templates/vulnerability.html @@ -0,0 +1,618 @@ +{% extends 'base.html' -%} +{% set active_section = 'vulnerabilities' -%} + +{% block extra_head %} + +{% endblock %} + +{% block content -%} +
+
+
+
+

+ {{ vulnerability.id }} +

+ {% if vulnerability.human_source_link and vulnerability.human_source_link.startswith("https://github.com/advisories/") -%} + + Suggest an improvement + + {% elif vulnerability.human_source_link and not vulnerability.id.startswith("openSUSE-") -%} + + {% else -%} + + See a problem? + + {% endif -%} +
+
+
+ {%- if vulnerability.human_source_link and not vulnerability.id.startswith("openSUSE-") -%} +
Source
+
{{ + vulnerability.human_source_link }} +
+ {%- endif -%} +
Import Source
+
{{ + vulnerability.source }}
+ +
JSON Data
+
+ https://{{ api_url }}/v1/vulns/{{ vulnerability.id }} +
+ {% if vulnerability.aliases -%} +
Aliases
+
+
    + {% for alias in vulnerability.aliases -%} +
  • + {% if alias in vulnerability.known_ids -%} + {{ alias }} + {% else -%} + {{ alias }} + {% endif -%} +
  • + {% endfor -%} +
+
+ {% endif -%} + {%- if vulnerability.upstream_hierarchy -%} +
Upstream
+
{{ vulnerability.upstream_hierarchy | safe }}
+ {%- endif -%} + {%- if vulnerability.downstream_hierarchy -%} +
Downstream
+
{{ vulnerability.downstream_hierarchy | safe }}
+ {%- endif -%} + {% if vulnerability.related -%} +
Related
+
+
    + {% for related in vulnerability.related -%} +
  • + {% if related in vulnerability.known_ids -%} + {{ related }} + {% else -%} + {{ related }} + {% endif -%} +
  • + {% endfor -%} +
+
+ {% endif -%} + {%- if vulnerability.withdrawn -%} +
+ Withdrawn + +
+
{{ vulnerability.withdrawn }}
+ {% endif -%} +
Published
+
{{ vulnerability.published }}
+
Modified
+
{{ vulnerability.modified }}
+ {%- if vulnerability.severity -%} +
Severity
+
+
    + {% for item in vulnerability.severity -%} +
  • + {% if item | is_cvss %} + {{ item | display_severity_rating }} + {{ item.type }} - {{ item.score }} + + CVSS Calculator + {% else %} + {{ item.type }} - {{ item.score }} + {% endif %} +
  • + {% endfor -%} +
+
+ {%- endif -%} +
Summary
+
+ {% if vulnerability.summary %} + {{ vulnerability.summary | literal_backticks }} + {% else %} + [none] + {% endif %} +
+
Details
+
+ {{ vulnerability.details | markdown | safe -}} +
+ {% if vulnerability.database_specific -%} +
+ Database specific + +
+
{{ vulnerability.database_specific | display_json }}
+ {% endif %} +
References
+
+ +
+ {% if vulnerability.credits -%} +
Credits
+
+
    + {% for credit in vulnerability.credits -%} +
  • +
      +
    • {{ credit.name }}{% if 'type' in credit %} - {{ credit.type }}{% endif %}
    • + {%- if 'contact' in credit -%} +
    • +
        + {%- for item in credit.contact -%} +
      • {{ item }}
      • + {%- endfor -%} +
      +
    • + {%- endif -%} +
    +
  • + {% endfor -%} +
+
+ {% endif %} +
+
+
+
+
+

Affected packages

+ + {% if vulnerability.affected|should_collapse %} + {% set ecosystems = vulnerability.affected | group_by_ecosystem %} +
+ {% for ecosystem_name, packages in ecosystems.items() -%} + {% set is_last_ecosystem = loop.last %} +
+ + {{ ecosystem_name }} + +
+ {% for affected in packages -%} +
+ + {% if 'package' in affected %} + {{ affected.package.name }} + {% else %} + {% set affected_repo = affected.ranges | default([], true) | selectattr('repo') | map(attribute='repo') | first %} + {% if affected_repo %} + {{ affected_repo | strip_scheme }} + {% endif %} + {% endif %} + +
+
+ {%- if 'package' in affected -%} +
+

Package

+
+
+
Name
+
{{ affected.package.name }}
+ {%- if ecosystem_name | has_link_to_deps_dev -%} +
View open source insights on deps.dev
+ {%- endif -%} + {%- if 'purl' in affected.package -%} +
Purl
+
{{ affected.package.purl }}
+ {%- endif -%} +
+
+
+ {%- endif -%} + {%- if 'severity' in affected -%} +
+

Severity

+
+
    + {% for item in affected.severity -%} +
  • + {% if item | is_cvss %} + {{ item | display_severity_rating }} + {{ item.type }} - {{ item.score }} + + CVSS Calculator + {% else %} + {{ item.type }} - {{ item.score }} + {% endif %} +
  • + {% endfor -%} +
+
+
+ {%- endif -%} +
+

Affected ranges

+
+ {% for range in affected.ranges -%} +
+
Type
+
{{ range.type -}}
+ {%- if range.repo -%} +
Repo
+
{{ range.repo }}
+ {%- endif -%} +
Events
+
+
+ {% for event in range.events -%} +
{{ event | event_type -}}
+
+ {% set link = event | event_link -%} + {% if link -%} + {{ event | event_value -}} + {% elif event | event_type == 'Introduced' and event | event_value == '0' -%} +
{{ event | event_value -}} + {% if range.type == 'GIT' %} + Unknown introduced commit / All previous commits are affected + {% else -%} + Unknown introduced version / All previous versions are affected + {% endif -%} +
+ {% else -%} + {{ event | event_value -}} + {% endif -%} +
+ {% endfor -%} +
+
+ {%- if range.database_specific -%} +
Database specific
+
{{ range.database_specific | display_json }}
+ {%- endif -%} +
+ {% endfor -%} +
+
+ {% if affected.versions -%} +
+

Affected versions

+
+ {% for group, versions in (affected.versions|group_versions(ecosystem_name)).items() -%} +
+ {{ group }} +
+ {% for version in versions -%} +
{{ version }}
+ {% endfor -%} +
+
+ {% endfor -%} +
+
+ {% endif -%} + {% if affected.ecosystem_specific -%} +
+

Ecosystem specific

+
{{ affected.ecosystem_specific | display_json }}
+
+ {% endif -%} + {% if affected.database_specific -%} +
+

Database specific

+
+ {% set db_specific = affected.database_specific %} + {% if db_specific is mapping %} +
+ {% for key, value in db_specific.items() %} +
+ {{ key }} +
+
{{ value | display_json }}
+
+
+ {% endfor %} +
+ {% else %} +
{{ db_specific | display_json }}
+ {% endif %} +
+
+ {% endif -%} +
+
+
+ {% endfor -%} +
+
+ {% endfor -%} +
+ + {% else %} + + {% for affected in vulnerability.affected -%} + {% if 'package' in affected %} + {% set ecosystem = affected.package.ecosystem %} + {% set package = affected.package.name %} + {% else %} + {% set ecosystem = 'Git' %} + {% set affected_repo = affected.ranges | default([], true) | selectattr('repo') | map(attribute='repo') | first %} + {% if affected_repo %} + {% set package = affected_repo | strip_scheme %} + {% endif %} + {% endif %} +

+ {{ ecosystem }} + / + {{ package }} +

+
+ {%- if 'package' in affected -%} +
+

+ Package +

+
+
+
Name
+ {%- if affected.package | package_in_ecosystem -%} +
{{ + affected.package.name }}
+ {%- else -%} +
{{ affected.package.name }}
+ {%- endif -%} + {%- if ecosystem | has_link_to_deps_dev -%} +
+ View open source insights on deps.dev
+ {%- endif -%} + {%- if 'purl' in affected.package -%} +
Purl
+
{{ affected.package.purl }}
+ {%- endif -%} +
+
+
+ {%- endif -%} + {%- if 'severity' in affected -%} +
+

+ Severity +

+
+
    + {% for item in affected.severity -%} +
  • + {% if item | is_cvss %} + {{ item | display_severity_rating }} + {{ item.type }} - {{ item.score }} + + CVSS Calculator + {% else %} + {{ item.type }} - {{ item.score }} + {% endif %} +
  • + {% endfor -%} +
+
+
+ {%- endif -%} +
+

+ Affected ranges + +

+
+ {% for range in affected.ranges -%} +
+
Type
+
{{ range.type -}}
+ + {%- if range.repo -%} +
Repo
+
{{ range.repo }}
+ {%- endif -%} + +
Events
+
+
+ {% for event in range.events -%} +
+ {{ event | event_type -}} +
+
+ {% set link = event | event_link -%} + {% if link -%} + + {{ event | event_value -}} + + {% elif event | event_type == 'Introduced' and event | event_value == '0' -%} +
+ {{ event | event_value -}} + {% if range.type == 'GIT' %} + Unknown introduced commit / All previous commits are affected + {% else -%} + Unknown introduced version / All previous versions are affected + {% endif -%} +
+ {% else -%} + {{ event | event_value -}} + {% endif -%} +
+ {% endfor -%} +
+
+ + {%- if range.database_specific -%} +
+ Database specific + +
+
{{ range.database_specific | display_json }}
+ {%- endif -%} +
+ {% endfor -%} +
+
+ {% if affected.versions -%} +
+

+ Affected versions + +

+
+ {% for group, versions in (affected.versions|group_versions(ecosystem)).items() -%} +
+ {{ group }} +
+ {% for version in versions -%} +
{{ version }}
+ {% endfor -%} +
+
+ {% endfor -%} +
+
+ {% endif -%} + {% if affected.ecosystem_specific -%} +
+

+ Ecosystem specific + +

+
+
{{ affected.ecosystem_specific | display_json }}
+
+
+ {% endif -%} + {% if affected.database_specific -%} +
+

+ Database specific + +

+
+ {% set db_specific = affected.database_specific %} + {% if db_specific is mapping %} +
+ {% for key, value in db_specific.items() %} +
+ {{ key }} +
+
{{ value | display_json }}
+
+
+ {% endfor %} +
+ {% else %} +
{{ db_specific | display_json }}
+ {% endif %} +
+
+ {% endif -%} +
+ {% endfor -%} +
+ {% endif %} +
+
+ + + + + +{% endblock -%} diff --git a/gcp/website/frontend3/webpack.dev.js b/gcp/website/frontend3/webpack.dev.js index b1523cbf340..0447f23fd8a 100644 --- a/gcp/website/frontend3/webpack.dev.js +++ b/gcp/website/frontend3/webpack.dev.js @@ -37,6 +37,8 @@ module.exports = { new CopyPlugin({ patterns: [ { from: './src/templates', to: '.', globOptions: { ignore: ['**/base.html', '**/triage.html'] } }, + // TODO(michaelkedar): Remove this once the website is fully migrated. + { from: './src/go/templates', to: 'go/', globOptions: { ignore: ['**/base.html', '**/triage.html'] } }, { from: './img/*', to: 'static/img/[name][ext]' }, ], }), @@ -58,6 +60,25 @@ module.exports = { chunks: ['triage'], excludeChunks: ['main', 'linter'], }), + // TODO(michaelkedar): Remove this once the website is fully migrated. + new HtmlWebpackPlugin({ + filename: 'go/base.html', + template: './src/go/base.html', + chunks: ['main'], + excludeChunks: ['linter'], + }), + new HtmlWebpackPlugin({ + filename: 'go/linter.html', + template: './src/go/templates/linter/index.html', + chunks: ['linter'], + excludeChunks: ['main'], + }), + new HtmlWebpackPlugin({ + filename: 'go/triage.html', + template: './src/go/templates/triage.html', + chunks: ['triage'], + excludeChunks: ['main', 'linter'], + }), new MiniCssExtractPlugin({ filename: 'static/[name].css' }), diff --git a/gcp/website/frontend3/webpack.prod.js b/gcp/website/frontend3/webpack.prod.js index 8c322a558c2..988beedb432 100644 --- a/gcp/website/frontend3/webpack.prod.js +++ b/gcp/website/frontend3/webpack.prod.js @@ -37,6 +37,8 @@ module.exports = { new CopyPlugin({ patterns: [ { from: './src/templates/*.html', to: '[name].html', globOptions: { ignore: ['**/base.html', '**/triage.html'] } }, + // TODO(michaelkedar): Remove this once the website is fully migrated. + { from: './src/go/templates/*.html', to: 'go/[name].html', globOptions: { ignore: ['**/base.html', '**/triage.html'] } }, { from: './img/*', to: 'static/img/[name][ext]' }, ], }), @@ -58,6 +60,25 @@ module.exports = { chunks: ['triage'], excludeChunks: ['main', 'linter'], }), + // TODO(michaelkedar): Remove this once the website is fully migrated. + new HtmlWebpackPlugin({ + filename: 'go/base.html', + template: './src/go/base.html', + chunks: ['main'], + excludeChunks: ['linter'], + }), + new HtmlWebpackPlugin({ + filename: 'go/linter.html', + template: './src/go/templates/linter/index.html', + chunks: ['linter'], + excludeChunks: ['main'], + }), + new HtmlWebpackPlugin({ + filename: 'go/triage.html', + template: './src/go/templates/triage.html', + chunks: ['triage'], + excludeChunks: ['main', 'linter'], + }), new MiniCssExtractPlugin({ filename: 'static/[name].[contenthash].css' }), diff --git a/go/internal/website/blog.go b/go/internal/website/blog.go index 7ca74004760..3e9b8ecb7bb 100644 --- a/go/internal/website/blog.go +++ b/go/internal/website/blog.go @@ -1,45 +1,117 @@ package website import ( - "fmt" + "html/template" + "io/fs" + "log/slog" + "mime" "net/http" + "path" + "regexp" + + "github.com/google/osv.dev/go/logger" ) +var validBlogName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +func (s *Server) loadBlogContent(filePath string) (template.HTML, error) { + fullPath := path.Join("static", "blog", filePath) + content, err := fs.ReadFile(s.config.StaticFS, fullPath) + if err != nil { + return "", err + } + + return template.HTML(content), nil //nolint:gosec // Trusted static content built by Hugo +} + // handleBlogIndex handles serving the blog landing page /blog/. -func (s *Server) handleBlogIndex(w http.ResponseWriter, _ *http.Request) { - // TODO: Load blog index content from StaticFS (static/blog/index.html) and render blog template - http.Error(w, "Blog index handler stub", http.StatusNotImplemented) +func (s *Server) handleBlogIndex(w http.ResponseWriter, r *http.Request) { + indexHTML, err := s.loadBlogContent("index.html") + if err != nil { + s.RenderNotFound(w, r, "") + + return + } + + data := BlogPageData{ + BasePageData: BasePageData{ + ActiveSection: "blog", + DisableTurboCache: false, + }, + Index: indexHTML, + } + + s.render(w, r, "blog.html", http.StatusOK, data) } // handleBlogRSS handles serving the blog RSS feed /blog/index.xml. -func (s *Server) handleBlogRSS(w http.ResponseWriter, _ *http.Request) { - // TODO: Serve static/blog/index.xml from StaticFS - http.Error(w, "Blog RSS handler stub", http.StatusNotImplemented) +func (s *Server) handleBlogRSS(w http.ResponseWriter, r *http.Request) { + fullPath := path.Join("static", "blog", "index.xml") + rssContent, err := fs.ReadFile(s.config.StaticFS, fullPath) + if err != nil { + s.RenderNotFound(w, r, "") + + return + } + + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(rssContent); err != nil { + logger.ErrorContext(r.Context(), "Failed to write blog RSS response", slog.Any("error", err)) + } } // handleBlogPost handles serving individual blog posts /blog/posts/{blog_name}/. func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { blogName := r.PathValue("blog_name") - if blogName == "" { - http.NotFound(w, r) + if blogName == "" || !validBlogName.MatchString(blogName) { + s.RenderNotFound(w, r, "") return } - // TODO: Validate blog_name, load static/blog/posts/{blog_name}/index.html and render post template - http.Error(w, fmt.Sprintf("Blog post handler stub (blogName=%q)", blogName), http.StatusNotImplemented) + postHTML, err := s.loadBlogContent(path.Join("posts", blogName, "index.html")) + if err != nil { + s.RenderNotFound(w, r, "") + + return + } + + data := BlogPostPageData{ + BasePageData: BasePageData{ + ActiveSection: "blog", + DisableTurboCache: true, + }, + Content: postHTML, + } + + s.render(w, r, "blog_post.html", http.StatusOK, data) } // handleBlogPostFile handles serving static assets inside blog post directories /blog/posts/{blog_name}/{file_name}. func (s *Server) handleBlogPostFile(w http.ResponseWriter, r *http.Request) { blogName := r.PathValue("blog_name") fileName := r.PathValue("file_name") - if blogName == "" || fileName == "" { - http.NotFound(w, r) + if blogName == "" || fileName == "" || !validBlogName.MatchString(blogName) { + s.RenderNotFound(w, r, "") return } - // TODO: Serve static/blog/posts/{blog_name}/{file_name} from StaticFS - http.Error(w, fmt.Sprintf("Blog post asset handler stub (blogName=%q, fileName=%q)", blogName, fileName), http.StatusNotImplemented) + assetPath := path.Join("static", "blog", "posts", blogName, path.Base(fileName)) + assetContent, err := fs.ReadFile(s.config.StaticFS, assetPath) + if err != nil { + s.RenderNotFound(w, r, "") + + return + } + + contentType := mime.TypeByExtension(path.Ext(fileName)) + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write(assetContent); err != nil { + logger.ErrorContext(r.Context(), "Failed to write blog asset response", slog.String("asset", assetPath), slog.Any("error", err)) + } } diff --git a/go/internal/website/linter.go b/go/internal/website/linter.go index e31433bf2ed..d8536e843f9 100644 --- a/go/internal/website/linter.go +++ b/go/internal/website/linter.go @@ -1,21 +1,27 @@ package website import ( - "fmt" + "encoding/json" "net/http" ) // handleLinterPage handles serving the linter findings UI page. -func (s *Server) handleLinterPage(w http.ResponseWriter, _ *http.Request) { - // TODO: Serve linter UI page / template - http.Error(w, "Linter UI page stub", http.StatusNotImplemented) +func (s *Server) handleLinterPage(w http.ResponseWriter, r *http.Request) { + data := LinterPageData{ + BasePageData: BasePageData{ + ActiveSection: "linter", + }, + } + + s.render(w, r, "linter.html", http.StatusOK, data) } // handleLinterSources handles listing sources that have linter findings from GCS. func (s *Server) handleLinterSources(w http.ResponseWriter, _ *http.Request) { // TODO: List prefixes in GCS bucket osv-test-public-import-logs under linter-result/ w.Header().Set("Content-Type", "application/json") - http.Error(w, `{"error": "Linter sources handler stub"}`, http.StatusNotImplemented) + //nolint:errchkjson + _ = json.NewEncoder(w).Encode([]string{"ghsa", "cve-osv", "malicious-packages"}) } // handleLinterFindings handles fetching linter findings JSON for a specific source from GCS. @@ -29,5 +35,10 @@ func (s *Server) handleLinterFindings(w http.ResponseWriter, r *http.Request) { // TODO: Download linter-result//result.json from GCS bucket w.Header().Set("Content-Type", "application/json") - http.Error(w, fmt.Sprintf(`{"error": "Linter findings handler stub", "source": %q}`, source), http.StatusNotImplemented) + //nolint:errchkjson + _ = json.NewEncoder(w).Encode(map[string][]map[string]string{ + "path/OSV-1234-5678.json": {{ + "Code": "SCH:001", + "Message": "record is bad", + }}}) } diff --git a/go/internal/website/pagedata.go b/go/internal/website/pagedata.go new file mode 100644 index 00000000000..cb12bdbebe0 --- /dev/null +++ b/go/internal/website/pagedata.go @@ -0,0 +1,59 @@ +package website + +import "html/template" + +// BasePageData contains common fields required by the base.html layout template. +type BasePageData struct { + ActiveSection string + DisableTurboCache bool +} + +// EcosystemDisplay holds pre-calculated bubble data for ecosystem vulnerability counts on the home page. +type EcosystemDisplay struct { + Name string + Count int + Radius float64 + TooltipTop float64 +} + +// HomePageData represents the data context passed to home.html template. +type HomePageData struct { + BasePageData + + Ecosystems []EcosystemDisplay +} + +// NotFoundPageData represents the data context passed to 404.html template. +type NotFoundPageData struct { + BasePageData + + FailedImportVulnID string +} + +// BlogPageData represents the data context passed to blog.html template. +type BlogPageData struct { + BasePageData + + Index template.HTML +} + +// BlogPostPageData represents the data context passed to blog_post.html template. +type BlogPostPageData struct { + BasePageData + + Content template.HTML +} + +// LinterPageData represents the data context passed to linter.html template. +type LinterPageData struct { + BasePageData +} + +// TriagePageData represents the data context passed to triage.html template. +type TriagePageData struct { + BasePageData + + // Columns contains 1-indexed column numbers (e.g., []int{1, 2, 3}) to match + // element IDs (e.g. column-1) and URL query parameters (s1, s2, s3) expected by triage.js. + Columns []int +} diff --git a/go/internal/website/server.go b/go/internal/website/server.go index b3b70fdcd41..3de4ccc0876 100644 --- a/go/internal/website/server.go +++ b/go/internal/website/server.go @@ -2,11 +2,14 @@ package website import ( + "bytes" "errors" + "html/template" "io/fs" "log/slog" "net/http" "os" + "path" "time" "github.com/google/osv.dev/go/logger" @@ -16,8 +19,9 @@ const goVanityMetadata = `Home"), 0600); err != nil { + goDir := filepath.Join(tmpDir, "go") + if err := os.MkdirAll(goDir, 0755); err != nil { + t.Fatalf("failed to create go dir: %v", err) + } + if err := os.WriteFile(filepath.Join(goDir, "base.html"), []byte(`{{ block "content" . }}{{ end }}`), 0600); err != nil { + t.Fatalf("failed to write base.html: %v", err) + } + if err := os.WriteFile(filepath.Join(goDir, "home.html"), []byte(`{{ define "content" }}Home{{ end }}`), 0600); err != nil { t.Fatalf("failed to write home.html: %v", err) } + if err := os.WriteFile(filepath.Join(goDir, "404.html"), []byte(`{{ define "content" }}404 {{ .FailedImportVulnID }}{{ end }}`), 0600); err != nil { + t.Fatalf("failed to write 404.html: %v", err) + } if err := os.WriteFile(filepath.Join(imgDir, "favicon-32x32.png"), []byte("FAVICON"), 0600); err != nil { t.Fatalf("failed to write favicon: %v", err) } + blogDir := filepath.Join(tmpDir, "static", "blog") + postDir := filepath.Join(blogDir, "posts", "hello-world") + if err := os.MkdirAll(postDir, 0755); err != nil { + t.Fatalf("failed to create blog post dir: %v", err) + } + if err := os.WriteFile(filepath.Join(goDir, "blog.html"), []byte(`{{ define "content" }}Blog Index: {{ .Index }}{{ end }}`), 0600); err != nil { + t.Fatalf("failed to write blog.html: %v", err) + } + if err := os.WriteFile(filepath.Join(goDir, "blog_post.html"), []byte(`{{ define "content" }}Blog Post: {{ .Content }}{{ end }}`), 0600); err != nil { + t.Fatalf("failed to write blog_post.html: %v", err) + } + if err := os.WriteFile(filepath.Join(blogDir, "index.html"), []byte("

Blog Index

"), 0600); err != nil { + t.Fatalf("failed to write blog index: %v", err) + } + if err := os.WriteFile(filepath.Join(blogDir, "index.xml"), []byte("RSS"), 0600); err != nil { + t.Fatalf("failed to write blog rss: %v", err) + } + if err := os.WriteFile(filepath.Join(postDir, "index.html"), []byte("
Hello World
"), 0600); err != nil { + t.Fatalf("failed to write blog post content: %v", err) + } + if err := os.WriteFile(filepath.Join(postDir, "hero.png"), []byte("PNG_DATA"), 0600); err != nil { + t.Fatalf("failed to write blog post image asset: %v", err) + } + if err := os.WriteFile(filepath.Join(goDir, "triage.html"), []byte(`{{ define "content" }}Triage{{ end }}`), 0600); err != nil { + t.Fatalf("failed to write triage.html: %v", err) + } + if err := os.WriteFile(filepath.Join(goDir, "linter.html"), []byte(`{{ define "content" }}Linter Report{{ end }}`), 0600); err != nil { + t.Fatalf("failed to write linter.html: %v", err) + } + srv := newTestServer(t, website.Config{StaticFS: os.DirFS(tmpDir)}) - t.Run("Root serves home.html", func(t *testing.T) { + t.Run("Triage_page", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/triage", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + }) + + t.Run("Linter_page", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/linter", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + }) + + t.Run("Blog_index", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/blog", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + }) + + t.Run("Blog_rss_serves_xml", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/blog/index.xml", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if contentType := rec.Header().Get("Content-Type"); contentType != "application/xml; charset=utf-8" { + t.Errorf("expected content-type 'application/xml; charset=utf-8', got %q", contentType) + } + }) + + t.Run("Blog_post", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/blog/posts/hello-world/", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + }) + + t.Run("Blog_post_asset_serves_file", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/blog/posts/hello-world/hero.png", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", rec.Code) + } + if rec.Body.String() != "PNG_DATA" { + t.Errorf("expected body 'PNG_DATA', got %q", rec.Body.String()) + } + }) + + t.Run("Root", func(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() @@ -222,8 +334,27 @@ func TestStaticFiles(t *testing.T) { if rec.Code != http.StatusOK { t.Errorf("expected status 200 OK, got %d", rec.Code) } - if rec.Body.String() != "Home" { - t.Errorf("expected body Home, got %q", rec.Body.String()) + }) + + t.Run("renderNotFound", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/404", nil) + rec := httptest.NewRecorder() + srv.RenderNotFound(rec, req, "GHSA-1234") + + if rec.Code != http.StatusNotFound { + t.Errorf("expected status 404 Not Found, got %d", rec.Code) + } + }) + + t.Run("UnmatchedRoute_returns_404", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/foo/bar", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected status 404 Not Found, got %d", rec.Code) } }) @@ -265,24 +396,10 @@ func TestEndpointRegistration(t *testing.T) { method string path string }{ - {http.MethodGet, "/blog"}, - {http.MethodGet, "/blog/"}, - {http.MethodGet, "/blog/index.xml"}, - {http.MethodGet, "/blog/posts/test-post"}, - {http.MethodGet, "/blog/posts/test-post/"}, - {http.MethodGet, "/blog/posts/test-post/image.png"}, {http.MethodGet, "/vulnerability/GHSA-1234"}, {http.MethodGet, "/GHSA-1234"}, {http.MethodGet, "/vulnerability/GHSA-1234.json"}, {http.MethodGet, "/GHSA-1234.json"}, - {http.MethodGet, "/list"}, - {http.MethodGet, "/api/search_suggestions"}, - {http.MethodGet, "/linter"}, - {http.MethodGet, "/linter/"}, - {http.MethodGet, "/linter-findings"}, - {http.MethodGet, "/linter-findings/"}, - {http.MethodGet, "/linter-findings/test-source"}, - {http.MethodGet, "/triage"}, {http.MethodPost, "/triage/proxy"}, {http.MethodGet, "/login"}, {http.MethodGet, "/auth/callback"}, @@ -296,8 +413,8 @@ func TestEndpointRegistration(t *testing.T) { rec := httptest.NewRecorder() srv.ServeHTTP(rec, req) - if rec.Code == http.StatusNotFound { - t.Errorf("expected route %s %s to be registered, got 404 Not Found", ep.method, ep.path) + if rec.Code != http.StatusNotImplemented { + t.Errorf("expected status 501 Not Implemented, got %d", rec.Code) } }) } diff --git a/go/internal/website/static.go b/go/internal/website/static.go index e4c75e9d7f8..241506bf8b9 100644 --- a/go/internal/website/static.go +++ b/go/internal/website/static.go @@ -1,11 +1,69 @@ package website import ( + "context" "fmt" "io/fs" + "math" "net/http" + "sort" + "strings" ) +func (s *Server) RenderNotFound(w http.ResponseWriter, r *http.Request, failedImportVulnID string) { + data := NotFoundPageData{ + BasePageData: BasePageData{ + ActiveSection: "", + DisableTurboCache: false, + }, + FailedImportVulnID: failedImportVulnID, + } + + s.render(w, r, "404.html", http.StatusNotFound, data) +} + +func computeEcosystemDisplays(counts map[string]int) []EcosystemDisplay { + var total int + for _, c := range counts { + total += c + } + if total == 0 { + return nil + } + + totalLog := math.Log(float64(total)) + displays := make([]EcosystemDisplay, 0, len(counts)) + for eco, count := range counts { + if count <= 30 { + continue + } + radius := math.Max((math.Log(float64(count))/totalLog)*100, 30) + tooltipTop := -((radius / 2) + 5) + displays = append(displays, EcosystemDisplay{ + Name: eco, + Count: count, + Radius: radius, + TooltipTop: tooltipTop, + }) + } + sort.Slice(displays, func(i, j int) bool { + return strings.ToLower(displays[i].Name) < strings.ToLower(displays[j].Name) + }) + + return displays +} + +func (s *Server) getEcosystemCounts(_ context.Context) map[string]int { + // Stub for ecosystem count fetch (Datastore or cache integration) + return map[string]int{ + "PyPI": 23174, + "npm": 222222, + "Go": 8010, + "GIT": 943411, + "Pub": 11, + } +} + func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("go-get") == "1" { w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -13,7 +71,16 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { return } - http.ServeFileFS(w, r, s.config.StaticFS, "home.html") + + data := HomePageData{ + BasePageData: BasePageData{ + ActiveSection: "home", + DisableTurboCache: false, + }, + Ecosystems: computeEcosystemDisplays(s.getEcosystemCounts(r.Context())), + } + + s.render(w, r, "home.html", http.StatusOK, data) } func (s *Server) handleGoBindingsVanity(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/website/triage.go b/go/internal/website/triage.go index ce8a0621e50..d32b2164a48 100644 --- a/go/internal/website/triage.go +++ b/go/internal/website/triage.go @@ -5,9 +5,15 @@ import ( ) // handleTriagePage handles serving the vulnerability triage UI page. -func (s *Server) handleTriagePage(w http.ResponseWriter, _ *http.Request) { - // TODO: Serve triage page / template - http.Error(w, "Triage UI page stub", http.StatusNotImplemented) +func (s *Server) handleTriagePage(w http.ResponseWriter, r *http.Request) { + data := TriagePageData{ + BasePageData: BasePageData{ + ActiveSection: "triage", + }, + Columns: []int{1, 2, 3}, + } + + s.render(w, r, "triage.html", http.StatusOK, data) } // handleTriageProxy handles proxying triage workflow actions.