{
  "name": "Fail CI on high-risk dependency vulnerabilities with OSV.dev and Apify",
  "nodes": [
    {
      "parameters": {
        "content": "## Fail CI on high-risk dependency vulnerabilities\n\nFor teams that want a cheap vulnerability gate for npm, PyPI, Go and other ecosystems without running a scanner in CI.\n\n### How it works\n1. Your CI job POSTs package-lock.json, a pinned requirements.txt, go.mod or a package list to the webhook.\n2. Every exact version is checked against the free OSV.dev database in one Apify run.\n3. The webhook answers with fail true or false, counts per severity and the top 15 findings with their fixed versions. An incomplete check also returns fail: true.\n4. Your CI step fails the build when fail is true.\n\n### Setup\n1. Create a Header Auth credential for the webhook (for example X-Api-Key) and send the same header from CI.\n2. In n8n create a **Bearer Auth** credential with your Apify API token (Apify Console > Settings > API & Integrations) and select it in the Apify HTTP Request node. Never paste the token into the node itself.\n3. Activate and POST {\"manifestText\": \"<file contents>\", \"minSeverity\": \"HIGH\"}.\n4. Fail the job when the response has fail: true.\n\n### Cost\nApify (billed to your own Apify account): $0.0005 per package checked plus $0.0015 per reported vulnerability, plus a $0.00005 start fee per run. Error rows are never charged.\n\n### Customization\n- Send minSeverity MEDIUM for a stricter gate, or CRITICAL for a looser one.\n- Post the summary to Slack or a pull request comment.",
        "height": 774,
        "width": 560
      },
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -680,
        240
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "osv-dependency-check",
        "authentication": "headerAuth",
        "responseMode": "responseNode",
        "options": {}
      },
      "name": "CI webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.2,
      "position": [
        0,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "// The Webhook node puts the request JSON under \"body\" (headers, params and query sit next to it).\nconst body = $input.first().json.body || {};\nconst input = {\n  minSeverity: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].includes(String(body.minSeverity).toUpperCase()) ? String(body.minSeverity).toUpperCase() : 'HIGH',\n  includeDevDependencies: body.includeDevDependencies !== false,\n  dedupeAliases: true,\n};\nif (Array.isArray(body.packages) && body.packages.length) input.packages = body.packages;\nelse if (typeof body.manifestText === 'string' && body.manifestText.trim()) input.manifestText = body.manifestText;\nreturn [{ json: { valid: !!(input.packages || input.manifestText), maxCostPerRunUsd: 2, apifyInput: input } }];"
      },
      "name": "Normalize manifest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        400
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "2539d96c-e072-59b4-aadc-7771e505ce9e",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "name": "Has a manifest?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        520,
        400
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { fail: true, error: 'Send manifestText (package-lock.json, pinned requirements.txt, go.mod...) or a packages list in the JSON body' } }}",
        "options": {
          "responseCode": 400
        }
      },
      "name": "Respond 400 no manifest",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        780,
        640
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/fairquery~osv-dependency-vulnerability-check/run-sync-get-dataset-items",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBearerAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "format",
              "value": "json"
            },
            {
              "name": "timeout",
              "value": "280"
            },
            {
              "name": "maxTotalChargeUsd",
              "value": "={{ $json.maxCostPerRunUsd || 1 }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.apifyInput) }}",
        "options": {
          "timeout": 300000,
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        }
      },
      "name": "Run OSV check on Apify",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        780,
        400
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { fail: true, error: 'Vulnerability check failed. Check that the manifest is a supported file with exact versions.', detail: $json.error && $json.error.message ? $json.error.message : String($json.error || 'unknown') } }}",
        "options": {
          "responseCode": 502
        }
      },
      "name": "Respond 502 check failed",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1040,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "const res = $input.first().json;\nconst rows = Array.isArray(res.body) ? res.body : [];\nconst minSeverity = $('Normalize manifest').first().json.apifyInput.minSeverity;\nconst RANK = { NONE: 0, UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };\nconst vulns = rows.filter(r => r.type === 'vulnerability');\nconst pkgs = rows.filter(r => r.type === 'package');\nconst errors = rows.filter(r => r.type === 'error');\nconst blocking = vulns.filter(v => (RANK[v.severity] || 0) >= RANK[minSeverity]);\nconst count = s => vulns.filter(v => v.severity === s).length;\n// Fail closed: a check that covered no packages, or hit lookup errors, must not report a pass.\nconst incomplete = pkgs.length === 0 || errors.length > 0;\nreturn [{ json: {\n  fail: blocking.length > 0 || incomplete,\n  reason: blocking.length ? `${blocking.length} finding(s) at or above ${minSeverity}`\n    : incomplete ? 'Check incomplete: ' + (errors.map(e => e.code).join(', ') || 'no packages checked')\n    : `No findings at or above ${minSeverity}`,\n  min_severity: minSeverity,\n  packages_checked: pkgs.length,\n  packages_vulnerable: pkgs.filter(p => p.vulnerable).length,\n  vulnerabilities: vulns.length,\n  critical: count('CRITICAL'),\n  high: count('HIGH'),\n  medium: count('MEDIUM'),\n  low: count('LOW'),\n  unknown_severity: count('UNKNOWN'),\n  errors: errors.map(e => ({ code: e.code, message: e.message })),\n  top: blocking.concat(vulns.filter(v => !blocking.includes(v))).slice(0, 15)\n    .map(v => ({ id: v.id, severity: v.severity, pkg: v.package_name, fix: (v.fixed_versions || [])[0] || null })),\n} }];"
      },
      "name": "CI gate summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        400
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": 200
        }
      },
      "name": "Respond with the result",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1300,
        400
      ]
    },
    {
      "parameters": {
        "content": "## 1. Receive\nCI POSTs a lockfile or package list with the header credential.",
        "height": 560,
        "width": 1000,
        "color": 7
      },
      "name": "Section 1: 1. Receive",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        240
      ]
    },
    {
      "parameters": {
        "content": "## 2. Check on Apify\nEvery exact version is checked against OSV.dev.",
        "height": 560,
        "width": 480,
        "color": 7
      },
      "name": "Section 2: 2. Check on Apify",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        720,
        240
      ]
    },
    {
      "parameters": {
        "content": "## 3. Answer CI\nfail: true on any finding at or above minSeverity (default HIGH), or if the check was incomplete.",
        "height": 320,
        "width": 480,
        "color": 7
      },
      "name": "Section 3: 3. Answer CI",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        980,
        240
      ]
    }
  ],
  "connections": {
    "CI webhook": {
      "main": [
        [
          {
            "node": "Normalize manifest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize manifest": {
      "main": [
        [
          {
            "node": "Has a manifest?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has a manifest?": {
      "main": [
        [
          {
            "node": "Run OSV check on Apify",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 400 no manifest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run OSV check on Apify": {
      "main": [
        [
          {
            "node": "CI gate summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 502 check failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CI gate summary": {
      "main": [
        [
          {
            "node": "Respond with the result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
