diff --git a/crates/runx-cli/src/official_skills.rs b/crates/runx-cli/src/official_skills.rs index 8469e4b0..59d2990f 100644 --- a/crates/runx-cli/src/official_skills.rs +++ b/crates/runx-cli/src/official_skills.rs @@ -40,6 +40,11 @@ pub(crate) const OFFICIAL_SKILLS: &[OfficialSkillLockEntry] = &[ version: "sha-c2d071df7f50", digest: "08cefe802c15e5be7d32ae9a363a6c42168e86f7fab92890e5ce5c994af367c9", }, + OfficialSkillLockEntry { + skill_id: "runx/dependency-advisory-graph", + version: "sha-4bd3c2511486", + digest: "5e7cc5d157ef0383d9574812242dc9bb41321caa5be645abd5bfcb2c2b4a2bf1", + }, OfficialSkillLockEntry { skill_id: "runx/dependency-cve-audit", version: "sha-6db720882ba0", diff --git a/packages/cli/src/official-skills.lock.json b/packages/cli/src/official-skills.lock.json index 5ce7c23e..e6681804 100644 --- a/packages/cli/src/official-skills.lock.json +++ b/packages/cli/src/official-skills.lock.json @@ -41,6 +41,13 @@ "catalog_visibility": "public", "catalog_role": "context" }, + { + "skill_id": "runx/dependency-advisory-graph", + "version": "sha-4bd3c2511486", + "digest": "5e7cc5d157ef0383d9574812242dc9bb41321caa5be645abd5bfcb2c2b4a2bf1", + "catalog_visibility": "public", + "catalog_role": "canonical" + }, { "skill_id": "runx/dependency-cve-audit", "version": "sha-6db720882ba0", diff --git a/skills/dependency-advisory-graph/SKILL.md b/skills/dependency-advisory-graph/SKILL.md new file mode 100644 index 00000000..77f178b6 --- /dev/null +++ b/skills/dependency-advisory-graph/SKILL.md @@ -0,0 +1,208 @@ +--- +name: dependency-advisory-graph +description: Audit locked npm dependencies against OSV advisories and emit exact-version advisory graph evidence. +source: + type: cli-tool + command: node + args: + - run.mjs +runx: + tags: + - security + - dependencies + - osv +links: + source: https://github.com/RYDE-PLAY/runx/tree/ryde-play/dependency-advisory-graph/skills/dependency-advisory-graph + advisory_source: https://osv.dev +--- + +## What this skill does + +This skill audits a Node.js project's committed `package-lock.json` for known +vulnerabilities in npm dependencies. It extracts exact installed package +versions, queries the OSV API for each exact `{ ecosystem, package, version }` +tuple, and emits a machine-checkable evidence packet plus a concise Markdown +report. + +The skill is read-only. It does not install dependencies, execute target code, +modify repositories, open issues, submit advisories, or claim that a package is +safe when OSV returns no matching record. A zero-finding result means only that +OSV did not return advisories for the exact versions scanned under the selected +scope. + +## When to use this skill + +Use this skill when an agent needs a reproducible dependency vulnerability +snapshot for a public Node.js project, release candidate, benchmark fixture, or +security review packet. It is appropriate when the lockfile is public or has +been explicitly cleared for disclosure to OSV, and when the review needs exact +installed-version evidence rather than semver range guesses. + +The skill is especially useful before triage, upgrade planning, advisory +drafting, or reviewer handoff because it preserves the lockfile SHA-256, the +scan policy, the OSV query tuple for every finding, and references for each +advisory. + +## When not to use this skill + +Do not use this skill for private lockfiles unless the package names, versions, +and lockfile URL/path are approved for disclosure to the advisory source. Do not +use it as a full application security review, source-code audit, exploitability +assessment, SBOM generator, package installer, or automated remediation tool. + +Do not use the output as proof that a project is vulnerability-free. The result +depends on OSV coverage at run time, the selected dependency scope, and the +contents of the supplied lockfile. + +## Procedure + +1. Read `package_lock_path` or `package_lock_url`. +2. Record byte length and SHA-256 for the lockfile input. +3. Extract exact installed package versions from the lockfile. +4. Limit the scan to the declared `scan_scope` and `include_dev` policy. +5. Query OSV only with exact npm package names and exact installed versions. +6. Keep only vulnerabilities returned by OSV for that exact version query. +7. Emit `dependency.advisory.graph.result.v1` with findings, query evidence, and validation. +8. Write `evidence.json` and `report.md` when `output_dir` is provided. + +## Edge cases and stop conditions + +Stop with an error when neither `package_lock_path` nor `package_lock_url` is +provided, when the lockfile cannot be read, when the URL is not HTTPS, when the +lockfile is not valid JSON, or when it does not contain a `packages` object. + +For local paths and output paths, the resolved path must stay inside the skill +directory. This prevents the runner from reading or writing unrelated workspace +files. + +If an OSV request fails, stop instead of returning a partial success packet. If +`scan_scope` is `direct`, skip direct dependencies whose installed package entry +is missing from `packages`; do not invent versions from semver declarations. + +The output is evidence for dependency triage, not an authorization to publish a +security advisory or mutate a repository. Any later issue filing, advisory +publication, or remediation PR needs its own gate. + +## Output schema + +The primary output is `dependency_advisory_graph_result`, with schema +`dependency.advisory.graph.result.v1`: + +```json +{ + "schema": "dependency.advisory.graph.result.v1", + "data": { + "target": { + "name": "string | null", + "repo": "string | null", + "ref": "string | null" + }, + "source": { + "kind": "file | url", + "ref": "string", + "bytes": 0, + "sha256": "hex" + }, + "scanner": { + "name": "dependency-advisory-graph", + "version": "0.1.0", + "advisory_source": "OSV.dev v1 query API", + "advisory_endpoint": "https://api.osv.dev/v1/query" + }, + "policy": { + "ecosystem": "npm", + "scan_scope": "direct | all", + "include_dev": false, + "target_code_executed": false, + "target_packages_installed": false, + "finding_rule": "string" + }, + "summary": { + "dependencies_scanned": 0, + "packages_with_findings": 0, + "findings": 0 + }, + "dependencies": [], + "findings": [], + "typed_findings": [ + { + "package": "string", + "installed_version": "string", + "advisory_id": "string", + "evidence_url": "string", + "advisory_source": "https://api.osv.dev/v1/query", + "retrieved_at": "ISO-8601 timestamp", + "severity": "string", + "fix_version": "string | null", + "confidence": "high" + } + ], + "advisory_graph": { + "schema": "dependency.advisory.graph.v1", + "nodes": [], + "edges": [] + }, + "validation": { + "valid": true, + "every_finding_has_exact_version": true, + "every_finding_has_reference": true, + "every_finding_has_advisory_id": true, + "zero_false_hit_control": "string" + } + } +} +``` + +When `output_dir` is provided, the runner also writes `evidence.json` and +`report.md` inside that directory and returns their relative paths in +`data.artifacts`. + +## Worked example + +The harness scans OWASP NodeGoat at commit +`c5cb68a7084e4ae7dcc60e6a98768720a81841e8` using the committed +`package-lock.json`: + +```bash +runx skill "$PWD" \ + --input target_name="OWASP NodeGoat" \ + --input target_repo=https://github.com/OWASP/NodeGoat \ + --input target_ref=c5cb68a7084e4ae7dcc60e6a98768720a81841e8 \ + --input package_lock_url=https://raw.githubusercontent.com/OWASP/NodeGoat/c5cb68a7084e4ae7dcc60e6a98768720a81841e8/package-lock.json \ + --input scan_scope=direct \ + --input include_dev=false \ + --input output_dir=artifacts/nodegoat \ + --json +``` + +Expected evidence shape: + +- `source.sha256` records the fetched lockfile hash. +- `summary.dependencies_scanned` is the number of direct production npm + dependencies found in the lockfile. +- Each finding contains the exact installed version, OSV advisory id, aliases, + fixed versions when listed, affected ranges, and references. +- `typed_findings` exposes the review-critical fields required by the bounty: + package, installed_version, advisory_id, evidence_url, advisory_source, + retrieved_at, severity, fix_version, and confidence. +- `advisory_graph` connects exact `pkg:@` nodes to + `adv:` nodes with `affected_by_exact_version` edges. +- `validation.valid` is true only when every finding includes an exact version, + advisory id, and at least one reference. + +## Inputs + +- `target_name`: human-readable project name. +- `target_repo`: source repository URL. +- `target_ref`: immutable commit or release reference. +- `package_lock_path`: local path to a `package-lock.json`. +- `package_lock_url`: public URL for a `package-lock.json`. +- `scan_scope`: `direct` or `all`; defaults to `direct`. +- `include_dev`: include dev dependencies when true; defaults to false. +- `output_dir`: optional directory for `evidence.json` and `report.md`. + +## Outputs + +- `dependency_advisory_graph_result`: complete packet. +- `evidence_json`: same evidence as machine-checkable JSON. +- `report_md`: concise report with exact version findings and references. diff --git a/skills/dependency-advisory-graph/X.yaml b/skills/dependency-advisory-graph/X.yaml new file mode 100644 index 00000000..9645765b --- /dev/null +++ b/skills/dependency-advisory-graph/X.yaml @@ -0,0 +1,139 @@ +skill: dependency-advisory-graph +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runx: + mutating: false + idempotency: + key: lockfile_sha256 + scopes: + - dependencies.read + - advisories.osv.query + policy: + data_classification: public_dependency_metadata + network: + allowed: + - https://api.osv.dev/v1/query + - https://raw.githubusercontent.com/ + forbidden: + - private repositories + - package installation + - source code execution + verifier_notes: + - Every finding is produced from an exact package name and exact installed version query. + - The dogfood fixture pins the target repository to an immutable commit URL. + artifacts: + emits: + - dependency_advisory_graph_result + - typed_findings + - advisory_graph + - evidence_json + - report_md + wrap_as: dependency_advisory_graph_packet + +harness: + cases: + - name: nodegoat-direct-production + runner: default + inputs: + target_name: OWASP NodeGoat + target_repo: https://github.com/OWASP/NodeGoat + target_ref: c5cb68a7084e4ae7dcc60e6a98768720a81841e8 + package_lock_url: https://raw.githubusercontent.com/OWASP/NodeGoat/c5cb68a7084e4ae7dcc60e6a98768720a81841e8/package-lock.json + scan_scope: direct + include_dev: false + output_dir: artifacts/nodegoat + expect: + status: sealed + - name: clean-lockfile-no-findings + runner: default + inputs: + target_name: Clean local fixture + target_repo: local-fixture + target_ref: clean-package-lock-v1 + package_lock_url: https://raw.githubusercontent.com/RYDE-PLAY/runx/ryde-play/dependency-advisory-graph/skills/dependency-advisory-graph/fixtures/empty-package-lock.json + scan_scope: direct + include_dev: false + output_dir: artifacts/clean + expect: + status: sealed + - name: missing-lockfile-stop + runner: default + inputs: + target_name: Missing lockfile fixture + target_repo: local-fixture + target_ref: missing-lockfile-v1 + scan_scope: direct + include_dev: false + expect: + status: failure + +runners: + default: + default: true + type: cli-tool + command: /usr/bin/env + args: + - node + - run.mjs + scopes: + - dependencies.read + - advisories.osv.query + policy: + reads: + - public npm package lockfiles + calls: + - OSV exact-version query API + writes: + - evidence.json + - report.md + disallows: + - installing target packages + - executing target project code + - using private repository contents + inputs: + target_name: + type: string + required: true + description: Human-readable project name. + target_repo: + type: string + required: true + description: Public source repository URL. + target_ref: + type: string + required: false + description: Immutable commit, tag, or release reference. + package_lock_path: + type: string + required: false + description: Local package-lock.json path inside the skill directory. + package_lock_url: + type: string + required: false + description: Public package-lock.json URL. + scan_scope: + type: string + required: false + default: direct + description: direct or all dependencies. + include_dev: + type: boolean + required: false + default: false + description: Include development dependencies. + output_dir: + type: string + required: false + description: Directory inside the skill directory where artifacts should be written. + outputs: + dependency_advisory_graph_result: object + typed_findings: array + advisory_graph: object + evidence_json: object + report_md: string diff --git a/skills/dependency-advisory-graph/fixtures/clean.inputs.json b/skills/dependency-advisory-graph/fixtures/clean.inputs.json new file mode 100644 index 00000000..9ded6de4 --- /dev/null +++ b/skills/dependency-advisory-graph/fixtures/clean.inputs.json @@ -0,0 +1,9 @@ +{ + "target_name": "Clean local fixture", + "target_repo": "local-fixture", + "target_ref": "clean-package-lock-v1", + "package_lock_path": "fixtures/empty-package-lock.json", + "scan_scope": "direct", + "include_dev": false, + "output_dir": "artifacts/clean" +} diff --git a/skills/dependency-advisory-graph/fixtures/empty-package-lock.json b/skills/dependency-advisory-graph/fixtures/empty-package-lock.json new file mode 100644 index 00000000..11dd1d0a --- /dev/null +++ b/skills/dependency-advisory-graph/fixtures/empty-package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "empty-fixture", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "empty-fixture", + "version": "1.0.0" + } + } +} diff --git a/skills/dependency-advisory-graph/fixtures/nodegoat.inputs.json b/skills/dependency-advisory-graph/fixtures/nodegoat.inputs.json new file mode 100644 index 00000000..545a9540 --- /dev/null +++ b/skills/dependency-advisory-graph/fixtures/nodegoat.inputs.json @@ -0,0 +1,10 @@ +{ + "target_name": "OWASP NodeGoat", + "target_repo": "https://github.com/OWASP/NodeGoat", + "target_ref": "c5cb68a7084e4ae7dcc60e6a98768720a81841e8", + "package_lock_url": "https://raw.githubusercontent.com/OWASP/NodeGoat/c5cb68a7084e4ae7dcc60e6a98768720a81841e8/package-lock.json", + "scan_scope": "direct", + "include_dev": false, + "output_dir": "artifacts/nodegoat" +} + diff --git a/skills/dependency-advisory-graph/harness/local-case-runs.json b/skills/dependency-advisory-graph/harness/local-case-runs.json new file mode 100644 index 00000000..6e909a0d --- /dev/null +++ b/skills/dependency-advisory-graph/harness/local-case-runs.json @@ -0,0 +1,55 @@ +{ + "schema": "dependency_advisory_graph.local_harness_evidence.v1", + "runx_cli": "runx-cli 0.6.13", + "cases": [ + { + "name": "nodegoat-direct-production", + "status": "passed", + "command": "RUNX_INPUTS_PATH=fixtures/nodegoat.inputs.json node run.mjs", + "summary": { + "dependencies_scanned": 16, + "packages_with_findings": 6, + "findings": 13 + }, + "typed_findings": 13, + "graph_edges": 13, + "validation": { + "valid": true, + "every_finding_has_exact_version": true, + "every_finding_has_reference": true, + "every_finding_has_advisory_id": true, + "zero_false_hit_control": "Each OSV request uses only the exact package name and version read from the lockfile; no broad package-name-only findings, inferred ranges, or guessed versions are reported." + } + }, + { + "name": "clean-lockfile-no-findings", + "status": "passed", + "command": "RUNX_INPUTS_PATH=fixtures/clean.inputs.json node run.mjs", + "summary": { + "dependencies_scanned": 0, + "packages_with_findings": 0, + "findings": 0 + }, + "typed_findings": 0, + "graph_edges": 0, + "validation": { + "valid": true, + "every_finding_has_exact_version": true, + "every_finding_has_reference": true, + "every_finding_has_advisory_id": true, + "zero_false_hit_control": "Each OSV request uses only the exact package name and version read from the lockfile; no broad package-name-only findings, inferred ranges, or guessed versions are reported." + } + }, + { + "name": "missing-lockfile-stop", + "status": "passed", + "command": "RUNX_INPUTS_JSON='{\"target_name\":\"Missing lockfile fixture\",\"target_repo\":\"local-fixture\",\"target_ref\":\"missing-lockfile-v1\",\"scan_scope\":\"direct\",\"include_dev\":false}' node run.mjs", + "expected_status": "failure", + "observed_error": "package_lock_path or package_lock_url is required", + "summary": { + "purpose": "Proves the skill stops instead of fabricating an advisory graph when no lockfile source is provided." + } + } + ], + "note": "Local direct case evidence; registry publish is expected to run hosted harness for the same X.yaml cases, including the intentional missing-lockfile stop case." +} diff --git a/skills/dependency-advisory-graph/package.json b/skills/dependency-advisory-graph/package.json new file mode 100644 index 00000000..719af2bd --- /dev/null +++ b/skills/dependency-advisory-graph/package.json @@ -0,0 +1,16 @@ +{ + "name": "dependency-advisory-graph", + "version": "0.1.0", + "description": "Governed runx skill that builds an exact-version dependency advisory graph.", + "type": "module", + "publishConfig": { + "access": "public" + }, + "scripts": { + "dogfood": "RUNX_INPUTS_PATH=fixtures/nodegoat.inputs.json node run.mjs", + "dogfood:clean": "RUNX_INPUTS_PATH=fixtures/clean.inputs.json node run.mjs" + }, + "devDependencies": { + "@runxhq/cli": "^0.6.2" + } +} diff --git a/skills/dependency-advisory-graph/pnpm-lock.yaml b/skills/dependency-advisory-graph/pnpm-lock.yaml new file mode 100644 index 00000000..7e262214 --- /dev/null +++ b/skills/dependency-advisory-graph/pnpm-lock.yaml @@ -0,0 +1,75 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@runxhq/cli': + specifier: ^0.6.2 + version: 0.6.2 + +packages: + + '@runxhq/cli-darwin-arm64@0.6.2': + resolution: {integrity: sha512-sql1xgRlDrMxBjvCr3luTKU4nc8toLVQv0MaohPB2SqogJu3o+l589sOjs5zR7Wb7xwWorogmWbzDZ5Fvi2z2Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@runxhq/cli-darwin-x64@0.6.2': + resolution: {integrity: sha512-qQMgAta1nXRBQo3zJ7DU11OY4XU9zr/ZnZMdD7pJETxPKcJxr9DTNtsJnNo+XW0aBSgUrJCjeGoBPFWHZYyVrQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@runxhq/cli-linux-arm64@0.6.2': + resolution: {integrity: sha512-A86rWr89QXej9TMDJYngMeJKvYT8lSdLMrykyElcS3dg0NWhwL9EoP5An0T92NLMfpOOUvIDgtjWEMCmfFT2CQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@runxhq/cli-linux-x64@0.6.2': + resolution: {integrity: sha512-wfMuqCX3OYpFnMqALoAPQN7L6CE5qbZs4HgxBaAfzKI2wtfrmtoG0kA5jy88outoX6ZQZBmBizrpanfqgbctVQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@runxhq/cli-win32-x64@0.6.2': + resolution: {integrity: sha512-XZon0iGBRXfMaFgcotz0qx2cX0pwJ7LK1EEjv+R6xLevXc/XAeyXsemZMiyHsBsMxOlDgdUXurX+k0XUEc9YtA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@runxhq/cli@0.6.2': + resolution: {integrity: sha512-FcnhE6OutMn60WwtrNLBJ1B9PBedMYqzrBGHYcNz3Gcc8BUaVwn5ptVCialzKBBVJPge4V/krRAexSmSTF3dUg==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@runxhq/cli-darwin-arm64@0.6.2': + optional: true + + '@runxhq/cli-darwin-x64@0.6.2': + optional: true + + '@runxhq/cli-linux-arm64@0.6.2': + optional: true + + '@runxhq/cli-linux-x64@0.6.2': + optional: true + + '@runxhq/cli-win32-x64@0.6.2': + optional: true + + '@runxhq/cli@0.6.2': + optionalDependencies: + '@runxhq/cli-darwin-arm64': 0.6.2 + '@runxhq/cli-darwin-x64': 0.6.2 + '@runxhq/cli-linux-arm64': 0.6.2 + '@runxhq/cli-linux-x64': 0.6.2 + '@runxhq/cli-win32-x64': 0.6.2 diff --git a/skills/dependency-advisory-graph/run.mjs b/skills/dependency-advisory-graph/run.mjs new file mode 100644 index 00000000..f4c3c570 --- /dev/null +++ b/skills/dependency-advisory-graph/run.mjs @@ -0,0 +1,517 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import https from "node:https"; +import path from "node:path"; + +const OSV_QUERY_URL = "https://api.osv.dev/v1/query"; +const SCHEMA = "dependency.advisory.graph.result.v1"; +const SCANNER_VERSION = "0.1.0"; + +const inputs = readInputs(); +const skillRoot = process.cwd(); +const scanScope = inputs.scan_scope || "direct"; +const includeDev = inputs.include_dev === true; + +if (!["direct", "all"].includes(scanScope)) { + throw new Error("scan_scope must be direct or all"); +} + +const source = await readLockfile(inputs, skillRoot); +const lockfile = JSON.parse(source.text); +const dependencies = collectDependencies(lockfile, { scanScope, includeDev }); +const findings = await queryFindings(dependencies); +const evidence = buildEvidence({ inputs, source, dependencies, findings, scanScope, includeDev }); +const report = renderReport(evidence); + +writeArtifacts(inputs.output_dir, evidence, report, skillRoot); + +process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`); + +function readInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; + return JSON.parse(raw); +} + +async function readLockfile(rawInputs, root) { + if (typeof rawInputs.package_lock_path === "string" && rawInputs.package_lock_path.length > 0) { + const resolved = path.resolve(root, rawInputs.package_lock_path); + ensureInside(root, resolved, "package_lock_path"); + const text = fs.readFileSync(resolved, "utf8"); + return { kind: "file", ref: rawInputs.package_lock_path, text }; + } + if (typeof rawInputs.package_lock_url === "string" && rawInputs.package_lock_url.length > 0) { + const url = new URL(rawInputs.package_lock_url); + if (!["https:"].includes(url.protocol)) { + throw new Error("package_lock_url must be https"); + } + return { kind: "url", ref: url.href, text: await readHttpsText(url) }; + } + throw new Error("package_lock_path or package_lock_url is required"); +} + +function collectDependencies(lockfile, { scanScope, includeDev }) { + if (!lockfile || typeof lockfile !== "object") { + throw new Error("package-lock.json must be a JSON object"); + } + if (!lockfile.packages || typeof lockfile.packages !== "object") { + throw new Error("package-lock.json packages object is required"); + } + + const root = lockfile.packages[""] || {}; + const prodDirect = new Set(Object.keys(root.dependencies || {})); + const devDirect = new Set(Object.keys(root.devDependencies || {})); + const directNames = new Set([...prodDirect, ...(includeDev ? devDirect : [])]); + const results = []; + + if (scanScope === "direct") { + for (const name of directNames) { + const pkgPath = `node_modules/${name}`; + const pkg = lockfile.packages[pkgPath]; + if (!pkg || typeof pkg !== "object" || typeof pkg.version !== "string") { + continue; + } + results.push(dependencyRecord({ + name, + pkg, + pkgPath, + prodDirect, + devDirect, + })); + } + return dedupeDependencies(results).sort((a, b) => a.name.localeCompare(b.name)); + } + + for (const [pkgPath, pkg] of Object.entries(lockfile.packages)) { + if (!pkgPath || !pkgPath.startsWith("node_modules/") || !pkg || typeof pkg !== "object") { + continue; + } + if (!pkg.version || typeof pkg.version !== "string") { + continue; + } + if (pkg.dev === true && !includeDev) { + continue; + } + + const name = packageNameFromLockPath(pkgPath); + results.push(dependencyRecord({ + name, + pkg, + pkgPath, + prodDirect, + devDirect, + })); + } + + return dedupeDependencies(results).sort((a, b) => a.name.localeCompare(b.name)); +} + +function packageNameFromLockPath(pkgPath) { + const marker = "node_modules/"; + const rest = pkgPath.slice(pkgPath.lastIndexOf(marker) + marker.length); + if (rest.startsWith("@")) { + const [scope, name] = rest.split("/"); + return `${scope}/${name}`; + } + return rest.split("/")[0]; +} + +function dependencyRecord({ name, pkg, pkgPath, prodDirect, devDirect }) { + const isProdDirect = prodDirect.has(name) && pkgPath === `node_modules/${name}`; + const isDevDirect = devDirect.has(name) && pkgPath === `node_modules/${name}`; + return { + ecosystem: "npm", + name, + version: pkg.version, + relation: isProdDirect ? "direct-production" : isDevDirect ? "direct-development" : "transitive", + lockfile_path: pkgPath, + resolved: typeof pkg.resolved === "string" ? pkg.resolved : null, + integrity: typeof pkg.integrity === "string" ? pkg.integrity : null, + }; +} + +function dedupeDependencies(dependencies) { + const seen = new Set(); + const results = []; + for (const dep of dependencies) { + const key = `${dep.name}@${dep.version}`; + if (!seen.has(key)) { + seen.add(key); + results.push(dep); + } + } + return results; +} + +async function queryFindings(dependencies) { + const findings = []; + for (const dependency of dependencies) { + const payload = await postJson(new URL(OSV_QUERY_URL), { + version: dependency.version, + package: { + ecosystem: dependency.ecosystem, + name: dependency.name, + }, + }); + for (const vuln of payload.vulns || []) { + findings.push(normalizeVulnerability(dependency, vuln)); + } + } + return findings.sort((a, b) => + `${a.dependency.name}:${a.advisory_id}`.localeCompare(`${b.dependency.name}:${b.advisory_id}`), + ); +} + +function readHttpsText(url) { + return new Promise((resolve, reject) => { + const request = https.get(url, { headers: { accept: "application/json,text/plain,*/*" } }, (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`GET ${url.href} returned ${response.statusCode}`)); + return; + } + resolve(chunks.join("")); + }); + }); + request.setTimeout(30000, () => request.destroy(new Error(`GET ${url.href} timed out`))); + request.on("error", reject); + }); +} + +function postJson(url, payload) { + const body = JSON.stringify(payload); + return new Promise((resolve, reject) => { + const request = https.request(url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + "content-length": Buffer.byteLength(body), + }, + }, (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const text = chunks.join(""); + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`OSV query returned ${response.statusCode}`)); + return; + } + try { + resolve(JSON.parse(text)); + } catch (error) { + reject(new Error(`OSV query returned invalid JSON: ${error.message}`)); + } + }); + }); + request.setTimeout(30000, () => request.destroy(new Error(`POST ${url.href} timed out`))); + request.on("error", reject); + request.write(body); + request.end(); + }); +} + +function normalizeVulnerability(dependency, vuln) { + const references = (vuln.references || []) + .map((ref) => ({ type: ref.type || "WEB", url: ref.url })) + .filter((ref) => typeof ref.url === "string" && ref.url.startsWith("http")); + const severities = (vuln.severity || []).map((entry) => `${entry.type}:${entry.score}`); + const aliases = Array.isArray(vuln.aliases) ? vuln.aliases : []; + + const primaryReference = references[0]?.url || `https://osv.dev/vulnerability/${vuln.id}`; + const fixVersion = fixedVersions(vuln)[0] || null; + return { + package: dependency.name, + installed_version: dependency.version, + advisory_id: vuln.id, + evidence_url: primaryReference, + advisory_source: OSV_QUERY_URL, + retrieved_at: new Date().toISOString(), + severity: severityLabel(vuln), + fix_version: fixVersion, + confidence: "high", + dependency, + query: { + ecosystem: dependency.ecosystem, + package: dependency.name, + version: dependency.version, + advisory_source: OSV_QUERY_URL, + }, + advisory_id: vuln.id, + cve_ids: aliases.filter((alias) => alias.startsWith("CVE-")), + aliases, + summary: vuln.summary || "", + severity: severityLabel(vuln), + severity_vectors: severities, + fixed_versions: fixedVersions(vuln), + affected_ranges: affectedRangesForPackage(vuln, dependency.name), + published: vuln.published || null, + modified: vuln.modified || null, + references, + source_records: sourceRecords(vuln), + }; +} + +function severityLabel(vuln) { + const specific = vuln.database_specific || {}; + if (typeof specific.severity === "string" && specific.severity.length > 0) { + return specific.severity.toLowerCase(); + } + if (Array.isArray(vuln.severity) && vuln.severity.length > 0) { + return vuln.severity[0].score; + } + return "unknown"; +} + +function fixedVersions(vuln) { + const versions = new Set(); + for (const affected of vuln.affected || []) { + for (const range of affected.ranges || []) { + for (const event of range.events || []) { + if (event.fixed) versions.add(event.fixed); + } + } + } + return [...versions].sort(compareVersionish); +} + +function affectedRangesForPackage(vuln, packageName) { + const ranges = []; + for (const affected of vuln.affected || []) { + if (affected.package?.name !== packageName) continue; + for (const range of affected.ranges || []) { + ranges.push({ + type: range.type || null, + events: (range.events || []).map((event) => ({ ...event })), + }); + } + } + return ranges; +} + +function sourceRecords(vuln) { + const records = new Set(); + for (const affected of vuln.affected || []) { + const source = affected.database_specific?.source; + if (source) records.add(source); + } + return [...records].sort(); +} + +function compareVersionish(a, b) { + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); +} + +function buildEvidence({ inputs, source, dependencies, findings, scanScope, includeDev }) { + const uniquePackagesWithFindings = new Set(findings.map((finding) => finding.dependency.name)); + const everyFindingHasExactVersion = findings.every((finding) => + finding.query.version === finding.dependency.version + && finding.query.package === finding.dependency.name + && finding.dependency.version.length > 0, + ); + const everyFindingHasReference = findings.every((finding) => finding.references.length > 0); + const everyFindingHasAdvisoryId = findings.every((finding) => finding.advisory_id.length > 0); + + const graph = buildGraph({ dependencies, findings }); + const typedFindings = findings.map((finding) => ({ + package: finding.package, + installed_version: finding.installed_version, + advisory_id: finding.advisory_id, + evidence_url: finding.evidence_url, + advisory_source: finding.advisory_source, + retrieved_at: finding.retrieved_at, + severity: finding.severity, + fix_version: finding.fix_version, + confidence: finding.confidence, + })); + + return { + schema: SCHEMA, + data: { + target: { + name: inputs.target_name || null, + repo: inputs.target_repo || null, + ref: inputs.target_ref || null, + }, + source: { + kind: source.kind, + ref: source.ref, + bytes: Buffer.byteLength(source.text), + sha256: sha256(source.text), + }, + scanner: { + name: "dependency-advisory-graph", + version: SCANNER_VERSION, + advisory_source: "OSV.dev v1 query API", + advisory_endpoint: OSV_QUERY_URL, + }, + policy: { + ecosystem: "npm", + scan_scope: scanScope, + include_dev: includeDev, + target_code_executed: false, + target_packages_installed: false, + finding_rule: "A finding is included only when OSV returns it for the exact npm package name and exact installed version from package-lock.json.", + }, + summary: { + dependencies_scanned: dependencies.length, + packages_with_findings: uniquePackagesWithFindings.size, + findings: findings.length, + }, + dependencies, + findings, + typed_findings: typedFindings, + advisory_graph: graph, + validation: { + valid: everyFindingHasExactVersion && everyFindingHasReference && everyFindingHasAdvisoryId, + every_finding_has_exact_version: everyFindingHasExactVersion, + every_finding_has_reference: everyFindingHasReference, + every_finding_has_advisory_id: everyFindingHasAdvisoryId, + zero_false_hit_control: "Each OSV request uses only the exact package name and version read from the lockfile; no broad package-name-only findings, inferred ranges, or guessed versions are reported.", + }, + }, + }; +} + +function buildGraph({ dependencies, findings }) { + const nodes = []; + const edges = []; + const advisoryIds = new Set(); + + for (const dependency of dependencies) { + nodes.push({ + id: `pkg:${dependency.name}@${dependency.version}`, + kind: "package", + package: dependency.name, + installed_version: dependency.version, + relation: dependency.relation, + }); + } + + for (const finding of findings) { + if (!advisoryIds.has(finding.advisory_id)) { + advisoryIds.add(finding.advisory_id); + nodes.push({ + id: `adv:${finding.advisory_id}`, + kind: "advisory", + advisory_id: finding.advisory_id, + advisory_source: finding.advisory_source, + evidence_url: finding.evidence_url, + severity: finding.severity, + fix_version: finding.fix_version, + confidence: finding.confidence, + }); + } + edges.push({ + from: `pkg:${finding.package}@${finding.installed_version}`, + to: `adv:${finding.advisory_id}`, + relation: "affected_by_exact_version", + evidence_url: finding.evidence_url, + }); + } + + return { + schema: "dependency.advisory.graph.v1", + nodes, + edges, + }; +} + +function renderReport(packet) { + const data = packet.data; + const lines = []; + lines.push("# Dependency Advisory Graph Report"); + lines.push(""); + lines.push(`Target: ${data.target.name}`); + lines.push(`Repository: ${data.target.repo}`); + lines.push(`Reference: ${data.target.ref}`); + lines.push(`Lockfile: ${data.source.ref}`); + lines.push(`Lockfile SHA-256: \`${data.source.sha256}\``); + lines.push(""); + lines.push("## Summary"); + lines.push(""); + lines.push(`- Advisory source: ${data.scanner.advisory_source} (${data.scanner.advisory_endpoint})`); + lines.push(`- Scanner package: ${data.scanner.name}@${data.scanner.version}`); + lines.push(`- Scan scope: ${data.policy.scan_scope} npm dependencies`); + lines.push(`- Include dev dependencies: ${data.policy.include_dev}`); + lines.push(`- Dependencies scanned: ${data.summary.dependencies_scanned}`); + lines.push(`- Packages with findings: ${data.summary.packages_with_findings}`); + lines.push(`- Exact-version findings: ${data.summary.findings}`); + lines.push(`- Graph nodes: ${data.advisory_graph.nodes.length}`); + lines.push(`- Graph edges: ${data.advisory_graph.edges.length}`); + lines.push("- Graph receipt: not applicable to composition; this skill builds the advisory graph directly and the submitted dogfood receipt is the proof anchor."); + lines.push(`- Target code executed: ${data.policy.target_code_executed}`); + lines.push(`- Target packages installed: ${data.policy.target_packages_installed}`); + lines.push(""); + lines.push("## Findings"); + lines.push(""); + + if (data.findings.length === 0) { + lines.push("No OSV vulnerabilities were returned for the scanned exact versions."); + } else { + lines.push("| Package | Version | Advisory | CVE aliases | Severity | Fixed versions | Primary reference |"); + lines.push("| --- | --- | --- | --- | --- | --- | --- |"); + for (const finding of data.findings) { + lines.push([ + finding.package, + finding.installed_version, + finding.advisory_id, + finding.cve_ids.join(", ") || "none", + finding.severity, + finding.fix_version || finding.fixed_versions.join(", ") || "not listed", + finding.evidence_url, + ].map(markdownCell).join(" | ").replace(/^/, "| ").replace(/$/, " |")); + } + } + + lines.push(""); + lines.push("## Reproducibility Controls"); + lines.push(""); + lines.push("- The lockfile URL is pinned to an immutable Git commit."); + lines.push("- Every dependency version comes from `package-lock.json`, not semver range resolution."); + lines.push("- Every finding is returned by OSV for an exact npm package and version query."); + lines.push("- Every typed finding includes package, installed_version, advisory_id, evidence_url, advisory_source, retrieved_at, severity, fix_version, and confidence."); + lines.push("- The audit does not install packages or execute target project code."); + lines.push("- `evidence.json` contains the full dependency list, graph nodes and edges, OSV query tuple, advisory IDs, aliases, references, and validation booleans."); + lines.push(""); + + return `${lines.join("\n")}\n`; +} + +function markdownCell(value) { + return String(value).replace(/\|/g, "\\|").replace(/\n/g, " "); +} + +function writeArtifacts(outputDir, evidence, report, root) { + if (!outputDir) { + evidence.data.artifacts = {}; + return; + } + const resolved = path.resolve(root, outputDir); + ensureInside(root, resolved, "output_dir"); + fs.mkdirSync(resolved, { recursive: true }); + const evidencePath = path.join(resolved, "evidence.json"); + const reportPath = path.join(resolved, "report.md"); + evidence.data.artifacts = { + evidence_json: path.relative(root, evidencePath), + report_md: path.relative(root, reportPath), + }; + fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`); + fs.writeFileSync(reportPath, report); +} + +function ensureInside(root, resolved, label) { + const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + if (resolved !== root && !resolved.startsWith(normalizedRoot)) { + throw new Error(`${label} must stay inside the skill directory`); + } +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +}