diff --git a/.github/workflows/vibeguard.yml b/.github/workflows/vibeguard.yml index b02bdfd..086eb63 100644 --- a/.github/workflows/vibeguard.yml +++ b/.github/workflows/vibeguard.yml @@ -43,14 +43,19 @@ jobs: head_sha="${{ github.event.pull_request.head.sha }}" results_dir="$(mktemp -d)" comment_file="$RUNNER_TEMP/vibeguard-comment.md" - vulnerabilities_file="$RUNNER_TEMP/vibeguard-vulnerabilities.md" - : > "$vulnerabilities_file" + code_vulnerabilities_file="$RUNNER_TEMP/vibeguard-code-vulnerabilities.md" + dependency_vulnerabilities_file="$RUNNER_TEMP/vibeguard-dependency-vulnerabilities.md" + dependency_notes_file="$RUNNER_TEMP/vibeguard-dependency-notes.md" + : > "$code_vulnerabilities_file" + : > "$dependency_vulnerabilities_file" + : > "$dependency_notes_file" detect_language() { case "${1##*.}" in py) echo "python" ;; js|jsx) echo "javascript" ;; ts|tsx) echo "typescript" ;; + go) echo "go" ;; *) echo "plaintext" ;; esac } @@ -62,6 +67,7 @@ jobs: MEDIUM) echo "![MEDIUM](https://img.shields.io/badge/MEDIUM-orange?style=flat-square)" ;; LOW) echo "![LOW](https://img.shields.io/badge/LOW-yellow?style=flat-square)" ;; SAFE) echo "![SAFE](https://img.shields.io/badge/SAFE-brightgreen?style=flat-square)" ;; + UNKNOWN) echo "![UNKNOWN](https://img.shields.io/badge/UNKNOWN-lightgrey?style=flat-square)" ;; *) echo "![UNKNOWN](https://img.shields.io/badge/UNKNOWN-lightgrey?style=flat-square)" ;; esac } @@ -70,76 +76,93 @@ jobs: local response_file="$1" local file="$2" local description="$3" - jq -n \ - --arg filename "$file" \ - --arg description "$description" \ - '{ - trust_score: 0, - risk_level: "CRITICAL", - vulnerabilities: [ - { - line_number: 0, - severity: "CRITICAL", - title: "Scan Failed", - description: $description, - fix: "Inspect the workflow logs and restore backend scan availability." - } - ], - summary: ("Security scan failed for " + $filename + ".") - }' > "$response_file" + jq -n --arg filename "$file" --arg description "$description" '{ + trust_score: 0, + risk_level: "CRITICAL", + vulnerabilities: [ + { + line_number: 0, + severity: "CRITICAL", + title: "Scan Failed", + description: $description, + fix: "Inspect the workflow logs and restore backend scan availability." + } + ], + summary: ("Security scan failed for " + $filename + ".") + }' > "$response_file" } - mapfile -t changed_files < <( - git diff --name-only "$base_sha" "$head_sha" -- '*.py' '*.js' '*.ts' '*.jsx' '*.tsx' - ) + write_dependency_failure_response() { + local response_file="$1" + local description="$2" + local status="${3:-skipped}" + jq -n --arg description "$description" --arg status "$status" '{ + total_packages: 0, + scanned_packages: 0, + vulnerable_packages: 0, + vulnerabilities: [], + dependency_score: 100, + risk_level: "UNKNOWN", + scan_status: $status, + warnings: [$description] + }' > "$response_file" + } + + mapfile -t changed_files < <(git diff --name-only "$base_sha" "$head_sha") + + changed_code_files=() + changed_dependency_files=() + for file in "${changed_files[@]}"; do + case "$file" in + *.py|*.js|*.ts|*.jsx|*.tsx) + changed_code_files+=("$file") + ;; + esac + + case "$(basename "$file")" in + requirements.txt|package.json|go.mod) + changed_dependency_files+=("$file") + ;; + esac + done { printf '%s\n' "$COMMENT_MARKER" - printf '## 🛡️ VibeGuard Security Scan Results\n\n' - printf '| File | Trust Score | Risk Level |\n' - printf '|------|-------------|------------|\n' + printf '## VibeGuard Security Scan Results\n\n' + printf '| Scan Type | File | Score | Risk Level |\n' + printf '|-----------|------|-------|------------|\n' } > "$comment_file" - if [ "${#changed_files[@]}" -eq 0 ]; then - printf '| No changed source files | N/A | SAFE |\n\n' >> "$comment_file" - printf '### Vulnerabilities Found\n\n' >> "$comment_file" - printf 'No vulnerabilities found because this PR does not modify supported source files.\n\n' >> "$comment_file" - printf -- '---\n*Powered by VibeGuard 🛡️*\n' >> "$comment_file" + if [ "${#changed_code_files[@]}" -eq 0 ] && [ "${#changed_dependency_files[@]}" -eq 0 ]; then + printf '| No supported changed files | N/A | N/A | SAFE |\n\n' >> "$comment_file" + printf '### Code Vulnerabilities\n\nNo supported source files changed in this PR.\n\n' >> "$comment_file" + printf '### Dependency Vulnerabilities\n\nNo supported dependency files changed in this PR.\n\n' >> "$comment_file" + printf -- '---\n*Powered by VibeGuard*\n' >> "$comment_file" echo "comment_file=$comment_file" >> "$GITHUB_OUTPUT" echo "has_low_score=false" >> "$GITHUB_OUTPUT" exit 0 fi has_low_score=false - any_vulnerabilities=false - scanned_file_count=0 + any_code_vulnerabilities=false + any_dependency_vulnerabilities=false + scanned_code_file_count=0 + scanned_dependency_file_count=0 - for file in "${changed_files[@]}"; do + for file in "${changed_code_files[@]}"; do if [ ! -f "$file" ]; then continue fi - scanned_file_count=$((scanned_file_count + 1)) + scanned_code_file_count=$((scanned_code_file_count + 1)) language="$(detect_language "$file")" response_hash="$(printf '%s' "$file" | sha256sum | cut -c1-12)" - response_file="$results_dir/$(basename "$file").$response_hash.json" - curl_error_file="$results_dir/$(basename "$file").$response_hash.curl.err" - payload="$(jq -n \ - --rawfile code "$file" \ - --arg language "$language" \ - --arg filename "$file" \ - '{code: $code, language: $language, filename: $filename}')" + response_file="$results_dir/$(basename "$file").$response_hash.code.json" + curl_error_file="$results_dir/$(basename "$file").$response_hash.code.curl.err" + payload="$(jq -n --rawfile code "$file" --arg language "$language" --arg filename "$file" '{code: $code, language: $language, filename: $filename}')" set +e - http_code="$(curl \ - --silent \ - --show-error \ - --output "$response_file" \ - --write-out '%{http_code}' \ - --request POST \ - --header 'Content-Type: application/json' \ - --data "$payload" \ - "$BACKEND_URL/scan" 2>"$curl_error_file")" + http_code="$(curl --silent --show-error --output "$response_file" --write-out '%{http_code}' --request POST --header 'Content-Type: application/json' --data "$payload" "$BACKEND_URL/scan" 2>"$curl_error_file")" curl_status=$? set -e @@ -154,8 +177,7 @@ jobs: trust_score="$(jq -r '.trust_score // 0' "$response_file")" risk_level="$(jq -r '.risk_level // "UNKNOWN"' "$response_file")" - - printf '| `%s` | %s/100 | %s |\n' "$file" "$trust_score" "$risk_level" >> "$comment_file" + printf '| Code | `%s` | %s/100 | %s |\n' "$file" "$trust_score" "$risk_level" >> "$comment_file" if [ "$trust_score" -lt 50 ]; then has_low_score=true @@ -163,41 +185,145 @@ jobs: vulnerability_count="$(jq '(.vulnerabilities // []) | length' "$response_file")" if [ "$vulnerability_count" -gt 0 ]; then - any_vulnerabilities=true - printf '#### `%s`\n\n' "$file" >> "$vulnerabilities_file" + any_code_vulnerabilities=true + printf '#### `%s`\n\n' "$file" >> "$code_vulnerabilities_file" while IFS=$'\t' read -r severity title line_number description fix; do badge="$(severity_badge "$severity")" - printf -- '- %s **%s** (Line %s)\n' "$badge" "$title" "$line_number" >> "$vulnerabilities_file" - printf ' Description: %s\n' "$description" >> "$vulnerabilities_file" - printf ' Fix suggestion: %s\n' "$fix" >> "$vulnerabilities_file" + printf -- '- %s **%s** (Line %s)\n' "$badge" "$title" "$line_number" >> "$code_vulnerabilities_file" + printf ' Description: %s\n' "$description" >> "$code_vulnerabilities_file" + printf ' Fix suggestion: %s\n' "$fix" >> "$code_vulnerabilities_file" done < <( jq -r ' (.vulnerabilities // [])[] | [ (.severity // "LOW"), - (.title // "Untitled vulnerability"), + ((.title // "Untitled vulnerability") | gsub("[\r\n\t]+"; " ")), ((.line_number // 0) | tostring), - (.description // "No description provided."), - (.fix // "No fix suggestion provided.") + ((.description // "No description provided.") | gsub("[\r\n\t]+"; " ")), + ((.fix // "No fix suggestion provided.") | gsub("[\r\n\t]+"; " ")) + ] | @tsv + ' "$response_file" + ) + printf '\n' >> "$code_vulnerabilities_file" + fi + done + + for file in "${changed_dependency_files[@]}"; do + if [ ! -f "$file" ]; then + continue + fi + + scanned_dependency_file_count=$((scanned_dependency_file_count + 1)) + response_hash="$(printf '%s' "$file" | sha256sum | cut -c1-12)" + response_file="$results_dir/$(basename "$file").$response_hash.dependencies.json" + curl_error_file="$results_dir/$(basename "$file").$response_hash.dependencies.curl.err" + payload="$(jq -n --rawfile file_content "$file" --arg filename "$file" '{file_content: $file_content, filename: $filename}')" + + set +e + http_code="$(curl --silent --show-error --output "$response_file" --write-out '%{http_code}' --request POST --header 'Content-Type: application/json' --data "$payload" "$BACKEND_URL/scan-dependencies" 2>"$curl_error_file")" + curl_status=$? + set -e + + if [ "$curl_status" -ne 0 ]; then + error_message="$(tr '\n' ' ' < "$curl_error_file" | sed 's/[[:space:]]\+/ /g')" + write_dependency_failure_response "$response_file" "The dependency scan API request did not complete successfully. ${error_message:-No error details available.}" "skipped" + elif [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + api_error="$(jq -r '.detail // empty' "$response_file" 2>/dev/null || true)" + description="The dependency scan API returned HTTP status ${http_code}." + if [ -n "$api_error" ]; then + description="$description $api_error" + fi + write_dependency_failure_response "$response_file" "$description" "skipped" + elif ! jq empty "$response_file" >/dev/null 2>&1; then + write_dependency_failure_response "$response_file" "The dependency scan API returned invalid JSON." "skipped" + fi + + dependency_score="$(jq -r '.dependency_score // 100' "$response_file")" + risk_level="$(jq -r '.risk_level // "UNKNOWN"' "$response_file")" + scan_status="$(jq -r '.scan_status // "unknown"' "$response_file")" + printf '| Dependencies | `%s` | %s/100 | %s |\n' "$file" "$dependency_score" "$risk_level" >> "$comment_file" + + if [ "$scan_status" != "skipped" ] && [ "$dependency_score" -lt 50 ]; then + has_low_score=true + fi + + vulnerability_count="$(jq '(.vulnerabilities // []) | length' "$response_file")" + if [ "$vulnerability_count" -gt 0 ]; then + any_dependency_vulnerabilities=true + printf '#### `%s`\n\n' "$file" >> "$dependency_vulnerabilities_file" + while IFS=$'\t' read -r package_name current_version vulnerability_id severity title description fixed_version; do + badge="$(severity_badge "$severity")" + printf -- '- %s **%s** `%s@%s`\n' "$badge" "$vulnerability_id" "$package_name" "$current_version" >> "$dependency_vulnerabilities_file" + printf ' Title: %s\n' "$title" >> "$dependency_vulnerabilities_file" + printf ' Description: %s\n' "$description" >> "$dependency_vulnerabilities_file" + if [ -n "$fixed_version" ]; then + printf ' Fix suggestion: Upgrade to %s\n' "$fixed_version" >> "$dependency_vulnerabilities_file" + else + printf ' Fix suggestion: No fixed version has been published yet.\n' >> "$dependency_vulnerabilities_file" + fi + done < <( + jq -r ' + (.vulnerabilities // [])[] | + [ + (.package_name // "unknown-package"), + (.current_version // "unknown-version"), + (.vulnerability_id // "UNKNOWN-ID"), + (.severity // "MEDIUM"), + ((.title // "Untitled vulnerability") | gsub("[\r\n\t]+"; " ")), + ((.description // "No description provided.") | gsub("[\r\n\t]+"; " ")), + (((.fixed_version // "") | tostring) | gsub("[\r\n\t]+"; " ")) ] | @tsv ' "$response_file" ) - printf '\n' >> "$vulnerabilities_file" + printf '\n' >> "$dependency_vulnerabilities_file" + fi + + warning_count="$(jq '(.warnings // []) | length' "$response_file")" + if [ "$warning_count" -gt 0 ]; then + printf '#### `%s`\n\n' "$file" >> "$dependency_notes_file" + while IFS= read -r note; do + printf -- '- %s\n' "$note" >> "$dependency_notes_file" + done < <(jq -r '(.warnings // [])[] | gsub("[\r\n\t]+"; " ")' "$response_file") + printf '\n' >> "$dependency_notes_file" fi done - if [ "$scanned_file_count" -eq 0 ]; then - printf '| No existing source files to scan | N/A | SAFE |\n' >> "$comment_file" + if [ "$scanned_code_file_count" -eq 0 ] && [ "${#changed_code_files[@]}" -gt 0 ]; then + printf '| Code | No existing source files to scan | N/A | SAFE |\n' >> "$comment_file" + fi + + if [ "$scanned_dependency_file_count" -eq 0 ] && [ "${#changed_dependency_files[@]}" -gt 0 ]; then + printf '| Dependencies | No existing dependency files to scan | N/A | SAFE |\n' >> "$comment_file" fi - printf '\n### Vulnerabilities Found\n\n' >> "$comment_file" - if [ "$any_vulnerabilities" = true ]; then - cat "$vulnerabilities_file" >> "$comment_file" + printf '\n### Code Vulnerabilities\n\n' >> "$comment_file" + if [ "$any_code_vulnerabilities" = true ]; then + cat "$code_vulnerabilities_file" >> "$comment_file" + elif [ "${#changed_code_files[@]}" -eq 0 ]; then + printf 'No supported source files changed in this PR.\n\n' >> "$comment_file" else - printf 'No vulnerabilities detected in the changed files.\n\n' >> "$comment_file" + printf 'No vulnerabilities detected in the changed source files.\n\n' >> "$comment_file" fi - printf -- '---\n*Powered by VibeGuard 🛡️*\n' >> "$comment_file" + printf '### Dependency Vulnerabilities\n\n' >> "$comment_file" + if [ "$any_dependency_vulnerabilities" = true ]; then + cat "$dependency_vulnerabilities_file" >> "$comment_file" + elif [ "${#changed_dependency_files[@]}" -eq 0 ]; then + printf 'No supported dependency files changed in this PR.\n\n' >> "$comment_file" + else + printf 'No known dependency vulnerabilities detected in the changed dependency files.\n\n' >> "$comment_file" + fi + + printf '### Dependency Scan Notes\n\n' >> "$comment_file" + if [ -s "$dependency_notes_file" ]; then + cat "$dependency_notes_file" >> "$comment_file" + elif [ "${#changed_dependency_files[@]}" -eq 0 ]; then + printf 'No dependency scan notes because no supported dependency files changed.\n\n' >> "$comment_file" + else + printf 'No dependency scan notes.\n\n' >> "$comment_file" + fi + + printf -- '---\n*Powered by VibeGuard*\n' >> "$comment_file" echo "comment_file=$comment_file" >> "$GITHUB_OUTPUT" echo "has_low_score=$has_low_score" >> "$GITHUB_OUTPUT" @@ -216,39 +342,15 @@ jobs: payload="$(jq -n --rawfile body "$COMMENT_FILE" '{body: $body}')" existing_comment_id="$( - curl \ - --fail \ - --silent \ - --show-error \ - --header "Authorization: Bearer $GITHUB_TOKEN" \ - --header 'Accept: application/vnd.github+json' \ - "$comments_api?per_page=100" | + curl --fail --silent --show-error --header "Authorization: Bearer $GITHUB_TOKEN" --header 'Accept: application/vnd.github+json' "$comments_api?per_page=100" | jq -r '.[] | select((.body // "") | contains("")) | .id' | tail -n 1 )" if [ -n "$existing_comment_id" ]; then - curl \ - --fail \ - --silent \ - --show-error \ - --request PATCH \ - --header "Authorization: Bearer $GITHUB_TOKEN" \ - --header 'Accept: application/vnd.github+json' \ - --header 'Content-Type: application/json' \ - --data "$payload" \ - "${{ github.api_url }}/repos/${REPOSITORY}/issues/comments/${existing_comment_id}" >/dev/null + curl --fail --silent --show-error --request PATCH --header "Authorization: Bearer $GITHUB_TOKEN" --header 'Accept: application/vnd.github+json' --header 'Content-Type: application/json' --data "$payload" "${{ github.api_url }}/repos/${REPOSITORY}/issues/comments/${existing_comment_id}" >/dev/null else - curl \ - --fail \ - --silent \ - --show-error \ - --request POST \ - --header "Authorization: Bearer $GITHUB_TOKEN" \ - --header 'Accept: application/vnd.github+json' \ - --header 'Content-Type: application/json' \ - --data "$payload" \ - "$comments_api" >/dev/null + curl --fail --silent --show-error --request POST --header "Authorization: Bearer $GITHUB_TOKEN" --header 'Accept: application/vnd.github+json' --header 'Content-Type: application/json' --data "$payload" "$comments_api" >/dev/null fi - name: Check scores @@ -258,4 +360,3 @@ jobs: echo "One or more scanned files scored below 50." >&2 exit 1 fi - diff --git a/backend/dependency_scanner.py b/backend/dependency_scanner.py new file mode 100644 index 0000000..f4909ce --- /dev/null +++ b/backend/dependency_scanner.py @@ -0,0 +1,511 @@ +import json +import re +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib import error, request + +OSV_QUERY_URL = "https://api.osv.dev/v1/query" +OSV_TIMEOUT_SECONDS = 10 +OSV_HEALTH_TIMEOUT_SECONDS = 5 +OSV_HEALTH_CACHE_TTL_SECONDS = 300 +OSV_HEALTH_SAMPLE = { + "package": {"name": "jinja2", "ecosystem": "PyPI"}, + "version": "3.1.4", +} +SUPPORTED_FILES = {"requirements.txt", "package.json", "go.mod"} +SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4} +SEVERITY_ALIASES = { + "MODERATE": "MEDIUM", + "IMPORTANT": "HIGH", + "UNKNOWN": "MEDIUM", + "INFO": "LOW", + "NONE": "LOW", +} + +_health_cache = { + "checked_at": 0.0, + "reachable": False, + "message": "OSV API has not been checked yet.", + "checked_at_iso": None, +} +_health_cache_lock = threading.Lock() + + +class OSVUnavailableError(RuntimeError): + pass + + +class OSVQueryError(RuntimeError): + pass + + +@dataclass(frozen=True) +class DependencySpec: + name: str + version: Optional[str] + ecosystem: str + + +def _utc_timestamp() -> str: + return datetime.utcnow().isoformat() + "Z" + + +def _normalize_severity(value: Optional[str]) -> Optional[str]: + if not value: + return None + + normalized = value.strip().upper() + normalized = SEVERITY_ALIASES.get(normalized, normalized) + if normalized in SEVERITY_ORDER: + return normalized + return None + + +def _severity_from_score(score: Any) -> Optional[str]: + if isinstance(score, (int, float)): + value = float(score) + elif isinstance(score, str): + match = re.match(r"^\s*(\d+(?:\.\d+)?)", score) + if not match: + return None + value = float(match.group(1)) + else: + return None + + if value >= 9.0: + return "CRITICAL" + if value >= 7.0: + return "HIGH" + if value >= 4.0: + return "MEDIUM" + return "LOW" + + +def _normalize_version(value: Optional[str]) -> Optional[str]: + if value is None: + return None + + cleaned = value.strip().strip("\"'") + if not cleaned: + return None + + cleaned = re.split(r"\s*\|\||\s+", cleaned, maxsplit=1)[0] + cleaned = re.sub(r"^[=<>!~^\s]+", "", cleaned) + cleaned = cleaned.rstrip(",") + if not cleaned or cleaned in {"*", "latest"}: + return None + if ":" in cleaned and not cleaned.startswith("v"): + return None + return cleaned + + +def _deduplicate_dependencies(dependencies: List[DependencySpec]) -> List[DependencySpec]: + seen = set() + unique_dependencies = [] + for dependency in dependencies: + key = (dependency.name.lower(), dependency.version or "", dependency.ecosystem) + if key in seen: + continue + seen.add(key) + unique_dependencies.append(dependency) + + return sorted(unique_dependencies, key=lambda item: (item.name.lower(), item.version or "")) + + +def _parse_requirements(content: str) -> List[DependencySpec]: + dependencies = [] + requirement_pattern = re.compile( + r"^([A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_,.-]+\])?)\s*(===|==|~=|>=|<=|!=|>|<)?\s*([^;\s]+)?" + ) + + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + + line = line.split("#", 1)[0].strip() + if not line: + continue + + match = requirement_pattern.match(line) + if not match: + continue + + package_name = match.group(1).split("[", 1)[0] + version = _normalize_version(match.group(3)) + dependencies.append(DependencySpec(package_name, version, "PyPI")) + + return dependencies + + +def _parse_package_json(content: str) -> List[DependencySpec]: + try: + manifest = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid package.json: {str(exc)}") from exc + + dependencies = [] + for section in ("dependencies", "devDependencies"): + section_data = manifest.get(section, {}) + if not isinstance(section_data, dict): + continue + + for package_name, raw_version in section_data.items(): + version = _normalize_version(raw_version if isinstance(raw_version, str) else None) + dependencies.append(DependencySpec(package_name, version, "npm")) + + return dependencies + + +def _parse_go_mod(content: str) -> List[DependencySpec]: + dependencies = [] + in_require_block = False + + for raw_line in content.splitlines(): + line = raw_line.strip() + if not line or line.startswith("//"): + continue + + line = line.split("//", 1)[0].strip() + if not line: + continue + + if line == "require (": + in_require_block = True + continue + + if in_require_block and line == ")": + in_require_block = False + continue + + if line.startswith(("module ", "go ", "toolchain ", "replace ", "exclude ", "retract ")): + continue + + dependency_line = line + if line.startswith("require "): + dependency_line = line[len("require "):].strip() + elif not in_require_block: + continue + + parts = dependency_line.split() + if len(parts) < 2: + continue + + dependencies.append(DependencySpec(parts[0], _normalize_version(parts[1]), "Go")) + + return dependencies + + +def _detect_file_type(filename: str) -> str: + basename = Path(filename).name.lower() + if basename not in SUPPORTED_FILES: + supported = ", ".join(sorted(SUPPORTED_FILES)) + raise ValueError(f'Unsupported dependency file "{filename}". Supported files: {supported}.') + return basename + + +def _post_osv_query(payload: Dict[str, Any], timeout: int = OSV_TIMEOUT_SECONDS) -> Dict[str, Any]: + body = json.dumps(payload).encode("utf-8") + http_request = request.Request( + OSV_QUERY_URL, + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + "User-Agent": "VibeGuard/1.0", + }, + ) + + try: + with request.urlopen(http_request, timeout=timeout) as response: + raw_response = response.read().decode("utf-8") + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace").strip() + message = details or f"HTTP {exc.code}" + raise OSVQueryError(message) from exc + except (error.URLError, TimeoutError, OSError) as exc: + raise OSVUnavailableError(str(exc)) from exc + + try: + return json.loads(raw_response) + except json.JSONDecodeError as exc: + raise OSVQueryError("OSV API returned invalid JSON") from exc + + +def _query_osv_vulnerabilities(dependency: DependencySpec) -> List[Dict[str, Any]]: + if not dependency.version: + return [] + + payload = { + "version": dependency.version, + "package": { + "name": dependency.name, + "ecosystem": dependency.ecosystem, + }, + } + + vulnerabilities: List[Dict[str, Any]] = [] + page_token: Optional[str] = None + + while True: + current_payload = dict(payload) + if page_token: + current_payload["page_token"] = page_token + + response_payload = _post_osv_query(current_payload) + vulnerabilities.extend(response_payload.get("vulns", [])) + page_token = response_payload.get("next_page_token") + if not page_token: + break + + return vulnerabilities + + +def _extract_fixed_version(vulnerability: Dict[str, Any], package_name: str) -> Optional[str]: + fixed_versions = [] + for affected in vulnerability.get("affected", []): + package = affected.get("package", {}) + if package.get("name") != package_name: + continue + + for affected_range in affected.get("ranges", []): + for event in affected_range.get("events", []): + fixed_version = event.get("fixed") + if fixed_version: + fixed_versions.append(fixed_version) + + if not fixed_versions: + return None + + unique_versions = [] + for version in fixed_versions: + if version not in unique_versions: + unique_versions.append(version) + return ", ".join(unique_versions) + + +def _pick_vulnerability_id(vulnerability: Dict[str, Any]) -> str: + aliases = vulnerability.get("aliases", []) + for prefix in ("CVE-", "GHSA-"): + for alias in aliases: + if isinstance(alias, str) and alias.startswith(prefix): + return alias + + vulnerability_id = vulnerability.get("id") + if isinstance(vulnerability_id, str) and vulnerability_id: + return vulnerability_id + + for alias in aliases: + if isinstance(alias, str) and alias: + return alias + + return "UNKNOWN" + + +def _extract_severity(vulnerability: Dict[str, Any], package_name: str) -> str: + for affected in vulnerability.get("affected", []): + package = affected.get("package", {}) + if package.get("name") != package_name: + continue + + severity = _normalize_severity((affected.get("ecosystem_specific") or {}).get("severity")) + if severity: + return severity + + severity = _normalize_severity((affected.get("database_specific") or {}).get("severity")) + if severity: + return severity + + severity = _normalize_severity((vulnerability.get("database_specific") or {}).get("severity")) + if severity: + return severity + + for entry in vulnerability.get("severity", []): + severity = _severity_from_score(entry.get("score")) + if severity: + return severity + + return "MEDIUM" + + +def _normalize_description(vulnerability: Dict[str, Any], title: str) -> str: + description = vulnerability.get("details") or vulnerability.get("summary") or title + return re.sub(r"\s+", " ", description).strip() + + +def _build_vulnerability_entry(dependency: DependencySpec, vulnerability: Dict[str, Any]) -> Dict[str, Any]: + vulnerability_id = _pick_vulnerability_id(vulnerability) + title = (vulnerability.get("summary") or vulnerability_id or "Untitled vulnerability").strip() + return { + "package_name": dependency.name, + "current_version": dependency.version or "unknown", + "vulnerability_id": vulnerability_id, + "severity": _extract_severity(vulnerability, dependency.name), + "title": title, + "description": _normalize_description(vulnerability, title), + "fixed_version": _extract_fixed_version(vulnerability, dependency.name), + } + + +def _calculate_risk_level(vulnerabilities: List[Dict[str, Any]], fully_scanned: bool) -> str: + if vulnerabilities: + return max(vulnerabilities, key=lambda item: SEVERITY_ORDER.get(item["severity"], 0))["severity"] + if fully_scanned: + return "SAFE" + return "UNKNOWN" + + +def _calculate_dependency_score(total_packages: int, vulnerable_packages: int) -> int: + if total_packages <= 0: + return 100 + safe_packages = max(total_packages - vulnerable_packages, 0) + return int(round((safe_packages / total_packages) * 100)) + + +def _build_result( + dependencies: List[DependencySpec], + vulnerabilities: List[Dict[str, Any]], + warnings: List[str], + scan_status: str, + scanned_packages: int, +) -> Dict[str, Any]: + vulnerable_packages = len({(item["package_name"], item["current_version"]) for item in vulnerabilities}) + dependency_score = _calculate_dependency_score(len(dependencies), vulnerable_packages) + fully_scanned = len(dependencies) == scanned_packages and scan_status == "ok" + + severity_rank = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} + vulnerabilities.sort(key=lambda item: (severity_rank.get(item["severity"], 4), item["package_name"].lower(), item["vulnerability_id"])) + + return { + "total_packages": len(dependencies), + "scanned_packages": scanned_packages, + "vulnerable_packages": vulnerable_packages, + "vulnerabilities": vulnerabilities, + "dependency_score": dependency_score, + "risk_level": _calculate_risk_level(vulnerabilities, fully_scanned), + "scan_status": scan_status, + "warnings": warnings, + } + + +def scan_dependencies(file_content: str, filename: str) -> Dict[str, Any]: + file_type = _detect_file_type(filename) + parser_map = { + "requirements.txt": _parse_requirements, + "package.json": _parse_package_json, + "go.mod": _parse_go_mod, + } + + dependencies = _deduplicate_dependencies(parser_map[file_type](file_content)) + if not dependencies: + return { + "total_packages": 0, + "scanned_packages": 0, + "vulnerable_packages": 0, + "vulnerabilities": [], + "dependency_score": 100, + "risk_level": "SAFE", + "scan_status": "ok", + "warnings": ["No dependencies were found in the submitted file."], + } + + warnings = [] + all_vulnerabilities: List[Dict[str, Any]] = [] + queryable_dependencies = [] + for dependency in dependencies: + if dependency.version: + queryable_dependencies.append(dependency) + else: + warnings.append( + f"Skipped {dependency.name}: no package version was provided, so OSV could not run a version-specific check." + ) + + if not queryable_dependencies: + return _build_result(dependencies, all_vulnerabilities, warnings, "partial", 0) + + successful_queries = 0 + unavailable_failures = 0 + + with ThreadPoolExecutor(max_workers=min(8, len(queryable_dependencies))) as executor: + future_to_dependency = { + executor.submit(_query_osv_vulnerabilities, dependency): dependency + for dependency in queryable_dependencies + } + for future in as_completed(future_to_dependency): + dependency = future_to_dependency[future] + try: + vulnerabilities = future.result() + except OSVUnavailableError as exc: + unavailable_failures += 1 + warnings.append( + f"OSV API was unreachable while scanning {dependency.name}@{dependency.version}: {str(exc)}" + ) + except OSVQueryError as exc: + warnings.append( + f"Could not query OSV for {dependency.name}@{dependency.version}: {str(exc)}" + ) + except Exception as exc: + warnings.append( + f"Unexpected error while scanning {dependency.name}@{dependency.version}: {str(exc)}" + ) + else: + successful_queries += 1 + for vulnerability in vulnerabilities: + all_vulnerabilities.append(_build_vulnerability_entry(dependency, vulnerability)) + + if successful_queries == 0 and unavailable_failures == len(queryable_dependencies): + warnings.insert(0, "OSV API is unreachable. Dependency vulnerability scanning was skipped.") + return _build_result(dependencies, all_vulnerabilities, warnings, "skipped", 0) + + if successful_queries == len(queryable_dependencies) and len(warnings) == 0: + scan_status = "ok" + else: + scan_status = "partial" + if unavailable_failures: + warnings.insert(0, "Some dependency checks could not be completed because the OSV API was intermittently unavailable.") + + return _build_result(dependencies, all_vulnerabilities, warnings, scan_status, successful_queries) + + +def check_osv_api(force_refresh: bool = False) -> Dict[str, Any]: + now = time.time() + with _health_cache_lock: + if not force_refresh and (now - _health_cache["checked_at"]) < OSV_HEALTH_CACHE_TTL_SECONDS: + return { + "reachable": _health_cache["reachable"], + "message": _health_cache["message"], + "checked_at": _health_cache["checked_at_iso"], + } + + try: + _post_osv_query(OSV_HEALTH_SAMPLE, timeout=OSV_HEALTH_TIMEOUT_SECONDS) + health_result = { + "reachable": True, + "message": "OSV API reachable.", + "checked_at": _utc_timestamp(), + } + except OSVUnavailableError as exc: + health_result = { + "reachable": False, + "message": f"OSV API unreachable: {str(exc)}", + "checked_at": _utc_timestamp(), + } + except OSVQueryError as exc: + health_result = { + "reachable": False, + "message": f"OSV API health check failed: {str(exc)}", + "checked_at": _utc_timestamp(), + } + + with _health_cache_lock: + _health_cache["checked_at"] = now + _health_cache["reachable"] = health_result["reachable"] + _health_cache["message"] = health_result["message"] + _health_cache["checked_at_iso"] = health_result["checked_at"] + + return health_result diff --git a/backend/main.py b/backend/main.py index adb4e99..5474459 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,17 @@ -from fastapi import FastAPI +import asyncio +import os + +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from dotenv import load_dotenv -import os -from scanner import scan_code + +try: + from dependency_scanner import check_osv_api, scan_dependencies as scan_dependency_file + from scanner import scan_code +except ImportError: + from .dependency_scanner import check_osv_api, scan_dependencies as scan_dependency_file + from .scanner import scan_code load_dotenv() @@ -21,21 +29,44 @@ allow_headers=["*"], ) + class ScanRequest(BaseModel): code: str language: str filename: str + +class DependencyScanRequest(BaseModel): + file_content: str + filename: str + + @app.get("/health") async def health(): - return {"status": "ok"} + osv_status = await asyncio.to_thread(check_osv_api) + return { + "status": "ok" if osv_status["reachable"] else "degraded", + "osv_api": osv_status, + } + @app.post("/scan") async def scan(request: ScanRequest): result = await scan_code(request.code, request.language, request.filename) return result + +@app.post("/scan-dependencies") +async def scan_dependencies(request: DependencyScanRequest): + try: + result = await asyncio.to_thread(scan_dependency_file, request.file_content, request.filename) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return result + + if __name__ == "__main__": import uvicorn + port = int(os.getenv("PORT", 8000)) uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/frontend/src/App.css b/frontend/src/App.css index db10114..712dda8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6,13 +6,15 @@ .app { min-height: 100svh; padding: 2rem; - background: #0a0a0a; + background: + radial-gradient(circle at top, rgba(0, 255, 136, 0.12), transparent 30%), + linear-gradient(180deg, #0b0f14 0%, #090b0f 100%); color: #fff; } .app-header { text-align: center; - margin-bottom: 3rem; + margin-bottom: 2rem; } .app-header h1 { @@ -23,7 +25,39 @@ .app-header p { font-size: 1.2rem; - color: #888; + color: #96a1b5; +} + +.tabs { + display: flex; + gap: 0.75rem; + max-width: 1400px; + margin: 0 auto 1.5rem; +} + +.tab { + flex: 1; + padding: 1rem 1.25rem; + border: 1px solid #23303f; + border-radius: 999px; + background: rgba(15, 20, 29, 0.9); + color: #96a1b5; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, color 0.2s ease, background 0.2s ease; +} + +.tab:hover { + transform: translateY(-1px); + border-color: #2d455b; + color: #d8e2f0; +} + +.tab.active { + border-color: #00ff88; + background: rgba(0, 255, 136, 0.14); + color: #00ff88; } .container { @@ -36,10 +70,11 @@ .left-panel, .right-panel { - background: #1a1a1a; - border-radius: 12px; + background: rgba(15, 20, 29, 0.9); + border-radius: 18px; padding: 2rem; - border: 1px solid #333; + border: 1px solid #1f2a38; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); } .controls { @@ -48,68 +83,85 @@ margin-bottom: 1rem; } +.controls-stack { + display: block; +} + .filename-input, .app select { - background: #0a0a0a; - border: 1px solid #333; - border-radius: 6px; + background: #090d13; + border: 1px solid #23303f; + border-radius: 10px; color: #fff; font-size: 1rem; } .filename-input { flex: 1; - padding: 0.75rem; + width: 100%; + padding: 0.85rem 1rem; } .app select { - padding: 0.75rem 1rem; + padding: 0.85rem 1rem; cursor: pointer; } +.form-hint { + margin: 0.75rem 0 0; + color: #96a1b5; + line-height: 1.5; +} + +.form-hint code { + color: #d8e2f0; +} + .code-editor { width: 100%; - height: 400px; + min-height: 420px; padding: 1rem; margin-bottom: 1rem; resize: vertical; - background: #0a0a0a; - border: 1px solid #333; - border-radius: 6px; + background: #090d13; + border: 1px solid #23303f; + border-radius: 10px; color: #fff; font-family: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; font-size: 0.95rem; + line-height: 1.6; } .scan-button { width: 100%; padding: 1rem; border: none; - border-radius: 6px; - background: #00ff88; - color: #0a0a0a; - font-size: 1.1rem; - font-weight: 700; + border-radius: 10px; + background: linear-gradient(135deg, #00ff88 0%, #00c27a 100%); + color: #07110b; + font-size: 1.05rem; + font-weight: 800; cursor: pointer; - transition: transform 0.2s ease, background 0.2s ease; + transition: transform 0.2s ease, filter 0.2s ease; } .scan-button:hover:not(:disabled) { - background: #00dd77; + filter: brightness(1.05); transform: translateY(-2px); } .scan-button:disabled { - opacity: 0.5; + opacity: 0.6; cursor: not-allowed; } .error { margin-top: 1rem; padding: 1rem; - background: #ff4444; - border-radius: 6px; - color: #fff; + background: rgba(255, 68, 68, 0.18); + border: 1px solid rgba(255, 68, 68, 0.35); + border-radius: 10px; + color: #ffd9d9; } .loading { @@ -121,7 +173,7 @@ width: 50px; height: 50px; margin: 0 auto 1rem; - border: 4px solid #333; + border: 4px solid #23303f; border-top-color: #00ff88; border-radius: 50%; animation: spin 1s linear infinite; @@ -137,45 +189,86 @@ margin-bottom: 1.5rem; padding: 2rem; border: 4px solid; - border-radius: 12px; + border-radius: 16px; text-align: center; } .score-circle { margin-bottom: 0.5rem; font-size: 4rem; - font-weight: 700; + font-weight: 800; } .score-label { - font-size: 1.2rem; - color: #888; + font-size: 1.1rem; + color: #96a1b5; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + padding: 1.25rem; + background: #090d13; + border: 1px solid #23303f; + border-radius: 14px; +} + +.stat-value { + font-size: 2rem; + font-weight: 800; + color: #f6fbff; +} + +.stat-label { + margin-top: 0.35rem; + color: #96a1b5; } .risk-badge { display: inline-block; margin-bottom: 1.5rem; padding: 0.5rem 1.5rem; - border-radius: 20px; - font-weight: 700; + border-radius: 999px; + color: #08130d; + font-weight: 800; } -.summary { +.summary, +.notes-panel { margin-bottom: 2rem; } .summary h3, -.vulnerabilities h3 { +.vulnerabilities h3, +.notes-panel h3 { margin-bottom: 0.75rem; color: #00ff88; } .summary p, -.vuln-description { - color: #ccc; +.vuln-description, +.notes-list { + color: #cbd5e1; line-height: 1.6; } +.notes-panel { + padding: 1rem 1.25rem; + background: #090d13; + border: 1px solid #23303f; + border-radius: 14px; +} + +.notes-list { + margin: 0; + padding-left: 1.25rem; +} + .vulnerabilities h3 { margin-bottom: 1rem; } @@ -183,64 +276,87 @@ .vulnerability { margin-bottom: 1rem; padding: 1rem; - background: #0a0a0a; - border: 1px solid #333; - border-radius: 8px; + background: #090d13; + border: 1px solid #23303f; + border-radius: 12px; } .vuln-header { display: flex; align-items: center; + flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } .severity-badge { - padding: 0.25rem 0.75rem; - border-radius: 12px; + padding: 0.3rem 0.75rem; + border-radius: 999px; + color: #08130d; font-size: 0.75rem; - font-weight: 700; + font-weight: 800; } .vuln-title { flex: 1; - font-weight: 700; + font-weight: 800; } -.line-number { - color: #888; +.line-number, +.package-version { + color: #96a1b5; font-size: 0.9rem; } +.vuln-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.meta-pill { + padding: 0.3rem 0.75rem; + border-radius: 999px; + background: rgba(0, 255, 136, 0.08); + color: #b4f7d3; + font-size: 0.82rem; +} + +.meta-pill-fix { + background: rgba(255, 215, 0, 0.14); + color: #ffe584; +} + .vuln-description { margin-bottom: 0.75rem; - line-height: 1.5; } .vuln-fix { - padding: 0.75rem; - background: #1a1a1a; + padding: 0.8rem 0.9rem; + background: rgba(255, 255, 255, 0.03); border-left: 3px solid #00ff88; - border-radius: 4px; - font-size: 0.9rem; + border-radius: 8px; + font-size: 0.95rem; } .vuln-fix strong { color: #00ff88; } -.no-vulnerabilities { - padding: 2rem; +.no-vulnerabilities, +.placeholder { + padding: 3rem 1rem; text-align: center; - font-size: 1.2rem; + font-size: 1.1rem; +} + +.no-vulnerabilities { color: #00ff88; } .placeholder { - padding: 3rem; - text-align: center; - color: #888; - font-size: 1.1rem; + color: #96a1b5; } @media (max-width: 1024px) { @@ -248,7 +364,30 @@ padding: 1rem; } + .tabs { + flex-direction: column; + } + .container { grid-template-columns: 1fr; } } + +@media (max-width: 640px) { + .controls { + flex-direction: column; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .app-header h1 { + font-size: 2.4rem; + } + + .left-panel, + .right-panel { + padding: 1.25rem; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index afb079c..bf10a11 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,24 +2,69 @@ import { useState } from 'react' import './App.css' const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' +const CODE_PLACEHOLDER = 'Paste your code here...' +const DEPENDENCY_PLACEHOLDER = `# requirements.txt +fastapi==0.115.0 +openai==1.52.0 + +# package.json +{ + "dependencies": { + "express": "^4.21.0" + } +}` + +const STATUS_COLORS = { + CRITICAL: '#ff4444', + HIGH: '#ff8c00', + MEDIUM: '#ffd700', + LOW: '#00ff88', + SAFE: '#00c27a', + UNKNOWN: '#6c7486', +} + +const getScoreColor = (score) => { + if (score >= 80) return '#00ff88' + if (score >= 60) return '#ffd700' + if (score >= 40) return '#ff8c00' + return '#ff4444' +} + +const getStatusColor = (status) => STATUS_COLORS[status] || STATUS_COLORS.UNKNOWN + +async function getErrorMessage(response, fallbackMessage) { + try { + const data = await response.json() + return data.detail || data.message || fallbackMessage + } catch { + return fallbackMessage + } +} function App() { + const [activeTab, setActiveTab] = useState('code') const [code, setCode] = useState('') const [language, setLanguage] = useState('python') const [filename, setFilename] = useState('code.py') - const [loading, setLoading] = useState(false) - const [result, setResult] = useState(null) - const [error, setError] = useState(null) + const [codeLoading, setCodeLoading] = useState(false) + const [codeResult, setCodeResult] = useState(null) + const [codeError, setCodeError] = useState(null) + + const [dependencyContent, setDependencyContent] = useState('') + const [dependencyFilename, setDependencyFilename] = useState('requirements.txt') + const [dependencyLoading, setDependencyLoading] = useState(false) + const [dependencyResult, setDependencyResult] = useState(null) + const [dependencyError, setDependencyError] = useState(null) - const handleScan = async () => { + const handleCodeScan = async () => { if (!code.trim()) { - setError('Please enter some code to scan') + setCodeError('Please enter some code to scan') return } - setLoading(true) - setError(null) - setResult(null) + setCodeLoading(true) + setCodeError(null) + setCodeResult(null) try { const response = await fetch(`${API_URL}/scan`, { @@ -28,32 +73,214 @@ function App() { body: JSON.stringify({ code, language, filename }), }) - if (!response.ok) throw new Error('Scan failed') + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Code scan failed')) + } const data = await response.json() - setResult(data) + setCodeResult(data) } catch (err) { - setError(err.message) + setCodeError(err.message) } finally { - setLoading(false) + setCodeLoading(false) } } - const getScoreColor = (score) => { - if (score >= 80) return '#00ff88' - if (score >= 60) return '#ffd700' - if (score >= 40) return '#ff8c00' - return '#ff4444' + const handleDependencyScan = async () => { + if (!dependencyContent.trim()) { + setDependencyError('Please paste a dependency file to scan') + return + } + + if (!dependencyFilename.trim()) { + setDependencyError('Please provide the dependency filename') + return + } + + setDependencyLoading(true) + setDependencyError(null) + setDependencyResult(null) + + try { + const response = await fetch(`${API_URL}/scan-dependencies`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_content: dependencyContent, filename: dependencyFilename }), + }) + + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Dependency scan failed')) + } + + const data = await response.json() + setDependencyResult(data) + } catch (err) { + setDependencyError(err.message) + } finally { + setDependencyLoading(false) + } + } + + const renderCodeResults = () => { + if (codeLoading) { + return ( +
+
+

