diff --git a/.claude/skills/export-dependencies/SKILL.md b/.claude/skills/export-dependencies/SKILL.md new file mode 100644 index 0000000..39fc54f --- /dev/null +++ b/.claude/skills/export-dependencies/SKILL.md @@ -0,0 +1,92 @@ +--- +name: export-dependencies +description: Export unique dependencies with scorecard metrics and/or license info for an Endor Labs namespace +argument-hint: "[--namespace my-namespace] [--report-type licenses|scores|full]" +--- + +Help the user run the `export_dependencies/main.py` script. Follow these steps: + +## 1. Determine report type from user intent + +Map the user's natural language request to a `--report-type` value before asking any other questions: + +| If the user says… | Use | +|---|---| +| "fetch all licenses", "show license info", "what licenses am I using" | `--report-type licenses` | +| "fetch endor scores", "show dependency scores", "scorecard for my dependencies" | `--report-type scores` | +| "fetch all dependencies", "full report", "dependencies with licenses and scores", "export dependencies" (generic) | `--report-type full` (default) | + +If the intent is **vague or unclear**, ask before proceeding: + +> "What would you like included in the report? +> 1. **Licenses only** — dependency names and their license info +> 2. **Scores only** — dependency names and Endor scorecard scores +> 3. **Full report** — dependency names, scores, and licenses (default) + +## 2. Collect parameters + +If $ARGUMENTS contains the needed flags, parse them directly. Otherwise ask the user for: + +**Required:** +- `--namespace` (or `-n`) — the Endor Labs namespace (or `ENDOR_NAMESPACE` env var) + +**Auth (one of two options):** +- Option A — Bearer token: `--token` (or `ENDOR_TOKEN` env var) +- Option B — API credentials: `--api-key` + `--api-secret` (or `ENDOR_API_CREDENTIALS_KEY` + `ENDOR_API_CREDENTIALS_SECRET` env vars) + +**Optional:** +- `--workers N` — parallel workers for metric lookups (default: 20; increase for speed, decrease if rate-limited) +- `--debug` — print per-page progress and diagnostic lines during aggregation + +## 3. Check environment setup + +Before running, verify dependencies are installed: + +```bash +cd export_dependencies +pip install -r requirements.txt +``` + +## 4. Run the script + +Run from the `export_dependencies/` directory: + +```bash +# Licenses only +python main.py --namespace --token "$ENDOR_TOKEN" --report-type licenses + +# Scores only +python main.py --namespace --token "$ENDOR_TOKEN" --report-type scores + +# Full report (default — same as omitting --report-type) +python main.py --namespace --token "$ENDOR_TOKEN" + +# Using API credentials +python main.py --namespace \ + --api-key "$ENDOR_API_CREDENTIALS_KEY" \ + --api-secret "$ENDOR_API_CREDENTIALS_SECRET" \ + --report-type licenses +``` + +> **Note:** This script can take several minutes for large namespaces. Warn the user before starting. + +## 5. Report results + +After the script finishes: +- Confirm the output CSV path (printed as the last line by the script). +- State the number of unique dependencies written and which columns were included. +- If it fails, show the exact error and help diagnose: auth failure, namespace not found, rate limits (suggest lowering `--workers`), or network errors. + +**Columns by report type:** + +| Report type | Columns written | +|---|---| +| `full` (default) | name, package_version_uuid, count, overall_score, SCORE_CATEGORY_POPULARITY, SCORE_CATEGORY_CODE_QUALITY, SCORE_CATEGORY_SECURITY, SCORE_CATEGORY_ACTIVITY, licenses | +| `licenses` | name, count, licenses | +| `scores` | name, package_version_uuid, count, overall_score, SCORE_CATEGORY_POPULARITY, SCORE_CATEGORY_CODE_QUALITY, SCORE_CATEGORY_SECURITY, SCORE_CATEGORY_ACTIVITY | + +**Common issues:** +- Blank metric columns: metrics may not be available for those specific package versions — expected for some entries. +- Many "metrics query objects: 0": verify the dependency package version UUIDs correspond to OSS metric entries. +- Intermittent network errors: lower `--workers`; the script has built-in retries with exponential backoff. +- 401/403 mid-run: the script auto-refreshes the token and retries once when using API credentials. diff --git a/.claude/skills/generate-findings-report/SKILL.md b/.claude/skills/generate-findings-report/SKILL.md new file mode 100644 index 0000000..695bb2a --- /dev/null +++ b/.claude/skills/generate-findings-report/SKILL.md @@ -0,0 +1,63 @@ +--- +name: generate-findings-report +description: Run the findings report script for Endor Labs +argument-hint: "[--end-date YYYY-MM-DD --output file.csv]" +--- + +Help the user run the `generate_findings_report/generate_findings_report.py` script. Follow these steps: + +## 1. Collect parameters + +If $ARGUMENTS contains the needed flags, parse them directly. Otherwise ask the user for: + +**Required:** +- `--end-date` — report end date in `YYYY-MM-DD` format (findings created on or before this date are included) +- `--output` — output CSV file path (suggest a default like `report_.csv`) + +**Optional (ask if they want to filter or tune performance):** +- `--project-uuid` — restrict findings to a single project UUID +- `--batch-size` — UUIDs per batch API request for enrichment (default: 100) +- `--split-by-category` — write separate CSV files per finding category (e.g. `report_vulnerability.csv`, `report_sast.csv`, etc.) + +## 2. Check environment setup + +Before running, verify: + +1. A `.env` file exists at `generate_findings_report/.env` OR the required env vars are exported. The script needs either: + - `ENDOR_TOKEN` + `ENDOR_NAMESPACE`, or + - `API_KEY` + `API_SECRET` + `ENDOR_NAMESPACE` + + If missing, inform the user and show them the required `.env` format: + ``` + ENDOR_NAMESPACE= + ENDOR_TOKEN= # set this OR the pair below + API_KEY= # optional if ENDOR_TOKEN is set + API_SECRET= # optional if ENDOR_TOKEN is set + ``` + +2. A virtual environment exists at `generate_findings_report/.venv/`. If not, guide the user to create it: + ```bash + cd generate_findings_report + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +## 3. Run the script + +Activate the venv and run from the `generate_findings_report/` directory: + +```bash +cd generate_findings_report && source .venv/bin/activate && python generate_findings_report.py --end-date --output [--project-uuid ] [--batch-size ] [--split-by-category] +``` + +## 4. Report results + +After the script finishes: +- If successful, confirm the output CSV path(s) and the number of rows written. If `--split-by-category` was used, list each file that was created. +- If it fails, show the error and help diagnose: auth failure, invalid date format, API error, missing namespace, etc. + +**Output columns in the CSV:** +Finding UUID, Category, CVE ID, CWE ID, Description, Criticality, Remediation, Package/Application, Package Location, Code Owners, Location File, Introduced At, Tags, Ecosystem, Project UUID, Project Name, Reachability, Fixable, CVSS Score, EPSS Score, Namespace + +**Finding categories:** Container, SAST, Secrets, Malware, License, Operational, Vulnerability diff --git a/export_dependencies/README.md b/export_dependencies/README.md index bdf78f9..89984ce 100644 --- a/export_dependencies/README.md +++ b/export_dependencies/README.md @@ -30,7 +30,7 @@ Namespace is required: ### Usage ```bash -# Using a token +# Using a token (full report — default) python main.py --namespace my-namespace --token "$ENDOR_TOKEN" # Using API credentials @@ -45,6 +45,27 @@ python main.py -n my-namespace --token "$ENDOR_TOKEN" --debug python main.py -n my-namespace --token "$ENDOR_TOKEN" --workers 40 ``` +### Report types + +Use `--report-type` to control which columns are written to the CSV. The default (`full`) preserves existing behavior. + +| `--report-type` | Columns written | +|---|---| +| `full` *(default)* | name, package_version_uuid, count, overall_score, SCORE_CATEGORY_POPULARITY, SCORE_CATEGORY_CODE_QUALITY, SCORE_CATEGORY_SECURITY, SCORE_CATEGORY_ACTIVITY, licenses | +| `licenses` | name, count, licenses | +| `scores` | name, package_version_uuid, count, overall_score, SCORE_CATEGORY_POPULARITY, SCORE_CATEGORY_CODE_QUALITY, SCORE_CATEGORY_SECURITY, SCORE_CATEGORY_ACTIVITY | + +```bash +# Licenses only +python main.py --namespace my-namespace --token "$ENDOR_TOKEN" --report-type licenses + +# Scores only +python main.py --namespace my-namespace --token "$ENDOR_TOKEN" --report-type scores + +# Full report (explicit — same as omitting --report-type) +python main.py --namespace my-namespace --token "$ENDOR_TOKEN" --report-type full +``` + Progress is printed on a single updating line (overwritten in place). With `--debug`, additional diagnostic lines appear during unique-dependency aggregation pagination. For example: ``` Aggregating unique dependencies. This make take a few minutes ... @@ -109,4 +130,101 @@ pypi://urllib3@1.26.20,66d0988469c594feb187c89a,42,6.5,8,5,4,9,BSD-3-Clause:MIT: - Ensure the `ENDOR_NAMESPACE` is correct and that your token/credentials are valid. - If you encounter intermittent network errors (e.g., temporary DNS failures), try lowering `--workers` to reduce concurrent connections, or simply re-run; the built-in connection pooling and retries already handle many transient issues. +## Claude Code Skill + +This script ships with a Claude Code skill (`/export-dependencies`) that lets you run the export interactively without memorizing flags. + +### Prerequisites + +The skill is available when Claude Code is opened from the `scripts/` directory (where the `.claude/skills/` folder lives). No extra installation is needed. + +### How to use + +Invoke the skill in Claude Code: + +``` +/export-dependencies +``` + +Claude will: +1. Infer the report type from your natural language request, or ask if it's unclear. +2. Ask for any required parameters you haven't provided (`--namespace`, auth credentials). +3. Check that dependencies are installed before running. +4. Execute the script, warn you if the namespace is large (export can take several minutes), and confirm the output file created. + +The skill understands natural language — you don't need to remember flags: + +| Say… | Runs with | +|---|---| +| "fetch all licenses I use in my namespace" | `--report-type licenses` | +| "fetch Endor scores for all my dependencies" | `--report-type scores` | +| "fetch all dependencies" or "full report" | `--report-type full` (default) | +| "fetch all dependencies with licenses and scores" | `--report-type full` | + +You can also pass flags directly to skip the prompts: + +``` +/export-dependencies --namespace my-namespace --token $ENDOR_TOKEN +/export-dependencies --namespace my-namespace --token $ENDOR_TOKEN --report-type licenses +/export-dependencies --namespace my-namespace --token $ENDOR_TOKEN --report-type scores +/export-dependencies --namespace my-namespace --token $ENDOR_TOKEN --workers 40 +``` + +### Example sessions + +**Licenses only:** +``` +User: fetch all licenses that I use in my namespace using export dependencies + +Claude: I'll run an export with license information only. What's your namespace? + +User: acme-corp + +Claude: Checking environment… + ✓ requests and python-dotenv installed + ✓ ENDOR_TOKEN set + +Running: + python main.py --namespace acme-corp --token "$ENDOR_TOKEN" --report-type licenses + +Number of unique dependencies after de-duplication: 3 421 (removed 189 duplicates) +Fetching dependency metrics in parallel with 20 workers ... +completed 3421/3421 + +Script completed. +Output: generated_reports/unique_dependencies_acme-corp_20260424_091823.csv + 3 421 unique dependencies written (columns: name, count, licenses). +``` + +**Scores only:** +``` +User: fetch Endor scores for all dependencies in my namespace using export dependencies + +Claude: I'll run an export with scorecard scores only. What's your namespace? + +User: acme-corp + +Running: + python main.py --namespace acme-corp --token "$ENDOR_TOKEN" --report-type scores + +Script completed. +Output: generated_reports/unique_dependencies_acme-corp_20260424_092541.csv + 3 421 unique dependencies written (columns: name, package_version_uuid, count, overall_score, category scores). +``` + +**Full report (default):** +``` +User: fetch all dependencies in my namespace using export dependencies + +Running: + python main.py --namespace acme-corp --token "$ENDOR_TOKEN" + +Script completed. +Output: generated_reports/unique_dependencies_acme-corp_20260424_093012.csv + 3 421 unique dependencies written with scorecard scores and license info. +``` + +## No Warranty + +This software is provided on an "as is" basis, without warranty of any kind. You are solely responsible for determining whether this software is suitable for your use. diff --git a/export_dependencies/main.py b/export_dependencies/main.py index f7cffa3..3c6fc50 100644 --- a/export_dependencies/main.py +++ b/export_dependencies/main.py @@ -500,8 +500,37 @@ def extract_scorecard_from_container(container: Dict[str, Any]) -> None: parser.add_argument("--token", default=os.getenv("ENDOR_TOKEN"), help="Bearer token (or set ENDOR_TOKEN)") parser.add_argument("--debug", action="store_true", help="Enable debug logging") parser.add_argument("--workers", type=int, default=20, help="Number of parallel workers for API calls") + parser.add_argument( + "--report-type", + choices=["full", "licenses", "scores"], + default="full", + help=( + "Controls which columns are written to the CSV. " + "'full' (default): all columns — name, package_version_uuid, count, scores, licenses. " + "'licenses': name, count, licenses only. " + "'scores': name, package_version_uuid, count, overall_score, category scores only." + ), + ) args = parser.parse_args() + _COLUMNS = { + "full": [ + "name", "package_version_uuid", "count", + "overall_score", + "SCORE_CATEGORY_POPULARITY", "SCORE_CATEGORY_CODE_QUALITY", + "SCORE_CATEGORY_SECURITY", "SCORE_CATEGORY_ACTIVITY", + "licenses", + ], + "licenses": ["name", "count", "licenses"], + "scores": [ + "name", "package_version_uuid", "count", + "overall_score", + "SCORE_CATEGORY_POPULARITY", "SCORE_CATEGORY_CODE_QUALITY", + "SCORE_CATEGORY_SECURITY", "SCORE_CATEGORY_ACTIVITY", + ], + } + output_columns = _COLUMNS[args.report_type] + # Validate namespace if not args.namespace: print("Error: --namespace or ENDOR_NAMESPACE is required.") @@ -535,17 +564,7 @@ def extract_scorecard_from_container(container: Dict[str, Any]) -> None: # Write header once with open(output_path, "w", newline="", encoding="utf-8") as f_header: writer = csv.writer(f_header) - writer.writerow([ - "name", - "package_version_uuid", - "count", - "overall_score", - "SCORE_CATEGORY_POPULARITY", - "SCORE_CATEGORY_CODE_QUALITY", - "SCORE_CATEGORY_SECURITY", - "SCORE_CATEGORY_ACTIVITY", - "licenses" - ]) + writer.writerow(output_columns) # Lock for safe serialized writes write_lock = threading.Lock() @@ -579,21 +598,22 @@ def process_and_write(row: Dict[str, Any]) -> int: objects = metrics_resp.get("list", {}).get("objects", []) or [] metrics = extract_metrics_from_dependency_details(objects) cat_scores = metrics.get("category_scores", {}) or {} + all_values = { + "name": row["name"], + "package_version_uuid": row["package_version_uuid"], + "count": row["count"], + "overall_score": metrics.get("overall_score", ""), + "SCORE_CATEGORY_POPULARITY": cat_scores.get("SCORE_CATEGORY_POPULARITY", ""), + "SCORE_CATEGORY_CODE_QUALITY": cat_scores.get("SCORE_CATEGORY_CODE_QUALITY", ""), + "SCORE_CATEGORY_SECURITY": cat_scores.get("SCORE_CATEGORY_SECURITY", ""), + "SCORE_CATEGORY_ACTIVITY": cat_scores.get("SCORE_CATEGORY_ACTIVITY", ""), + "licenses": metrics.get("licenses", ""), + } with write_lock: # Append row safely with open(output_path, "a", newline="", encoding="utf-8") as f_out: writer = csv.writer(f_out) - writer.writerow([ - row["name"], - row["package_version_uuid"], - row["count"], - metrics.get("overall_score", ""), - cat_scores.get("SCORE_CATEGORY_POPULARITY", ""), - cat_scores.get("SCORE_CATEGORY_CODE_QUALITY", ""), - cat_scores.get("SCORE_CATEGORY_SECURITY", ""), - cat_scores.get("SCORE_CATEGORY_ACTIVITY", ""), - metrics.get("licenses", "") - ]) + writer.writerow([all_values[col] for col in output_columns]) return len(objects) except Exception as ex: if args.debug: diff --git a/generate_findings_report/.env.example b/generate_findings_report/.env.example new file mode 100644 index 0000000..0da19c5 --- /dev/null +++ b/generate_findings_report/.env.example @@ -0,0 +1,4 @@ +API_KEY= +API_SECRET= +ENDOR_NAMESPACE= +ENDOR_TOKEN= diff --git a/generate_findings_report/.python-version b/generate_findings_report/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/generate_findings_report/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/generate_findings_report/README.md b/generate_findings_report/README.md new file mode 100644 index 0000000..9ae7aa2 --- /dev/null +++ b/generate_findings_report/README.md @@ -0,0 +1,174 @@ +# Findings Report + +Generates a CSV report of security findings by fetching from the Endor Findings API, then enriching with PackageVersion (Package Location from `spec.relative_path`, Code Owners from `spec.code_owners.owners`) and Projects (Project Name from `meta.name`). CVE, CWE, CVSS, and EPSS data comes directly from the Findings API—no separate Vulnerabilities API call is needed. No input CSV is required—all data comes from the Findings, PackageVersion, and Projects APIs. + +## Finding categories + +The report covers all major finding categories: + +| Category | Derived when `spec.finding_categories` contains | +|---|---| +| Container | `FINDING_CATEGORY_CONTAINER` | +| SAST | `FINDING_CATEGORY_SAST` | +| Secrets | `FINDING_CATEGORY_SECRETS` | +| Malware | `FINDING_CATEGORY_MALWARE` | +| License | `FINDING_CATEGORY_LICENSE_RISK` | +| Operational | `FINDING_CATEGORY_OPERATIONAL` | +| Vulnerability | `FINDING_CATEGORY_VULNERABILITY` | + +A single finding can carry multiple categories (e.g. a Container finding also has `FINDING_CATEGORY_VULNERABILITY`). The "Category" column shows a single label based on priority (Container > SAST > Secrets > Malware > License > Operational > Vulnerability). + +## Data source + +Findings are fetched via `GET /namespaces/{namespace}/findings` with a filter on `meta.create_time <= date(end_date)` and optionally `spec.project_uuid`. The filter also includes `context.type==CONTEXT_TYPE_MAIN`. + +## Output CSV columns + +- **Finding UUID** – Top-level `uuid` of the finding. +- **Category** – Single label: Container, SAST, Secrets, Malware, License, Operational, or Vulnerability. +- **CVE ID** – From `spec.finding_metadata.vulnerability` aliases or `meta.description`; populated for Vulnerability and Container findings. +- **CWE ID** – From SAST `spec.finding_metadata.custom.cwes`, Vulnerability/Container `spec.finding_metadata.vulnerability.spec.database_specific.cwe_ids`, or Malware `spec.finding_metadata.malware.spec.cwe_id`. +- **Description** – From `meta.description`. +- **Criticality** – From `spec.level`, mapped to human-readable (Critical, High, Medium, Low, Info). +- **Remediation** – From `spec.remediation`. +- **Package/Application** – From `spec.target_dependency_package_name` when present; otherwise from `spec.finding_metadata.source_policy_info.finding_name` or `meta.description`. +- **Package Location** – From PackageVersion `spec.relative_path`; **Not Available** when not available or finding parent is not a PackageVersion. +- **Code Owners** – From PackageVersion `spec.code_owners.owners`; **Not Available** when not available. +- **Location File** – From `spec.dependency_file_paths` (comma-separated). Populated for SAST, Secrets, and SCA findings where available. +- **Introduced At** – From `meta.create_time`. +- **Tags** – From `spec.finding_tags` (comma-separated). +- **Ecosystem** – Mapped from the raw API enum to a human-readable name (e.g. `ECOSYSTEM_PYPI` → `Python`, `ECOSYSTEM_NPM` → `JavaScript`). Full mapping: + + | Raw Value | Display | + |---|---| + | `ECOSYSTEM_APK` | APK | + | `ECOSYSTEM_C` | C/C++ | + | `ECOSYSTEM_CARGO` | Rust | + | `ECOSYSTEM_COCOAPOD` | CocoaPods | + | `ECOSYSTEM_DEBIAN` | Debian | + | `ECOSYSTEM_GEM` | Ruby | + | `ECOSYSTEM_GO` | Go | + | `ECOSYSTEM_MAVEN` | Java | + | `ECOSYSTEM_NPM` | JavaScript | + | `ECOSYSTEM_NUGET` | .NET | + | `ECOSYSTEM_PACKAGIST` | PHP | + | `ECOSYSTEM_PYPI` | Python | + | `ECOSYSTEM_RPM` | RPM | + | `ECOSYSTEM_SWIFT` | Swift | + + Unknown values fall back to stripping the `ECOSYSTEM_` prefix and title-casing the remainder. + +- **Project UUID** – From `spec.project_uuid`. +- **Project Name** – From Projects API `meta.name`; **Not Available** when not found. +- **Reachability** – Derived from `spec.finding_tags`: `FINDING_TAGS_REACHABLE_FUNCTION` → **Reachable**, `FINDING_TAGS_UNREACHABLE_FUNCTION` → **Unreachable**, `FINDING_TAGS_POTENTIALLY_REACHABLE_FUNCTION` → **Potentially Reachable**. Only populated for Vulnerability and Container findings; blank for all others. +- **Fixable** – Derived from `spec.finding_tags`: `FINDING_TAGS_FIX_AVAILABLE` → **Yes**, `FINDING_TAGS_UNFIXABLE` → **No**. Blank when neither tag is present. +- **CVSS Score** – From `spec.finding_metadata.vulnerability.spec.cvss_v3_severity.score`. Populated for Vulnerability and Container findings. +- **EPSS Score** – From `spec.finding_metadata.vulnerability.spec.epss_score.probability_score`. Populated for Vulnerability and Container findings. +- **Namespace** – From `tenant_meta.namespace`. + +## Setup + +1. Create a `.env` file (or set environment variables): + + - **Option A – token directly:** set `ENDOR_TOKEN` and `ENDOR_NAMESPACE`. + - **Option B – API credentials:** set `API_KEY`, `API_SECRET`, and `ENDOR_NAMESPACE` (script will obtain a token via the auth API). + + ``` + ENDOR_NAMESPACE= + ENDOR_TOKEN= # optional if API_KEY and API_SECRET are set + API_KEY= # optional if ENDOR_TOKEN is set + API_SECRET= + ``` + +2. Install dependencies: + + ```bash + python3 -m venv venv + source venv/bin/activate # or venv\Scripts\activate on Windows + pip install -r requirements.txt + ``` + +## Usage + +```bash +python generate_findings_report.py --end-date 2026-01-31 --output report.csv +``` + +Optional: + +- `--project-uuid ` – Restrict to findings for a single project. +- `--batch-size N` – Number of UUIDs per batch API request for PackageVersions and Projects (default: 100). +- `--split-by-category` – Write separate CSV files per finding category instead of a single combined file. Files are named `{output_base}_{category}.csv` (e.g. `report_vulnerability.csv`, `report_sast.csv`, `report_secrets.csv`, `report_container.csv`, `report_license.csv`, `report_operational.csv`, `report_malware.csv`). Only categories with findings are written. + +```bash +python generate_findings_report.py --end-date 2026-01-31 --output report.csv --project-uuid {project-uuid} +python generate_findings_report.py --end-date 2026-01-31 --output report.csv --batch-size 200 +python generate_findings_report.py --end-date 2026-01-31 --output report.csv --split-by-category +``` + +`--end-date` is required and must be in `YYYY-MM-DD` format. + +## How it works + +1. Builds a filter for findings: `context.type==CONTEXT_TYPE_MAIN` and `meta.create_time <= date(end_date)`, optionally adding `spec.project_uuid==`. +2. Calls `GET /namespaces/{namespace}/findings` with that filter and a field mask; paginates through results. +3. Collects unique `meta.parent_uuid` values where `meta.parent_kind=="PackageVersion"`; calls `GET /namespaces/{namespace}/package-versions` in batches to get **Package Location** and **Code Owners** (shown as **Not Available** when missing). +4. Collects unique `spec.project_uuid` values; calls `GET /namespaces/{namespace}/projects` in batches to get **Project Name** (shown as **Not Available** when missing). +5. For each finding, derives the single **Category** label, extracts **CVE ID** and **CWE ID** from embedded metadata (no separate Vulnerabilities API call), extracts **CVSS Score** and **EPSS Score**, and populates **Reachability** only for Vulnerability/Container findings. +6. Writes the output CSV with all columns in the order listed above. When `--split-by-category` is used, writes separate CSV files per category (e.g. `report_vulnerability.csv`, `report_sast.csv`). + +## Claude Code Skill + +This script ships with a Claude Code skill (`/generate-findings-report`) that lets you run the report interactively without memorizing flags. + +### Prerequisites + +The skill is available when Claude Code is opened from the `scripts/` directory (where the `.claude/skills/` folder lives). No extra installation is needed. + +### How to use + +Invoke the skill in Claude Code: + +``` +/generate-findings-report +``` + +Claude will: +1. Ask for any required parameters you haven't provided (`--end-date`, `--output`). +2. Check that your `.env` file and virtual environment are set up before running. +3. Execute the script and confirm the output file(s) created. + +You can also pass flags directly in the invocation to skip the prompts: + +``` +/generate-findings-report --end-date 2026-01-31 --output report.csv +/generate-findings-report --end-date 2026-01-31 --output report.csv --split-by-category +``` + +### Example session + +``` +User: /generate-findings-report --end-date 2026-03-31 --output q1_findings.csv --split-by-category + +Claude: Checking environment… + ✓ .env found (ENDOR_NAMESPACE=) + ✓ .venv found + +Running: + python generate_findings_report.py \ + --end-date 2026-03-31 \ + --output q1_findings.csv \ + --split-by-category + +Script completed. Files written: + q1_findings_vulnerability.csv (1 234 rows) + q1_findings_sast.csv ( 87 rows) + q1_findings_secrets.csv ( 12 rows) + q1_findings_container.csv ( 340 rows) + q1_findings_license.csv ( 56 rows) + q1_findings_operational.csv ( 8 rows) +``` + +## No Warranty + +This software is provided on an "as is" basis, without warranty of any kind. You are solely responsible for determining whether this software is suitable for your use. diff --git a/generate_findings_report/generate_findings_report.py b/generate_findings_report/generate_findings_report.py new file mode 100644 index 0000000..3eba3db --- /dev/null +++ b/generate_findings_report/generate_findings_report.py @@ -0,0 +1,680 @@ +#!/usr/bin/env python3 +""" +Generate a findings report from the Findings API. + +Fetches findings (Vulnerabilities, Secrets, SAST, Container, License, +Operational, Malware) via the Findings list API, enriches with +PackageVersion (relative_path, code_owners) and Projects (project name), +and writes a CSV report. All CVE/CWE/CVSS/EPSS data comes directly from +the Findings API—no separate Vulnerabilities API call is needed. + +Usage: + python generate_findings_report.py --end-date 2026-01-31 --output report.csv + python generate_findings_report.py --end-date 2026-01-31 --output report.csv --project-uuid xxxxxxxxxxxxxxxx + python generate_findings_report.py --end-date 2026-01-31 --output report.csv --split-by-category +""" + +import csv +import os +import re +import sys +import argparse +from datetime import datetime +from typing import Dict, List, Any, Optional, Tuple + +import requests +from dotenv import load_dotenv + +load_dotenv() + +API_URL = "https://api.endorlabs.com/v1" +ENDOR_NAMESPACE = os.getenv("ENDOR_NAMESPACE") + +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d+") + +FINDINGS_MASK = ( + "uuid,meta.create_time,meta.description,meta.parent_uuid,meta.parent_kind," + "spec.project_uuid,spec.level,spec.remediation,spec.finding_metadata," + "spec.finding_tags,spec.finding_categories,spec.ecosystem," + "spec.target_dependency_package_name,spec.target_dependency_name," + "spec.target_dependency_version,spec.dependency_file_paths," + "tenant_meta.namespace" +) +PACKAGE_VERSIONS_MASK = "uuid,spec.relative_path,spec.code_owners.owners" +PROJECTS_MASK = "uuid,meta.name" + +OUTPUT_COLUMNS = [ + "Finding UUID", + "Category", + "CVE ID", + "CWE ID", + "Description", + "Criticality", + "Remediation", + "Package/Application", + "Package Location", + "Code Owners", + "Location File", + "Introduced At", + "Tags", + "Ecosystem", + "Project UUID", + "Project Name", + "Reachability", + "Fixable", + "CVSS Score", + "EPSS Score", + "Namespace", +] + +_ECOSYSTEM_DISPLAY_MAP = { + "ECOSYSTEM_APK": "APK", + "ECOSYSTEM_C": "C/C++", + "ECOSYSTEM_CARGO": "Rust", + "ECOSYSTEM_COCOAPOD": "CocoaPods", + "ECOSYSTEM_DEBIAN": "Debian", + "ECOSYSTEM_GEM": "Ruby", + "ECOSYSTEM_GO": "Go", + "ECOSYSTEM_MAVEN": "Java", + "ECOSYSTEM_NPM": "JavaScript", + "ECOSYSTEM_NUGET": ".NET", + "ECOSYSTEM_PACKAGIST": "PHP", + "ECOSYSTEM_PYPI": "Python", + "ECOSYSTEM_RPM": "RPM", + "ECOSYSTEM_SWIFT": "Swift", +} + +_LEVEL_DISPLAY_MAP = { + "FINDING_LEVEL_CRITICAL": "Critical", + "FINDING_LEVEL_HIGH": "High", + "FINDING_LEVEL_MEDIUM": "Medium", + "FINDING_LEVEL_LOW": "Low", + "FINDING_LEVEL_INFO": "Info", + "FINDING_LEVEL_UNSPECIFIED": "", +} + +_CATEGORY_PRIORITY = [ + ("FINDING_CATEGORY_CONTAINER", "Container"), + ("FINDING_CATEGORY_SAST", "SAST"), + ("FINDING_CATEGORY_SECRETS", "Secrets"), + ("FINDING_CATEGORY_MALWARE", "Malware"), + ("FINDING_CATEGORY_LICENSE_RISK", "License"), + ("FINDING_CATEGORY_OPERATIONAL", "Operational"), + ("FINDING_CATEGORY_VULNERABILITY", "Vulnerability"), +] + +_REACHABILITY_TAG_MAP = { + "FINDING_TAGS_REACHABLE_FUNCTION": "Reachable", + "FINDING_TAGS_UNREACHABLE_FUNCTION": "Unreachable", + "FINDING_TAGS_POTENTIALLY_REACHABLE_FUNCTION": "Potentially Reachable", +} + +_VULN_CONTAINER_CATEGORIES = {"Vulnerability", "Container"} + +ALL_CATEGORY_LABELS = [label for _, label in _CATEGORY_PRIORITY] + + +def friendly_ecosystem(raw: str) -> str: + """Map raw ecosystem enum to a human-readable name.""" + if not raw: + return "" + label = _ECOSYSTEM_DISPLAY_MAP.get(raw) + if label: + return label + return raw.replace("ECOSYSTEM_", "").replace("_", " ").title() + + +def friendly_level(raw: str) -> str: + """Map raw finding level enum to a human-readable name.""" + if not raw: + return "" + label = _LEVEL_DISPLAY_MAP.get(raw) + if label is not None: + return label + return raw.replace("FINDING_LEVEL_", "").replace("_", " ").title() + + +def get_token() -> str: + api_key = os.getenv("API_KEY") + api_secret = os.getenv("API_SECRET") + if not api_key or not api_secret: + raise ValueError("API_KEY and API_SECRET must be set in environment") + url = f"{API_URL}/auth/api-key" + payload = {"key": api_key, "secret": api_secret} + headers = {"Content-Type": "application/json", "Request-Timeout": "60"} + response = requests.post(url, json=payload, headers=headers, timeout=60) + if response.status_code != 200: + raise Exception(f"Failed to get token: {response.status_code}, {response.text}") + token = response.json().get("token") + if not token: + raise Exception("No token in API response") + return token + + +def parse_date_to_iso(date_str: str) -> str: + """Parse YYYY-MM-DD to end-of-day ISO 8601 string for API filter.""" + dt = datetime.strptime(date_str.strip(), "%Y-%m-%d") + dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999) + return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def build_findings_filter( + end_date: str, + project_uuid: Optional[str] = None, +) -> str: + """Build filter for findings up to end_date.""" + end_iso = parse_date_to_iso(end_date) + filters = [ + "context.type==CONTEXT_TYPE_MAIN", + f"meta.create_time <= date({end_iso})", + ] + if project_uuid: + filters.append(f'spec.project_uuid=="{project_uuid}"') + return " and ".join(filters) + + +def fetch_findings( + namespace: str, + filter_str: str, + headers: Dict[str, str], + page_size: int = 500, +) -> List[Dict[str, Any]]: + """Fetch all findings matching the filter; paginate through results.""" + url = f"{API_URL}/namespaces/{namespace}/findings" + objects: List[Dict[str, Any]] = [] + next_page_id = None + while True: + params = { + "list_parameters.filter": filter_str, + "list_parameters.mask": FINDINGS_MASK, + "list_parameters.page_size": str(page_size), + } + if next_page_id is not None: + params["list_parameters.page_id"] = next_page_id + response = requests.get(url, headers=headers, params=params, timeout=600) + if response.status_code != 200: + print( + f"Findings API error: {response.status_code} - {response.text}", + file=sys.stderr, + ) + raise RuntimeError(f"Findings API failed: {response.status_code}") + data = response.json() + batch = data.get("list", {}).get("objects", []) + objects.extend(batch) + next_page_id = data.get("list", {}).get("response", {}).get("next_page_id") + if not next_page_id: + break + return objects + + +def get_package_version_relative_paths_and_code_owners( + namespace: str, + package_version_uuids: List[str], + headers: Dict[str, str], + batch_size: int = 100, +) -> Tuple[Dict[str, str], Dict[str, str]]: + """ + Fetch package-versions by uuid; return (uuid -> spec.relative_path, + uuid -> code_owners string). + """ + relative_paths: Dict[str, str] = {} + code_owners_map: Dict[str, str] = {} + if not package_version_uuids: + return relative_paths, code_owners_map + url = f"{API_URL}/namespaces/{namespace}/package-versions" + for i in range(0, len(package_version_uuids), batch_size): + batch = package_version_uuids[i : i + batch_size] + uuid_list = "', '".join(batch) + filter_str = f"uuid in ['{uuid_list}']" + next_page_id = None + while True: + params = { + "list_parameters.filter": filter_str, + "list_parameters.mask": PACKAGE_VERSIONS_MASK, + "list_parameters.page_size": "500", + } + if next_page_id is not None: + params["list_parameters.page_id"] = next_page_id + response = requests.get(url, headers=headers, params=params, timeout=600) + if response.status_code != 200: + print( + f"Package-versions API error: {response.status_code} - {response.text}", + file=sys.stderr, + ) + raise RuntimeError( + f"Package-versions API failed: {response.status_code}" + ) + data = response.json() + objs = data.get("list", {}).get("objects", []) + for obj in objs: + uid = obj.get("uuid") + if not uid: + continue + spec = obj.get("spec") or {} + rel_path = spec.get("relative_path") or "" + relative_paths[uid] = ( + rel_path.strip() if isinstance(rel_path, str) else str(rel_path) + ) + code_owners_obj = spec.get("code_owners") or {} + code_owners = code_owners_obj.get("owners") + if code_owners is None: + code_owners_map[uid] = "" + elif isinstance(code_owners, list): + code_owners_map[uid] = ", ".join(str(x) for x in code_owners) + else: + code_owners_map[uid] = str(code_owners) + next_page_id = data.get("list", {}).get("response", {}).get("next_page_id") + if not next_page_id: + break + return relative_paths, code_owners_map + + +def get_project_names( + namespace: str, + project_uuids: List[str], + headers: Dict[str, str], + batch_size: int = 100, +) -> Dict[str, str]: + """Fetch projects by uuid; return uuid -> meta.name map.""" + names: Dict[str, str] = {} + if not project_uuids: + return names + url = f"{API_URL}/namespaces/{namespace}/projects" + for i in range(0, len(project_uuids), batch_size): + batch = project_uuids[i : i + batch_size] + uuid_list = "', '".join(batch) + filter_str = f"uuid in ['{uuid_list}']" + next_page_id = None + while True: + params = { + "list_parameters.filter": filter_str, + "list_parameters.mask": PROJECTS_MASK, + "list_parameters.page_size": "500", + } + if next_page_id is not None: + params["list_parameters.page_id"] = next_page_id + response = requests.get(url, headers=headers, params=params, timeout=600) + if response.status_code != 200: + print( + f"Projects API error: {response.status_code} - {response.text}", + file=sys.stderr, + ) + raise RuntimeError(f"Projects API failed: {response.status_code}") + data = response.json() + objs = data.get("list", {}).get("objects", []) + for obj in objs: + uid = obj.get("uuid") + if not uid: + continue + name = (obj.get("meta") or {}).get("name") or "" + names[uid] = name.strip() if isinstance(name, str) else str(name) + next_page_id = data.get("list", {}).get("response", {}).get("next_page_id") + if not next_page_id: + break + return names + + +def derive_category(finding_categories: Any) -> str: + """Map finding_categories list to a single clean label using priority.""" + if not finding_categories: + return "Other" + cats = finding_categories if isinstance(finding_categories, list) else [finding_categories] + cat_set = set(cats) + for raw_cat, label in _CATEGORY_PRIORITY: + if raw_cat in cat_set: + return label + return "Other" + + +def derive_fixable(finding_tags: Any) -> str: + """Return Yes/No fixability label from finding_tags.""" + if not finding_tags: + return "" + tags = finding_tags if isinstance(finding_tags, list) else [finding_tags] + if "FINDING_TAGS_FIX_AVAILABLE" in tags: + return "Yes" + if "FINDING_TAGS_UNFIXABLE" in tags: + return "No" + return "" + + +def derive_reachability(finding_tags: Any) -> str: + """Return a human-readable reachability label from finding_tags.""" + if not finding_tags: + return "" + tags = finding_tags if isinstance(finding_tags, list) else [finding_tags] + for tag in tags: + label = _REACHABILITY_TAG_MAP.get(tag) + if label: + return label + return "" + + +def format_list_field(val: Any) -> str: + """Format list/array field for CSV output.""" + if val is None: + return "" + if isinstance(val, list): + return ", ".join(str(x) for x in val) + return str(val) + + +def extract_cve_from_finding(obj: Dict[str, Any]) -> str: + """ + Extract CVE ID from finding metadata. Checks vulnerability aliases first, + then vulnerability meta.name, then falls back to regex on meta.description. + """ + spec = obj.get("spec") or {} + fm = spec.get("finding_metadata") or {} + + vuln = fm.get("vulnerability") + if vuln: + vuln_spec = vuln.get("spec") or {} + aliases = vuln_spec.get("aliases") or [] + for alias in aliases: + if isinstance(alias, str) and alias.startswith("CVE-"): + return alias + vuln_meta = vuln.get("meta") or {} + vuln_name = vuln_meta.get("name") or "" + if vuln_name.startswith("CVE-"): + return vuln_name + + desc = (obj.get("meta") or {}).get("description") or "" + match = CVE_PATTERN.search(desc) + if match: + return match.group(0) + + return "" + + +def extract_cwe_from_finding(obj: Dict[str, Any], category: str) -> str: + """ + Extract CWE IDs from finding based on category. + - SAST: spec.finding_metadata.custom.cwes + - Vulnerability/Container: spec.finding_metadata.vulnerability.spec.database_specific.cwe_ids + - Malware: spec.finding_metadata.malware.spec.cwe_id + """ + spec = obj.get("spec") or {} + fm = spec.get("finding_metadata") or {} + + if category == "SAST": + custom = fm.get("custom") or {} + cwes = custom.get("cwes") + if cwes: + return ", ".join(str(c) for c in cwes) if isinstance(cwes, list) else str(cwes) + + if category in ("Vulnerability", "Container"): + vuln = fm.get("vulnerability") or {} + vuln_spec = vuln.get("spec") or {} + db_specific = vuln_spec.get("database_specific") or {} + cwe_ids = db_specific.get("cwe_ids") + if cwe_ids: + return ", ".join(str(c) for c in cwe_ids) if isinstance(cwe_ids, list) else str(cwe_ids) + + if category == "Malware": + malware = fm.get("malware") or {} + malware_spec = malware.get("spec") or {} + cwe_id = malware_spec.get("cwe_id") + if cwe_id: + return str(cwe_id) + + return "" + + +def derive_package_application(obj: Dict[str, Any]) -> str: + """Derive Package/Application from finding fields.""" + spec = obj.get("spec") or {} + pkg_name = spec.get("target_dependency_package_name") + if pkg_name: + return str(pkg_name) + + fm = spec.get("finding_metadata") or {} + spi = fm.get("source_policy_info") or {} + finding_name = spi.get("finding_name") + if finding_name: + return str(finding_name) + + desc = (obj.get("meta") or {}).get("description") or "" + return desc + + +def write_csv_rows(path: str, rows: List[Dict[str, Any]]) -> None: + if not rows: + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=OUTPUT_COLUMNS) + writer.writeheader() + return + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter( + f, fieldnames=OUTPUT_COLUMNS, extrasaction="ignore" + ) + writer.writeheader() + writer.writerows(rows) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate findings report from Findings API." + ) + parser.add_argument( + "--end-date", + required=True, + help="Report end date (YYYY-MM-DD). Findings with meta.create_time <= end of this date.", + ) + parser.add_argument( + "--output", + required=True, + help="Output CSV report path.", + ) + parser.add_argument( + "--project-uuid", + default=None, + help="Optional project UUID to filter findings to a single project.", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Number of UUIDs per API batch request for PackageVersions and Projects (default: 100).", + ) + parser.add_argument( + "--split-by-category", + action="store_true", + default=False, + help="Write separate CSV files per finding category instead of a single combined file.", + ) + args = parser.parse_args() + + for label, val in [("--end-date", args.end_date)]: + try: + datetime.strptime(val.strip(), "%Y-%m-%d") + except ValueError: + print(f"{label} must be YYYY-MM-DD format, got: {val}", file=sys.stderr) + sys.exit(1) + + if not ENDOR_NAMESPACE: + print("ENDOR_NAMESPACE must be set in environment", file=sys.stderr) + sys.exit(1) + + token = os.getenv("ENDOR_TOKEN") + if not token: + try: + token = get_token() + except Exception as e: + print( + "Set ENDOR_TOKEN or both API_KEY and API_SECRET in environment.", + file=sys.stderr, + ) + print(f"Auth failed: {e}", file=sys.stderr) + sys.exit(1) + + headers = { + "User-Agent": "curl/7.68.0", + "Accept": "*/*", + "Authorization": f"Bearer {token}", + "Request-Timeout": "600", + } + + filter_str = build_findings_filter(args.end_date, args.project_uuid) + print(f"Fetching findings (meta.create_time <= {args.end_date})") + if args.project_uuid: + print(f" Filtering by project: {args.project_uuid}") + try: + findings = fetch_findings(ENDOR_NAMESPACE, filter_str, headers) + except Exception as e: + print(f"API error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Fetched {len(findings)} findings") + + if not findings: + write_csv_rows(args.output, []) + print(f"Wrote 0 rows to {args.output}") + return + + # --- Enrichment: PackageVersions --- + pv_uuids = list( + { + (obj.get("meta") or {}).get("parent_uuid") + for obj in findings + if (obj.get("meta") or {}).get("parent_kind") == "PackageVersion" + and (obj.get("meta") or {}).get("parent_uuid") + } + ) + if pv_uuids: + print(f"Fetching relative_path and code_owners for {len(pv_uuids)} package_version UUIDs") + try: + pv_to_path, pv_to_owners = get_package_version_relative_paths_and_code_owners( + ENDOR_NAMESPACE, pv_uuids, headers, batch_size=args.batch_size + ) + except Exception as e: + print(f"Package-versions API error: {e}", file=sys.stderr) + sys.exit(1) + else: + pv_to_path = {} + pv_to_owners = {} + + # --- Enrichment: Projects --- + project_uuids = list( + { + (obj.get("spec") or {}).get("project_uuid") + for obj in findings + if (obj.get("spec") or {}).get("project_uuid") + } + ) + if project_uuids: + print(f"Fetching project names for {len(project_uuids)} project UUIDs") + try: + project_names = get_project_names( + ENDOR_NAMESPACE, project_uuids, headers, batch_size=args.batch_size + ) + except Exception as e: + print(f"Projects API error: {e}", file=sys.stderr) + sys.exit(1) + else: + project_names = {} + + # --- Build output rows --- + out_rows: List[Dict[str, Any]] = [] + for obj in findings: + uid = obj.get("uuid") or "" + spec = obj.get("spec") or {} + meta = obj.get("meta") or {} + tenant_meta = obj.get("tenant_meta") or {} + + finding_categories = spec.get("finding_categories") + category = derive_category(finding_categories) + finding_tags = spec.get("finding_tags") + + parent_uuid = meta.get("parent_uuid") or "" + parent_kind = meta.get("parent_kind") or "" + + rel_path = "" + code_owners = "" + if parent_kind == "PackageVersion" and parent_uuid: + rel_path = pv_to_path.get(parent_uuid, "") + code_owners = pv_to_owners.get(parent_uuid, "") + + proj_uuid = spec.get("project_uuid") or "" + proj_name = project_names.get(proj_uuid, "") + + reachability = "" + if category in _VULN_CONTAINER_CATEGORIES: + reachability = derive_reachability(finding_tags) + + cve_id = "" + cvss_score = "" + epss_score = "" + if category in _VULN_CONTAINER_CATEGORIES: + cve_id = extract_cve_from_finding(obj) + fm = spec.get("finding_metadata") or {} + vuln = fm.get("vulnerability") or {} + vuln_spec = vuln.get("spec") or {} + cvss_sev = vuln_spec.get("cvss_v3_severity") or {} + score = cvss_sev.get("score") + if score is not None: + cvss_score = str(score) + epss = vuln_spec.get("epss_score") or {} + prob = epss.get("probability_score") + if prob is not None: + epss_score = str(prob) + + cwe_id = extract_cwe_from_finding(obj, category) + + dep_file_paths = spec.get("dependency_file_paths") + location_file = format_list_field(dep_file_paths) if dep_file_paths else "" + + desc = meta.get("description") or "" + + out_rows.append({ + "Finding UUID": uid, + "Category": category, + "CVE ID": cve_id, + "CWE ID": cwe_id, + "Description": desc, + "Criticality": friendly_level(spec.get("level", "")), + "Remediation": spec.get("remediation", ""), + "Package/Application": derive_package_application(obj), + "Package Location": rel_path if rel_path else "Not Available", + "Code Owners": code_owners if code_owners else "Not Available", + "Location File": location_file, + "Introduced At": meta.get("create_time", ""), + "Tags": format_list_field(finding_tags), + "Ecosystem": friendly_ecosystem(spec.get("ecosystem", "")), + "Project UUID": proj_uuid, + "Project Name": proj_name if proj_name else "Not Available", + "Reachability": reachability, + "Fixable": derive_fixable(finding_tags), + "CVSS Score": cvss_score, + "EPSS Score": epss_score, + "Namespace": tenant_meta.get("namespace", ""), + }) + + if args.split_by_category: + base, ext = os.path.splitext(args.output) + if not ext: + ext = ".csv" + rows_by_cat: Dict[str, List[Dict[str, Any]]] = { + label: [] for label in ALL_CATEGORY_LABELS + } + rows_by_cat["Other"] = [] + for row in out_rows: + rows_by_cat.setdefault(row["Category"], []).append(row) + + total_files = 0 + for cat_label, cat_rows in rows_by_cat.items(): + if not cat_rows: + continue + cat_slug = cat_label.lower().replace(" ", "_") + cat_path = f"{base}_{cat_slug}{ext}" + write_csv_rows(cat_path, cat_rows) + print(f" {cat_label}: {len(cat_rows)} rows -> {cat_path}") + total_files += 1 + print(f"Wrote {len(out_rows)} total rows across {total_files} files") + else: + write_csv_rows(args.output, out_rows) + print(f"Wrote {len(out_rows)} rows to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/generate_findings_report/requirements.txt b/generate_findings_report/requirements.txt new file mode 100644 index 0000000..50629b3 --- /dev/null +++ b/generate_findings_report/requirements.txt @@ -0,0 +1,2 @@ +requests==2.31.0 +python-dotenv==1.0.0