securevibe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,46 @@
1
+ SecureVibe Beta License
2
+
3
+ Copyright (c) 2026 Benedict Patrick. All rights reserved.
4
+
5
+ SecureVibe is proprietary software. It is not open source. By installing or
6
+ using SecureVibe you agree to these terms.
7
+
8
+ 1. License grant. During the public beta you may install and run SecureVibe
9
+ free of charge to analyze and protect code you own or are authorized to
10
+ test, for personal or commercial purposes, subject to the usage limits
11
+ built into the software.
12
+
13
+ 2. Usage limits and paid plans. The free beta includes a daily usage limit.
14
+ Limits, features, and pricing may change during and after the beta, and
15
+ continued use after the beta may require a paid subscription. You will
16
+ keep control of your code either way: analysis runs on your machine.
17
+
18
+ 3. Restrictions. You may not copy, modify, translate, or create derivative
19
+ works of the software; redistribute, sublicense, rent, sell, or host it
20
+ as a service for third parties; remove or bypass usage limits or notices;
21
+ or reverse engineer it except to the extent a law expressly permits.
22
+
23
+ 4. Ownership. The software, including all intellectual property rights in
24
+ it, remains the exclusive property of the copyright holder. Findings and
25
+ reports produced from your code are yours.
26
+
27
+ 5. Feedback. If you send feedback, you grant the copyright holder a
28
+ perpetual, irrevocable, royalty free right to use it without obligation.
29
+
30
+ 6. Honesty of results. SecureVibe reports what its analysis found. A clean
31
+ result, a passing gate, or an applied fix is not a guarantee that your
32
+ application is secure. Always review changes and run your own tests.
33
+
34
+ 7. No warranty. The software is provided "as is", without warranty of any
35
+ kind, express or implied, including merchantability, fitness for a
36
+ particular purpose, and noninfringement.
37
+
38
+ 8. Limitation of liability. To the maximum extent permitted by law, the
39
+ copyright holder is not liable for any indirect, incidental, special,
40
+ consequential, or exemplary damages, or any loss of data, profits, or
41
+ revenue, arising from the use of or inability to use the software.
42
+
43
+ 9. Termination. This license ends automatically if you breach it. On
44
+ termination you must stop using the software and delete all copies.
45
+
46
+ 10. Contact. Questions and commercial licensing: benedictroger2006@gmail.com
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # securevibe
2
+
3
+ The local-first SecureVibe CLI — autonomous AI security engineer for AI generated apps.
4
+ Analysis runs entirely on your machine (tree-sitter AST + taint), no upload required.
5
+
6
+ ## Quick start
7
+
8
+ ```bash
9
+ npm install -g securevibe # or: npx securevibe scan <path>
10
+
11
+ securevibe scan <path> # findings + security score
12
+ securevibe ready <path> # launch readiness scorecard + go/no-go verdict
13
+ securevibe fix <path> # preview fixes (dry run)
14
+ securevibe fix <path> --apply # apply + back up originals (asks per change)
15
+ ```
16
+
17
+ From a clone of this repo instead: `pnpm install && pnpm build`, then
18
+ `node packages/cli/dist/index.js scan <path>` (`pnpm demo` scans the bundled sample).
19
+
20
+ The free beta includes a daily usage limit (50 analyses per day, reset at local
21
+ midnight). The `--staged` commit guard is never limited. See [`LICENSE`](LICENSE).
22
+
23
+ ## Commands
24
+
25
+ | Command | What it does |
26
+ |---|---|
27
+ | `securevibe scan [path]` | Full scan → findings + security score (default command) |
28
+ | `securevibe init [path]` | **Wire SecureVibe in: pre-commit guard + GitHub Action + gitignore** (doc 12) |
29
+ | `securevibe fix [path]` | **Autonomously fix findings, then re-scan to verify** (doc 05) |
30
+ | `securevibe deps [path]` | Audit dependencies for known CVEs against the local OSV database (SCA) |
31
+ | `securevibe db <update\|status>` | Manage the local OSV database — `update` is the only networked command |
32
+ | `securevibe ready [path]` | Launch readiness scorecard: pass/fail gates + go/no-go verdict |
33
+ | `securevibe ai-audit [path]` | Focus on the AI-agent attack surface (doc 04) |
34
+ | `securevibe protect [path]` | Remediation-first view: the fix for every finding (doc 05) |
35
+ | `securevibe attack-map [path]` | Exploit paths from reachable findings + choke point (doc 06) |
36
+ | `securevibe score [path]` | Just the score + deployment-readiness verdict (doc 08) |
37
+
38
+ Flags: `--json`, `--sarif` (GitHub code-scanning / CI interop), `--no-color`,
39
+ `--ci` (exit `2` on BLOCK, `1` on WARN, `0` on SHIP — for build gates),
40
+ `--staged` (scan only git-staged files — what the pre-commit guard uses).
41
+ `fix` flags: `--apply` (write to disk; asks before each change), `--yes` (apply every
42
+ verified fix without prompting — for CI/scripts), `--no-llm` (deterministic-only), `--json`.
43
+ `init` flags: `--force` (replace an existing hook/workflow/config).
44
+
45
+ ## Always-on (doc 12)
46
+
47
+ `securevibe init` makes the tool continuous so insecure code can't slip through:
48
+
49
+ - a **git pre-commit hook** that runs `scan --staged --ci` and **rejects any commit that introduces
50
+ a blocking issue** — including a committed secret (verified: it aborts the commit, then lets the
51
+ clean version through);
52
+ - a **GitHub Actions workflow** (`.github/workflows/securevibe.yml`) that scans every PR and uploads
53
+ SARIF to GitHub code scanning, gating the build on deployment-readiness;
54
+ - gitignores `.env` / backups and drops a minimal `securevibe.config.json`.
55
+
56
+ It is idempotent (existing files are left alone unless `--force`) and runs entirely locally —
57
+ **no account, no upload, and your secrets / API keys are never stored or transmitted.**
58
+
59
+ ## The fix loop (doc 05)
60
+
61
+ `securevibe fix` turns detection into remediation. For every finding it produces a
62
+ concrete code change, then **re-runs the exact same detectors on the new content** and
63
+ only keeps the change if it passes a verification gate:
64
+
65
+ > the targeted finding is gone **AND** no new medium-or-worse issue was introduced **AND**
66
+ > the file still parses.
67
+
68
+ Anything that fails the gate is rolled back and surfaced as a manual instruction.
69
+
70
+ - **Deterministic, offline (zero setup):** parameterize SQL (driver-aware placeholders),
71
+ lock wildcard CORS to an env-driven allowlist, pin weak JWT algorithms, and move
72
+ hardcoded secrets to env references / gitignore `.env`.
73
+ - **Claude-powered (optional, set `ANTHROPIC_API_KEY`):** *targets* the logic-level classes a
74
+ rule can't safely rewrite — RCE/command-injection, AI tool-hijack / missing firewall, IDOR,
75
+ DOM XSS, and complex SQL. One rewrite per file; the result is kept **only if it passes the
76
+ verification gate**, otherwise it falls back to a precise manual instruction.
77
+
78
+ **Honesty (read this):**
79
+ - A "fixed" finding means *our detector no longer flags it* — not that the app is proven secure.
80
+ Taint is intra-file, so always review the diff and run your tests.
81
+ - The AI-critical (`ai-tool-hijack`) clears only when the fix adds a guard our detector
82
+ *recognizes* (a tool allowlist + argument-schema validation — what the LLM prompt asks for).
83
+ The prompt-injection surface typically remains (input still reaches the model) and is reported
84
+ as a residual to address with input isolation.
85
+ - The deterministic, offline fixers are verified end-to-end here. The **live Claude path is wired
86
+ and unit-tested (response parsing, gate behavior) but has not been exercised against a real API
87
+ key in this build** — set `ANTHROPIC_API_KEY` and review the diffs it proposes.
88
+ - `fix` is **dry-run by default**; `--apply` backs up originals to `.securevibe-backup/<ts>/` first.
89
+ - `fix --apply` is **interactive**: it shows each verified diff and asks `[y]es / [n]o / [a]ll / [q]uit`
90
+ before writing. A declined change is reported `skipped`, never `fixed`, and the after-score reflects
91
+ only what you approved. In a non-interactive shell (CI, piped) it refuses to write unless `--yes` is
92
+ given, so it never hangs and never writes unattended by surprise.
93
+
94
+ ## Dependency / CVE scanning (SCA)
95
+
96
+ `securevibe deps` reads `package.json` and `requirements.txt`, matches each pinned version
97
+ against a local copy of the [OSV](https://osv.dev) advisory database, and reports known CVEs.
98
+
99
+ - **A separate dimension.** Dependency findings are reported on their own Dependencies line and
100
+ never enter the code sub-scores, so the three code baselines stay byte-identical. A `critical`
101
+ CVE still blocks deployment, and `securevibe fix` bumps the package to the lowest non vulnerable
102
+ version while preserving your range prefix (`^`, `~`, `==`, ...).
103
+ - **Offline first.** A bundled seed (`data/osv-seed.json`) ships with the CLI, so `deps` works with
104
+ no network. `db update` is the **only networked command**: it streams the public OSV npm and PyPI
105
+ dumps into `~/.securevibe/db`. Nothing about your code or package list leaves the machine.
106
+ - **Honest framing.** A clean result reads "no known advisories as of `<db date>`", not "secure" —
107
+ it means nothing matched the local database on that date, which is not a proof of safety.
108
+
109
+ ## What it detects
110
+
111
+ **Classic (doc 03):** SQL injection, command/code execution (RCE), XSS,
112
+ IDOR / broken access control, insecure CORS, weak JWT, hardcoded secrets
113
+ (provider keys, connection-string passwords, `.env` assignments).
114
+
115
+ **AI-application security (doc 04 — the differentiator):**
116
+ - Prompt-injection surface (untrusted input → LLM)
117
+ - **Prompt injection → tool execution** (model output drives an unvalidated fs/shell/DB/network sink)
118
+ - Over-privileged AI tools
119
+ - Missing AI execution firewall
120
+ - RAG / indirect-injection exposure
121
+ - Unauthenticated MCP servers
122
+
123
+ Findings are tagged with CWE / OWASP (incl. **OWASP LLM Top 10**) / MITRE ATT&CK & ATLAS,
124
+ and severity is **reachability-weighted** (taint-confirmed flows rank highest), per doc 03/08.
125
+
126
+ ## How it maps to the architecture
127
+
128
+ | Source | Design doc |
129
+ |---|---|
130
+ | `src/engine/parser.ts` | tree-sitter parsing layer — doc 02 |
131
+ | `src/engine/taint.ts` | intra-file taint — doc 03 |
132
+ | `src/engine/detectors/classic.ts` | classic detectors — doc 03 |
133
+ | `src/engine/detectors/aisecurity.ts` | AI-app security — doc 04 |
134
+ | `src/engine/detectors/secrets.ts` | secret detection — doc 03 |
135
+ | `src/engine/score.ts` | scoring + readiness — doc 08 |
136
+ | `src/engine/fix/*` | autonomous fix loop (deterministic + LLM + verify) — doc 05 |
137
+ | `src/ui/*` | report / protect / fix / attack-map / SARIF — docs 12/17 |
138
+
139
+ This is the buildable MVP slice. The cloud orchestration, multi-agent remediation
140
+ (auto-PRs), sandboxed attack simulation, and runtime protection live in the design docs
141
+ (05/07/09/13) and are the commercial layer.
142
+
143
+ ## Notes & limitations (honest MVP scope)
144
+
145
+ - Languages: JS / TS / TSX / Python (tree-sitter grammars). New grammars are additive.
146
+ - Taint is intra-file (doc 03 calls cross-file/symbolic out as the scale-up); the IDOR
147
+ detector is a heuristic semantic check (confidence-scored accordingly).
148
+ - `fix` verifies against our own (intra-file) detector — it confirms the finding is no
149
+ longer flagged, not that the app is provably secure. The deterministic fixers are
150
+ non-breaking by construction; the verification gate is the safety net for LLM rewrites.
151
+ - `fix` applies changes locally + backs them up; it does **not** open PRs yet (that's
152
+ doc 05's cloud engine). Detection favors precision but is not exhaustive.
@@ -0,0 +1,39 @@
1
+ {
2
+ "date": "2026-06-20",
3
+ "ecosystems": {
4
+ "npm": {
5
+ "lodash": [
6
+ {
7
+ "id": "GHSA-jf85-cpcp-j695",
8
+ "aliases": ["CVE-2019-10744"],
9
+ "severity": "critical",
10
+ "summary": "Prototype pollution in lodash before 4.17.12 via defaultsDeep allows modifying object prototype.",
11
+ "ranges": [{ "introduced": "0", "fixed": "4.17.12" }],
12
+ "url": "https://github.com/advisories/GHSA-jf85-cpcp-j695"
13
+ }
14
+ ],
15
+ "minimist": [
16
+ {
17
+ "id": "GHSA-vh95-rmgr-6w4m",
18
+ "aliases": ["CVE-2020-7598"],
19
+ "severity": "medium",
20
+ "summary": "Prototype pollution in minimist before 1.2.3.",
21
+ "ranges": [{ "introduced": "0", "fixed": "1.2.3" }],
22
+ "url": "https://github.com/advisories/GHSA-vh95-rmgr-6w4m"
23
+ }
24
+ ]
25
+ },
26
+ "PyPI": {
27
+ "jinja2": [
28
+ {
29
+ "id": "GHSA-h5c8-rqwp-cp95",
30
+ "aliases": ["CVE-2024-22195"],
31
+ "severity": "medium",
32
+ "summary": "Jinja2 before 3.1.3: HTML attribute injection via the xmlattr filter.",
33
+ "ranges": [{ "introduced": "0", "fixed": "3.1.3" }],
34
+ "url": "https://github.com/advisories/GHSA-h5c8-rqwp-cp95"
35
+ }
36
+ ]
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,88 @@
1
+ /** Depth-first visit of every *named* node. */
2
+ export function walk(node, visit) {
3
+ visit(node);
4
+ for (let i = 0; i < node.namedChildCount; i++) {
5
+ const child = node.namedChild(i);
6
+ if (child)
7
+ walk(child, visit);
8
+ }
9
+ }
10
+ /** Collect every named descendant whose type is in `types`. */
11
+ export function descendantsOfType(root, types) {
12
+ const set = new Set(types);
13
+ const out = [];
14
+ walk(root, (n) => {
15
+ if (set.has(n.type))
16
+ out.push(n);
17
+ });
18
+ return out;
19
+ }
20
+ /** 1-based line/column for reporting. */
21
+ export function loc(node) {
22
+ return { line: node.startPosition.row + 1, column: node.startPosition.column + 1 };
23
+ }
24
+ /**
25
+ * Resolve the callee text of a call node across JS and Python. Both grammars
26
+ * expose the called expression under the `function` field, so its `.text`
27
+ * yields the dotted name (e.g. `child_process.exec`, `db.query`,
28
+ * `openai.chat.completions.create`).
29
+ */
30
+ export function calleeText(callNode) {
31
+ const fn = callNode.childForFieldName("function");
32
+ return fn ? fn.text : null;
33
+ }
34
+ /** The last dotted segment of a callee, e.g. `exec` from `child_process.exec`. */
35
+ export function calleeLastSegment(callNode) {
36
+ const callee = calleeText(callNode);
37
+ if (!callee)
38
+ return null;
39
+ const parts = callee.split(".");
40
+ return parts[parts.length - 1] ?? null;
41
+ }
42
+ /** Raw text of the call's argument list (incl. parens / brackets). */
43
+ export function argsText(callNode) {
44
+ const args = callNode.childForFieldName("arguments");
45
+ return args ? args.text : "";
46
+ }
47
+ /** True if this node is a function/method call in either grammar. */
48
+ export function isCall(node) {
49
+ return node.type === "call_expression" || node.type === "call";
50
+ }
51
+ /** Extract a single source line (trimmed) for evidence display. */
52
+ export function lineOf(code, line) {
53
+ const lines = code.split(/\r?\n/);
54
+ return (lines[line - 1] ?? "").trim();
55
+ }
56
+ /**
57
+ * True if the parse tree contains a syntax error. The fix loop uses this as a
58
+ * hard gate: a candidate rewrite that no longer parses cleanly is rejected and
59
+ * rolled back, so we never write code that breaks the build.
60
+ */
61
+ export function hasError(root) {
62
+ if (root.hasError)
63
+ return true;
64
+ let bad = false;
65
+ walk(root, (n) => {
66
+ if (n.type === "ERROR" || n.isMissing)
67
+ bad = true;
68
+ });
69
+ return bad;
70
+ }
71
+ /**
72
+ * Return the source with comments blanked out (newlines preserved so line
73
+ * numbers are stable). Text-level heuristics must not match on comments —
74
+ * otherwise a `// no allowlist here` comment reads as "has an allowlist".
75
+ */
76
+ export function codeWithoutComments(root, code) {
77
+ const comments = descendantsOfType(root, ["comment"]);
78
+ if (comments.length === 0)
79
+ return code;
80
+ const chars = code.split("");
81
+ for (const c of comments) {
82
+ for (let i = c.startIndex; i < c.endIndex && i < chars.length; i++) {
83
+ if (chars[i] !== "\n" && chars[i] !== "\r")
84
+ chars[i] = " ";
85
+ }
86
+ }
87
+ return chars.join("");
88
+ }
@@ -0,0 +1,88 @@
1
+ // packages/cli/src/engine/deps/db.ts
2
+ /**
3
+ * Local OSV database for the SCA slice. Offline-first: a bundled seed snapshot
4
+ * ships in the package so the first scan needs no network; `securevibe db update`
5
+ * writes refreshed per-ecosystem files to ~/.securevibe/db and they are merged on
6
+ * load. Memoized — loaded once per process.
7
+ */
8
+ import { promises as fs } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ export function ecosystemKey(e) {
13
+ return e; // "npm" | "PyPI" — kept as a function so call sites are explicit
14
+ }
15
+ export function userDbDir() {
16
+ return path.join(os.homedir(), ".securevibe", "db");
17
+ }
18
+ /** Resolve the bundled seed; identical relative depth under src/ and dist/. */
19
+ function seedPath() {
20
+ return fileURLToPath(new URL("../../../data/osv-seed.json", import.meta.url));
21
+ }
22
+ export function lookup(db, ecosystem, name) {
23
+ const eco = db.ecosystems[ecosystem];
24
+ if (!eco)
25
+ return [];
26
+ return eco[name.toLowerCase()] ?? [];
27
+ }
28
+ let cached = null;
29
+ export async function loadDepDb() {
30
+ if (cached)
31
+ return cached;
32
+ const db = { date: null, ecosystems: {} };
33
+ // 1) bundled seed
34
+ try {
35
+ const seed = JSON.parse(await fs.readFile(seedPath(), "utf8"));
36
+ mergeInto(db, seed);
37
+ }
38
+ catch {
39
+ /* seed missing → empty db; scans still run, deps simply unassessed */
40
+ }
41
+ // 2) user cache (db update output): ~/.securevibe/db/<eco>.json
42
+ try {
43
+ const entries = await fs.readdir(userDbDir());
44
+ for (const f of entries) {
45
+ if (!f.endsWith(".json"))
46
+ continue;
47
+ try {
48
+ const part = JSON.parse(await fs.readFile(path.join(userDbDir(), f), "utf8"));
49
+ mergeInto(db, part);
50
+ }
51
+ catch {
52
+ /* skip a corrupt cache file */
53
+ }
54
+ }
55
+ }
56
+ catch {
57
+ /* no user cache yet — fine, seed is enough */
58
+ }
59
+ cached = db;
60
+ return db;
61
+ }
62
+ /** Test seam: drop the memoized db (used by db.update tests). */
63
+ export function _resetDbCache() {
64
+ cached = null;
65
+ }
66
+ /** Exported as a test seam: seed + `db update` cache overlap, so merge dedups by id. */
67
+ export function mergeInto(db, part) {
68
+ if (part?.date && (!db.date || part.date > db.date))
69
+ db.date = part.date;
70
+ const ecos = part?.ecosystems ?? {};
71
+ for (const [eco, pkgs] of Object.entries(ecos)) {
72
+ db.ecosystems[eco] ??= {};
73
+ for (const [name, advisories] of Object.entries(pkgs)) {
74
+ const key = name.toLowerCase();
75
+ const existing = db.ecosystems[eco][key] ?? [];
76
+ // Dedup by advisory id: the seed and a fresh `db update` overlap (lodash,
77
+ // jinja2, minimist are in both), so a plain concat would double-count.
78
+ const seen = new Set(existing.map((a) => a.id));
79
+ const merged = existing.slice();
80
+ for (const a of advisories)
81
+ if (!seen.has(a.id)) {
82
+ seen.add(a.id);
83
+ merged.push(a);
84
+ }
85
+ db.ecosystems[eco][key] = merged;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,63 @@
1
+ // packages/cli/src/engine/deps/detect.ts
2
+ /**
3
+ * Dependency (SCA) detection. Pure: a single manifest's content + the local OSV
4
+ * db -> Finding[]. Dependency findings carry category "dependency" so the code
5
+ * score (engine/score.ts) excludes them by construction; they are surfaced as a
6
+ * separate dimension instead. Honest framing: a clean result means "no known
7
+ * advisories as of <db date>", not "secure".
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { SEVERITY_ORDER } from "../types.js";
11
+ import { isManifest, parseManifest } from "./manifests.js";
12
+ import { isVulnerable, minFixedVersion } from "./match.js";
13
+ import { lookup } from "./db.js";
14
+ export function detectDependencies(relPath, code, db) {
15
+ if (!isManifest(relPath))
16
+ return [];
17
+ const out = [];
18
+ for (const dep of parseManifest(relPath, code)) {
19
+ for (const adv of lookup(db, dep.ecosystem, dep.name)) {
20
+ if (!isVulnerable(dep.baseVersion, adv.ranges))
21
+ continue;
22
+ const fixed = minFixedVersion(dep.baseVersion, adv.ranges);
23
+ const cve = adv.aliases.find((a) => a.startsWith("CVE-")) ?? adv.id;
24
+ const title = `Vulnerable dependency: ${dep.name}@${dep.baseVersion} (${cve})`;
25
+ const fix = fixed
26
+ ? `Upgrade ${dep.name} to ${fixed} or later (review the diff and run your tests).`
27
+ : `No patched release is known for ${dep.name}. Consider replacing or removing it.`;
28
+ out.push({
29
+ id: stableId(relPath, "vulnerable-dependency", dep.line, title),
30
+ detector: "vulnerable-dependency",
31
+ title,
32
+ category: "dependency",
33
+ severity: adv.severity,
34
+ confidence: dep.rangePrefix === "" || dep.rangePrefix === "==" ? 0.95 : 0.8,
35
+ reachable: false,
36
+ cwe: "CWE-1395",
37
+ owasp: "A06:2021 Vulnerable and Outdated Components",
38
+ file: relPath,
39
+ line: dep.line,
40
+ column: 1,
41
+ evidence: `${dep.name}: ${dep.version}`,
42
+ why: `${adv.summary} (${cve}; advisory ${adv.id})`,
43
+ fix,
44
+ });
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ function stableId(relPath, detector, line, title) {
50
+ const key = `${relPath}:${detector}:${line}:${title}`;
51
+ return createHash("sha1").update(key).digest("hex").slice(0, 12);
52
+ }
53
+ export function buildDependencyReport(findings, dbDate) {
54
+ const deps = findings.filter((f) => f.detector === "vulnerable-dependency");
55
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
56
+ let worst = null;
57
+ for (const f of deps) {
58
+ bySeverity[f.severity]++;
59
+ if (worst === null || SEVERITY_ORDER[f.severity] > SEVERITY_ORDER[worst])
60
+ worst = f.severity;
61
+ }
62
+ return { dbDate, total: deps.length, bySeverity, worst };
63
+ }
@@ -0,0 +1,110 @@
1
+ // packages/cli/src/engine/deps/manifests.ts
2
+ /**
3
+ * Manifest discovery + parsing for the SCA slice. Direct dependencies only:
4
+ * package.json (npm) and requirements.txt (PyPI). Lockfiles / transitive deps
5
+ * are a later slice. We keep the raw spec, the range operator, the base version
6
+ * (lowest the spec admits, used for matching), dev flag, and 1-based line.
7
+ */
8
+ import path from "node:path";
9
+ export function isManifest(relPath) {
10
+ const base = path.basename(relPath).toLowerCase();
11
+ return base === "package.json" || base === "requirements.txt";
12
+ }
13
+ function ecosystemFor(relPath) {
14
+ const base = path.basename(relPath).toLowerCase();
15
+ if (base === "package.json")
16
+ return "npm";
17
+ if (base === "requirements.txt")
18
+ return "PyPI";
19
+ return null;
20
+ }
21
+ const PREFIX_RE = /^(>=|<=|==|~=|\^|~|>|<|=)?\s*(.*)$/;
22
+ function splitSpec(spec) {
23
+ const m = spec.trim().match(PREFIX_RE);
24
+ if (!m)
25
+ return { prefix: "", base: spec.trim() };
26
+ const prefix = m[1] ?? "";
27
+ // base version = leading numeric.dotted token of the remainder
28
+ const base = (m[2].match(/[0-9][0-9A-Za-z.+-]*/) ?? [""])[0];
29
+ return { prefix, base };
30
+ }
31
+ export function parseManifest(relPath, code) {
32
+ const ecosystem = ecosystemFor(relPath);
33
+ if (!ecosystem)
34
+ return [];
35
+ if (ecosystem === "npm")
36
+ return parsePackageJson(code);
37
+ return parseRequirements(code);
38
+ }
39
+ function parsePackageJson(code) {
40
+ let json;
41
+ try {
42
+ json = JSON.parse(code);
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ const out = [];
48
+ const lines = code.split(/\r?\n/);
49
+ const collect = (obj, isDev) => {
50
+ if (!obj || typeof obj !== "object")
51
+ return;
52
+ for (const [name, raw] of Object.entries(obj)) {
53
+ if (typeof raw !== "string")
54
+ continue;
55
+ const { prefix, base } = splitSpec(raw);
56
+ if (!base)
57
+ continue;
58
+ out.push({
59
+ ecosystem: "npm",
60
+ name,
61
+ version: raw,
62
+ baseVersion: base,
63
+ rangePrefix: prefix,
64
+ isDev,
65
+ line: lineOfKey(lines, name),
66
+ });
67
+ }
68
+ };
69
+ collect(json.dependencies, false);
70
+ collect(json.devDependencies, true);
71
+ return out;
72
+ }
73
+ /** First line that declares `"name":` — good enough for a single manifest. */
74
+ function lineOfKey(lines, name) {
75
+ const needle = new RegExp(`"${escapeRe(name)}"\\s*:`);
76
+ for (let i = 0; i < lines.length; i++)
77
+ if (needle.test(lines[i]))
78
+ return i + 1;
79
+ return 1;
80
+ }
81
+ function escapeRe(s) {
82
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ }
84
+ function parseRequirements(code) {
85
+ const out = [];
86
+ const lines = code.split(/\r?\n/);
87
+ for (let i = 0; i < lines.length; i++) {
88
+ const line = lines[i].replace(/#.*$/, "").trim();
89
+ if (!line || line.startsWith("-"))
90
+ continue; // skip flags like -r, -e
91
+ const m = line.match(/^([A-Za-z0-9._-]+)\s*(>=|<=|==|~=|>|<)?\s*([0-9][0-9A-Za-z.+!-]*)?/);
92
+ if (!m || !m[1])
93
+ continue;
94
+ const name = m[1];
95
+ const prefix = m[2] ?? "";
96
+ const base = m[3] ?? "";
97
+ if (!base)
98
+ continue; // unpinned: nothing to match in this slice
99
+ out.push({
100
+ ecosystem: "PyPI",
101
+ name,
102
+ version: `${prefix}${base}`,
103
+ baseVersion: base,
104
+ rangePrefix: prefix,
105
+ isDev: false,
106
+ line: i + 1,
107
+ });
108
+ }
109
+ return out;
110
+ }
@@ -0,0 +1,51 @@
1
+ function split(v) {
2
+ const clean = v.trim().replace(/^[vV=]+/, "");
3
+ const [core, ...rest] = clean.split(/[-+]/);
4
+ const release = core.split(".").map((p) => {
5
+ const n = parseInt(p, 10);
6
+ return Number.isNaN(n) ? 0 : n;
7
+ });
8
+ const pre = rest.length ? rest.join("-") : null;
9
+ return { release, pre };
10
+ }
11
+ export function compareVersions(a, b) {
12
+ const av = split(a);
13
+ const bv = split(b);
14
+ const len = Math.max(av.release.length, bv.release.length);
15
+ for (let i = 0; i < len; i++) {
16
+ const x = av.release[i] ?? 0;
17
+ const y = bv.release[i] ?? 0;
18
+ if (x !== y)
19
+ return x < y ? -1 : 1;
20
+ }
21
+ // Equal release: a prerelease is lower than no prerelease.
22
+ if (av.pre && !bv.pre)
23
+ return -1;
24
+ if (!av.pre && bv.pre)
25
+ return 1;
26
+ if (av.pre && bv.pre)
27
+ return av.pre < bv.pre ? -1 : av.pre > bv.pre ? 1 : 0;
28
+ return 0;
29
+ }
30
+ export function isVulnerable(version, ranges) {
31
+ return ranges.some((r) => {
32
+ const introduced = r.introduced && r.introduced !== "0" ? r.introduced : null;
33
+ if (introduced && compareVersions(version, introduced) < 0)
34
+ return false;
35
+ if (r.fixed && compareVersions(version, r.fixed) >= 0)
36
+ return false;
37
+ return true;
38
+ });
39
+ }
40
+ export function minFixedVersion(version, ranges) {
41
+ let best = null;
42
+ for (const r of ranges) {
43
+ if (!r.fixed)
44
+ continue;
45
+ if (compareVersions(r.fixed, version) <= 0)
46
+ continue; // not actually an upgrade
47
+ if (best === null || compareVersions(r.fixed, best) < 0)
48
+ best = r.fixed;
49
+ }
50
+ return best;
51
+ }