Analyzing code security...

+
+ ) + } + + if (!codeResult) { + return ( +
+

Paste your code and click "Scan Code" to begin.

+
+ ) + } + + return ( + <> +
+
+ {codeResult.trust_score} +
+
Trust Score
+
+ +
+ {codeResult.risk_level} +
+ +
+

Summary

+

{codeResult.summary}

+
+ + {codeResult.vulnerabilities && codeResult.vulnerabilities.length > 0 ? ( +
+

Vulnerabilities ({codeResult.vulnerabilities.length})

+ {codeResult.vulnerabilities.map((vuln, idx) => ( +
+
+ + {vuln.severity} + + {vuln.title} + Line {vuln.line_number} +
+

{vuln.description}

+
+ Fix: {vuln.fix} +
+
+ ))} +
+ ) : ( +
No vulnerabilities detected.
+ )} + + ) } - const getSeverityColor = (severity) => { - const colors = { - CRITICAL: '#ff4444', - HIGH: '#ff8c00', - MEDIUM: '#ffd700', - LOW: '#00ff88', + const renderDependencyResults = () => { + if (dependencyLoading) { + return ( +
+
+

Checking dependencies against OSV.dev...

+
+ ) + } + + if (!dependencyResult) { + return ( +
+

Paste a dependency file and click "Scan Dependencies" to begin.

+
+ ) } - return colors[severity] || '#888' + + const notes = dependencyResult.warnings || [] + const scannedPackages = dependencyResult.scanned_packages ?? dependencyResult.total_packages + const noteTitle = dependencyResult.scan_status === 'skipped' + ? 'Dependency Scan Skipped' + : dependencyResult.scan_status === 'partial' + ? 'Dependency Scan Notes' + : 'Dependency Scan Summary' + + return ( + <> +
+
+
{scannedPackages}
+
Packages Scanned
+
+
+
{dependencyResult.vulnerable_packages}
+
Vulnerable Packages
+
+
+
+ {dependencyResult.dependency_score} +
+
Dependency Score
+
+
+ +
+ {dependencyResult.risk_level} +
+ + {notes.length > 0 && ( +
+

{noteTitle}

+ +
+ )} + + {dependencyResult.vulnerabilities && dependencyResult.vulnerabilities.length > 0 ? ( +
+

Known Vulnerabilities ({dependencyResult.vulnerabilities.length})

+ {dependencyResult.vulnerabilities.map((vuln, idx) => ( +
+
+ + {vuln.severity} + + {vuln.vulnerability_id} + {vuln.package_name}@{vuln.current_version} +
+
+ {vuln.title} + {vuln.fixed_version && Fix {vuln.fixed_version}} +
+

{vuln.description}

+
+ Fix: {vuln.fixed_version ? `Upgrade to version ${vuln.fixed_version}` : 'No fixed version published yet.'} +
+
+ ))} +
+ ) : ( +
+ {dependencyResult.risk_level === 'UNKNOWN' + ? 'No known dependency vulnerabilities were returned for the packages that could be checked.' + : 'No known dependency vulnerabilities detected.'} +
+ )} + + ) } return ( @@ -63,100 +290,90 @@ function App() {

Ship safe. Every time.

+
+ + +
+
-
- setFilename(e.target.value)} - className="filename-input" - /> - -
- -