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 +46 -0
- package/README.md +152 -0
- package/data/osv-seed.json +39 -0
- package/dist/engine/ast.js +88 -0
- package/dist/engine/deps/db.js +88 -0
- package/dist/engine/deps/detect.js +63 -0
- package/dist/engine/deps/manifests.js +110 -0
- package/dist/engine/deps/match.js +51 -0
- package/dist/engine/deps/osv.js +162 -0
- package/dist/engine/detectors/aisecurity.js +195 -0
- package/dist/engine/detectors/classic.js +284 -0
- package/dist/engine/detectors/secrets.js +71 -0
- package/dist/engine/detectors/util.js +28 -0
- package/dist/engine/fix/deterministic.js +335 -0
- package/dist/engine/fix/llm.js +118 -0
- package/dist/engine/fix/session.js +333 -0
- package/dist/engine/fix/types.js +1 -0
- package/dist/engine/git.js +58 -0
- package/dist/engine/init.js +161 -0
- package/dist/engine/parser.js +61 -0
- package/dist/engine/readiness/checks.js +99 -0
- package/dist/engine/readiness/scorecard.js +59 -0
- package/dist/engine/scan.js +269 -0
- package/dist/engine/score.js +66 -0
- package/dist/engine/taint.js +123 -0
- package/dist/engine/types.js +8 -0
- package/dist/index.js +258 -0
- package/dist/ui/attackmap.js +66 -0
- package/dist/ui/banner.js +68 -0
- package/dist/ui/deps.js +54 -0
- package/dist/ui/emit.js +62 -0
- package/dist/ui/fix.js +188 -0
- package/dist/ui/init.js +34 -0
- package/dist/ui/prompt.js +62 -0
- package/dist/ui/protect.js +67 -0
- package/dist/ui/readiness.js +62 -0
- package/dist/ui/report.js +169 -0
- package/dist/usage.js +68 -0
- package/package.json +66 -0
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
|
+
}
|