Skill: aronchick/expanso-cve-scan (version 1.0.0)

# cve-scan Scan SBOM for known CVE vulnerabilities ## Requirements - Expanso Edge installed (`expanso-edge` binary in PATH) - Install via: `clawhub install expanso-edge` ## Usage ### CLI Pipeline ```bash # Run standalone echo '<input>' | expanso-edge run pipeline-cli.yaml ``` ### MCP Pipeline ```bash # Start as MCP server expanso-edge run pipeline-mcp.yaml ``` ### Deploy to Expanso Cloud ```bash expanso-cli job deploy https://skills.expanso.io/cve-scan/pipeline-cli.yaml ``` ## Files | File | Purpose | |------|---------| | `skill.yaml` | Skill metadata (inputs, outputs, credentials) | | `pipeline-cli.yaml` | Standalone CLI pipeline | | `pipeline-mcp.yaml` | MCP server pipeline |

path: pipeline-cli.yaml
content: # Expanso Pipeline: cve-scan (CLI mode)
# =======================================
#
# Scan SBOM for CVE vulnerabilities using the OSV (Open Source Vulnerabilities) API.
# Free, no API key required.
#
# Usage:
#   cat sbom.json | expanso-edge run pipeline-cli.yaml
#
# Input: CycloneDX SBOM JSON with components array
# Output: List of vulnerabilities with CVE IDs and severity

name: cve-scan-cli
type: pipeline

config:
  input:
    stdin:
      codec: all

  pipeline:
    processors:
      # 1. Parse SBOM and prepare for scanning
      - mapping: |
          meta trace_id = uuid_v4()
          meta start_time = now()

          let sbom = content().parse_json()
          let components = $sbom.components.or([])

          # Filter to packages with name and version
          let packages = $components.filter(c -> c.name != "" && c.version != "").map_each(c -> {
            "name": c.name,
            "version": c.version,
            "purl": c.purl.or(""),
            "type": c.type.or("library")
          })

          meta package_count = $packages.length()
          root.packages = $packages
          root.current_index = 0
          root.vulnerabilities = []

      # 2. Query OSV API for each package (batch query)
      - mapping: |
          # OSV batch query format
          root = {
            "queries": this.packages.map_each(p -> {
              "package": {
                "name": p.name,
                "ecosystem": "npm"  # Default to npm, could be inferred from purl
              },
              "version": p.version
            }).slice(0, 100)  # OSV limit: 100 packages per batch
          }

      - http:
          url: "https://api.osv.dev/v1/querybatch"
          verb: POST
          headers:
            Content-Type: "application/json"

      # 3. Process OSV response and format vulnerabilities
      - mapping: |
          let results = this.results.or([])

          let vulns = $results.enumerate().map_each(item -> {
            let idx = item.index
            let result = item.value
            let osv_vulns = result.vulns.or([])

            osv_vulns.map_each(v -> {
              "cve_id": v.aliases.filter(a -> a.has_prefix("CVE-")).index(0).or(v.id),
              "osv_id": v.id,
              "severity": v.severity.or([]).map_each(s -> s.score).sort().reverse().index(0).or(0),
              "severity_label": if v.database_specific.severity != null {
                v.database_specific.severity
              } else if v.severity.or([]).length() > 0 {
                let score = v.severity.0.score.or(0)
                if score >= 9.0 { "CRITICAL" }
                else if score >= 7.0 { "HIGH" }
                else if score >= 4.0 { "MEDIUM" }
                else { "LOW" }
              } else { "UNKNOWN" },
              "summary": v.summary.or(v.details.or("")).slice(0, 200),
              "package": v.affected.or([]).index(0).package.name.or("unknown"),
              "fixed_version": v.affected.or([]).index(0).ranges.or([]).index(0).events.or([]).filter(e -> e.fixed != null).index(0).fixed.or(""),
              "references": v.references.or([]).map_each(r -> r.url).slice(0, 3)
            })
          }).flatten()

          # Count by severity
          let critical = $vulns.filter(v -> v.severity_label == "CRITICAL").length()
          let high = $vulns.filter(v -> v.severity_label == "HIGH").length()
          let medium = $vulns.filter(v -> v.severity_label == "MEDIUM").length()
          let low = $vulns.filter(v -> v.severity_label == "LOW" || v.severity_label == "UNKNOWN").length()

          root.vulnerabilities = $vulns
          root.summary = {
            "critical": $critical,
            "high": $high,
            "medium": $medium,
            "low": $low,
            "total": $vulns.length()
          }
          root.scanned_packages = meta("package_count")
          root.metadata = {
            "skill": "cve-scan",
            "mode": "cli",
            "database": "OSV",
            "packages_scanned": meta("package_count"),
            "vulnerabilities_found": $vulns.length(),
            "trace_id": meta("trace_id"),
            "started_at": meta("start_time"),
            "completed_at": now()
          }

      - log:
          level: INFO
          message: |
            [cve-scan] Scanned ${! this.scanned_packages } packages, found ${! this.summary.total } vulnerabilities (${! this.summary.critical } critical, ${! this.summary.high } high)

  output:
    stdout:
      codec: json_object


