diff --git a/gcp/website/frontend3/src/templates/triage.html b/gcp/website/frontend3/src/templates/triage.html new file mode 100644 index 00000000000..39e117cea3b --- /dev/null +++ b/gcp/website/frontend3/src/templates/triage.html @@ -0,0 +1,57 @@ +{% extends 'base.html' %} +{% set active_section = 'triage' %} + +{% block content %} +
+
+

CVE Conversion Triager

+ +
+ + Load +
+
+
+ {% for i in range(1, 4) %} +
+
+ +
+ +
+
+
+ +
Select a source to view content
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/gcp/website/frontend3/src/triage.js b/gcp/website/frontend3/src/triage.js new file mode 100644 index 00000000000..be1df96c1ce --- /dev/null +++ b/gcp/website/frontend3/src/triage.js @@ -0,0 +1,167 @@ +import "./triage.scss"; +import "@material/web/textfield/filled-text-field.js"; +import "@material/web/button/filled-button.js"; +import "@material/web/progress/circular-progress.js"; + +document.addEventListener("DOMContentLoaded", () => { + const vulnIdInput = document.getElementById("vuln-id-input"); + const loadBtn = document.getElementById("load-btn"); + const columns = document.querySelectorAll(".triage-column"); + + // Map selection values to their respective endpoints/paths + const sourceConfigMap = { + // External APIs + "cve-org": { + proxySource: "cve", + }, + "nvd-api": { + proxySource: "nvd", + }, + // Test Instance + "test-nvd": { + proxySource: "test-nvd", + }, + "test-cve5": { + proxySource: "test-cve5", + }, + "test-osv": { + proxySource: "test-osv", + }, + "test-nvd-metrics": { + proxySource: "test-nvd-metrics", + }, + "test-cve5-metrics": { + proxySource: "test-cve5-metrics", + }, + // Prod Instance + "prod-nvd": { + proxySource: "prod-nvd", + }, + "prod-cve5": { + proxySource: "prod-cve5", + }, + "prod-osv": { + proxySource: "prod-osv", + }, + "prod-nvd-metrics": { + proxySource: "prod-nvd-metrics", + }, + "prod-cve5-metrics": { + proxySource: "prod-cve5-metrics", + }, + // API + "api-test": { + urlTemplate: "https://api.test.osv.dev/v1/vulns/{id}", + }, + "api-prod": { + urlTemplate: "https://api.osv.dev/v1/vulns/{id}", + }, + }; + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function syntaxHighlight(json) { // credit to https://codepen.io/absolutedevelopment/pen/EpwVzN + if (typeof json !== 'string') { + json = JSON.stringify(json, undefined, 2); + } + + const escapedJson = escapeHtml(json); + + return escapedJson.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, + function (match) { + let cls = 'json-number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'json-key'; + } else { + cls = 'json-string'; + } + } else if (/true|false/.test(match)) { + cls = 'json-boolean'; + } else if (/null/.test(match)) { + cls = 'json-null'; + } + return `${match}`; + } + ); + } + + async function fetchData(sourceKey, vulnId) { + const config = sourceConfigMap[sourceKey]; + let url; + + const safeId = encodeURIComponent(vulnId); + + if (config.proxySource) { + url = `/triage/proxy?source=${encodeURIComponent(config.proxySource)}&id=${safeId}`; + } else if (config.urlTemplate) { + url = config.urlTemplate.replace("{id}", safeId); + } else { + throw new Error("Invalid configuration"); + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.status === 404 ? "Not Found" : `Error: ${response.statusText}`); + } + return response.json(); + } + + function updateColumn(column) { + const select = column.querySelector(".source-select"); + const contentPre = column.querySelector(".json-content"); + const spinner = column.querySelector(".loading-spinner"); + const sourceKey = select.value; + const vulnId = vulnIdInput.value.trim(); + + if (!sourceKey) { + contentPre.textContent = "Select a source to view content"; + return; + } + + if (!vulnId) { + contentPre.textContent = "Please enter a Vulnerability ID"; + return; + } + + spinner.classList.remove("hidden"); + contentPre.textContent = ""; + + fetchData(sourceKey, vulnId) + .then((data) => { + contentPre.innerHTML = syntaxHighlight(data); + }) + .catch((error) => { + contentPre.textContent = error.message; + }) + .finally(() => { + spinner.classList.add("hidden"); + }); + } + + loadBtn.addEventListener("click", () => { + columns.forEach((col) => updateColumn(col)); + }); + + // Also handle Enter key on the input field + vulnIdInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + columns.forEach((col) => updateColumn(col)); + } + }); + + // Individual column updates when dropdown changes + columns.forEach((col) => { + const select = col.querySelector(".source-select"); + select.addEventListener("change", () => { + if (vulnIdInput.value.trim()) { + updateColumn(col); + } + }); + }); +}); diff --git a/gcp/website/frontend3/src/triage.scss b/gcp/website/frontend3/src/triage.scss new file mode 100644 index 00000000000..3b0ab36ba5c --- /dev/null +++ b/gcp/website/frontend3/src/triage.scss @@ -0,0 +1,126 @@ +.triage-container { + // padding: 40px; + padding: 20px 40px; + width: 100%; + box-sizing: border-box; + + --md-sys-color-primary: #c5221f; + --md-sys-color-on-primary: #ffffff; + --md-filled-text-field-focus-active-indicator-color: #c5221f; + --md-filled-text-field-focus-label-text-color: #c5221f; + --md-filled-button-container-color: #c5221f; + + .header-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; + } + h1 { + margin: 0; + } + + .input-section { + display: flex; + align-items: center; + gap: 16px; + + + .standard-input { + flex: 1; + max-width: 400px; + } + } + } + +.triage-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.triage-column { + border: 1px solid #555; + border-radius: 8px; + display: flex; + flex-direction: column; + height: 75vh; + background-color: #333; +} + +.column-header { + padding: 16px; + background-color: #3c4043; + border-bottom: 1px solid #555; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + label { + font-weight: bold; + margin-bottom: 8px; + display: block; + } +} + +.select-wrapper { + margin-top: 5px; +} + +.source-select { + width: 100%; + padding: 8px; + background-color: #292929; + color: #fff; + border: 1px solid #555; + border-radius: 4px; +} + +.content-display { + flex-grow: 1; + padding: 16px; + overflow: auto; + position: relative; + background-color: #1e1e1e; +} + +.json-content { + white-space: pre-wrap; + word-wrap: break-word; + font-family: "Overpass Mono", monospace; + font-size: 13px; + overflow-wrap: anywhere; + margin: 0; + color: #d4d4d4; + + .json-key { + color: #9cdcfe; + } + + .json-string { + color: #ce9178; + } + + .json-number { + color: #b5cea8; + } + + .json-boolean { + color: #569cd6; + } + + .json-null { + color: #569cd6; + } +} + +.loading-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + --md-circular-progress-active-indicator-color: white; +} + +.hidden { + display: none; +} \ No newline at end of file diff --git a/gcp/website/frontend3/webpack.dev.js b/gcp/website/frontend3/webpack.dev.js index fb148bdf3ed..b1523cbf340 100644 --- a/gcp/website/frontend3/webpack.dev.js +++ b/gcp/website/frontend3/webpack.dev.js @@ -9,6 +9,7 @@ module.exports = { entry: { main: './src/index.js', linter: './src/linter.js', + triage: './src/triage.js', }, output: { path: path.resolve(__dirname, '../dist'), @@ -35,7 +36,7 @@ module.exports = { plugins: [ new CopyPlugin({ patterns: [ - { from: './src/templates', to: '.', globOptions: { ignore: ['**/base.html'] } }, + { from: './src/templates', to: '.', globOptions: { ignore: ['**/base.html', '**/triage.html'] } }, { from: './img/*', to: 'static/img/[name][ext]' }, ], }), @@ -51,6 +52,12 @@ module.exports = { chunks: ['linter'], excludeChunks: ['main'], }), + new HtmlWebpackPlugin({ + filename: 'triage.html', + template: './src/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 4cf6c4779ad..8c322a558c2 100644 --- a/gcp/website/frontend3/webpack.prod.js +++ b/gcp/website/frontend3/webpack.prod.js @@ -9,6 +9,7 @@ module.exports = { entry: { main: './src/index.js', linter: './src/linter.js', + triage: './src/triage.js', }, output: { path: path.resolve(__dirname, '../dist'), @@ -35,7 +36,7 @@ module.exports = { plugins: [ new CopyPlugin({ patterns: [ - { from: './src/templates/*.html', to: '[name].html' }, + { from: './src/templates/*.html', to: '[name].html', globOptions: { ignore: ['**/base.html', '**/triage.html'] } }, { from: './img/*', to: 'static/img/[name][ext]' }, ], }), @@ -51,6 +52,12 @@ module.exports = { chunks: ['linter'], excludeChunks: ['main'], }), + new HtmlWebpackPlugin({ + filename: 'triage.html', + template: './src/templates/triage.html', + chunks: ['triage'], + excludeChunks: ['main', 'linter'], + }), new MiniCssExtractPlugin({ filename: 'static/[name].[contenthash].css' }), diff --git a/gcp/website/main.py b/gcp/website/main.py index 3ae677aad5f..afea5997d73 100644 --- a/gcp/website/main.py +++ b/gcp/website/main.py @@ -22,6 +22,7 @@ import frontend_handlers import handlers import linter_api +import triage_handlers import osv.logs ndb_client = ndb.Client() @@ -46,6 +47,7 @@ def create_app(): flask_app.register_blueprint(handlers.blueprint) flask_app.register_blueprint(frontend_handlers.blueprint) flask_app.register_blueprint(linter_api.blueprint) + flask_app.register_blueprint(triage_handlers.blueprint) flask_app.config['TEMPLATES_AUTO_RELOAD'] = True flask_app.config['COMPRESS_MIMETYPES'] = ['text/html'] diff --git a/gcp/website/triage_handlers.py b/gcp/website/triage_handlers.py new file mode 100644 index 00000000000..ddde033f451 --- /dev/null +++ b/gcp/website/triage_handlers.py @@ -0,0 +1,141 @@ +"""Triage handlers.""" +import logging +import re +import requests +import utils + +from flask import Blueprint, request, jsonify, render_template +from google.cloud import storage + +blueprint = Blueprint('triage_handlers', __name__) + + +@blueprint.before_request +def restrict_to_local(): + """Restrict triage handlers to local environment.""" + if utils.is_cloud_run(): + return jsonify({'error': 'This tool is only available locally.'}), 403 + return None + + +_CVE_ID_REGEX = re.compile(r'^CVE-\d{4}-\d+$', re.IGNORECASE) +_STORAGE_CLIENT = None + + +def get_storage_client(): + """Get storage client.""" + global _STORAGE_CLIENT # pylint: disable=global-statement + if _STORAGE_CLIENT is None: + _STORAGE_CLIENT = storage.Client() + return _STORAGE_CLIENT + + +@blueprint.route('/triage') +def triage_index(): + """Triage index.""" + return render_template('triage.html') + + +GCS_SOURCE_CONFIG = { + 'test-nvd': { + 'bucket': 'osv-test-cve-osv-conversion', + 'path_template': 'nvd-osv/{id}.json' + }, + 'test-cve5': { + 'bucket': 'osv-test-cve-osv-conversion', + 'path_template': 'cve5/{id}.json' + }, + 'test-osv': { + 'bucket': 'osv-test-cve-osv-conversion', + 'path_template': 'osv-output/{id}.json' + }, + 'test-nvd-metrics': { + 'bucket': 'osv-test-cve-osv-conversion', + 'path_template': 'nvd-osv/{id}.metrics.json' + }, + 'test-cve5-metrics': { + 'bucket': 'osv-test-cve-osv-conversion', + 'path_template': 'cve5/{id}.metrics.json' + }, + 'prod-nvd': { + 'bucket': 'cve-osv-conversion', + 'path_template': 'nvd-osv/{id}.json' + }, + 'prod-cve5': { + 'bucket': 'cve-osv-conversion', + 'path_template': 'cve5/{id}.json' + }, + 'prod-osv': { + 'bucket': 'cve-osv-conversion', + 'path_template': 'osv-output/{id}.json' + }, + 'prod-nvd-metrics': { + 'bucket': 'cve-osv-conversion', + 'path_template': 'nvd-osv/{id}.metrics.json' + }, + 'prod-cve5-metrics': { + 'bucket': 'cve-osv-conversion', + 'path_template': 'cve5/{id}.metrics.json' + }, +} + + +@blueprint.route('/triage/proxy') +def triage_proxy(): + """Proxy to fetch files from GCS buckets or external APIs securely.""" + source = request.args.get('source') + vuln_id = request.args.get('id') + + if not source or not vuln_id: + return jsonify({'error': 'Missing source or id parameters'}), 400 + + # Validate CVE ID format + if not re.match(_CVE_ID_REGEX, vuln_id): + return jsonify({'error': 'Invalid ID format'}), 400 + + # Handle GCS sources + if source in GCS_SOURCE_CONFIG: + config = GCS_SOURCE_CONFIG[source] + bucket_name = config['bucket'] + path = config['path_template'].format(id=vuln_id.upper()) + + try: + bucket = get_storage_client().bucket(bucket_name) + blob = bucket.blob(path) + + if not blob.exists(): + return jsonify({'error': 'File not found'}), 404 + + content = blob.download_as_text() + return content, 200, {'Content-Type': 'application/json'} + + except Exception as e: # pylint: disable=broad-exception-caught + logging.error('Error fetching from GCS (%s): %s', source, e) + return jsonify({'error': 'Internal server error'}), 500 + + # Handle API sources + url = None + if source == 'cve': + # Construct GitHub raw URL for CVE data + match = re.match(r'^CVE-(\d{4})-(\d+)$', vuln_id, re.IGNORECASE) + if not match: + return jsonify({'error': 'Invalid ID format'}), 400 + year = match.group(1) + seq = match.group(2) + seq_prefix = seq[:-3] if len(seq) > 3 else '0' + url = (f'https://raw.githubusercontent.com/CVEProject/cvelistV5/' + f'refs/heads/main/cves/{year}/{seq_prefix}xxx/' + f'{vuln_id.upper()}.json') + elif source == 'nvd': + url = (f'https://services.nvd.nist.gov/rest/json/cves/2.0' + f'?cveId={vuln_id.upper()}') + else: + return jsonify({'error': 'Invalid source'}), 400 + + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + return response.text, 200, {'Content-Type': 'application/json'} + except Exception as e: + logging.error('Error fetching from external API (%s): %s', source, e) + return jsonify({'error': 'Error fetching from external API'}), 500