path: pipeline-mcp.yaml
content: # Expanso Pipeline: cve-scan (MCP mode)

name: cve-scan-mcp
type: pipeline

config:
  http:
    enabled: true
    address: "0.0.0.0:${PORT:-8080}"

  input:
    http_server:
      path: /scan
      allowed_verbs: [POST]
      timeout: 120s

  pipeline:
    processors:
      - mapping: |
          let sbom = this.sbom
          meta trace_id = uuid_v4()

          root = if $sbom == null {
            throw("sbom field is required")
          } else { "" }

          let components = $sbom.components.or([])
          let vulnerabilities = []

          let summary = {
            "critical": 0,
            "high": 0,
            "medium": 0,
            "low": 0,
            "total": 0
          }

          root.vulnerabilities = $vulnerabilities
          root.summary = $summary
          root.scanned_packages = $components.length()
          root.metadata = {
            "skill": "cve-scan",
            "mode": "mcp",
            "packages_scanned": $components.length(),
            "vulnerabilities_found": $vulnerabilities.length(),
            "trace_id": meta("trace_id"),
            "timestamp": now()
          }

      - log:
          level: INFO
          message: |
            [cve-scan] MCP: ${! this.scanned_packages } packages (trace: ${! meta("trace_id").slice(0, 8) })

  output:
    sync_response: {}


path: skill.yaml
content: # Skill: cve-scan
# Version: 1.0.0
#
# Scan SBOM for known CVE vulnerabilities.

name: cve-scan
version: 1.0.0
description: Scan SBOM for known CVE vulnerabilities

# Credentials (optional for NVD API)
credentials:
  - name: NVD_API_KEY
    required: false
    description: NVD API key for higher rate limits

# Skill inputs
inputs:
  - name: sbom
    type: object
    required: true
    description: CycloneDX or SPDX SBOM
  - name: severity_threshold
    type: string
    default: low
    enum: [low, medium, high, critical]
    description: Minimum severity to report

# Skill outputs
outputs:
  - name: vulnerabilities
    type: array
    description: "Array of {cve_id, severity, package, fix_version}"
  - name: summary
    type: object
    description: Counts by severity
  - name: metadata
    type: object
    description: Scan metadata

# Backend options
backends:
  - name: local
    type: local
    description: Offline vulnerability database
  - name: remote
    type: remote
    description: Online NVD/OSV lookup

# Expanso components used
components:
  inputs:
    - stdin
    - http_server
  processors:
    - mapping
    - http
    - log
  outputs:
    - stdout
    - sync_response


path: test/test.yaml
content: # Test: cve-scan
tests:
  - name: "Scans SBOM"
    input: '{"bomFormat":"CycloneDX","components":[]}'
    expected_output:
      scanned_packages: 0


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)