safeey-cli 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Noohra Innovate Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Safeey CLI
2
+
3
+ Scan your source code for security vulnerabilities — locally, in seconds.
4
+ A [Safeey](https://safeey.io) product by Noohra Innovate Ltd.
5
+
6
+ Where Safeey's web scanner checks your **live site** from the outside, `safeey-cli`
7
+ checks your **source code** from the inside — the two halves of shipping securely.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g safeey-cli
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```bash
18
+ safeey scan ./ # scan the current project
19
+ safeey scan ./src # scan a folder
20
+ safeey scan . --fail-on critical # for CI: exit non-zero on critical findings
21
+ safeey scan . --json > report.json # machine-readable output
22
+ ```
23
+
24
+ Your code never leaves your machine — scanning runs entirely locally.
25
+
26
+ ## What it checks
27
+
28
+ - **Dangerous code patterns** (AST-based): `eval`/`Function` code execution,
29
+ command injection via `child_process`, SQL injection from string-built queries,
30
+ XSS via `innerHTML`/`dangerouslySetInnerHTML`, disabled TLS verification, weak
31
+ hashing (MD5/SHA-1), open redirects, permissive CORS, hardcoded secret fallbacks.
32
+ - **Hardcoded secrets**: AWS keys, private keys, Stripe/Google/GitHub/Slack/OpenAI
33
+ tokens, JWTs, and generic credential assignments (values are masked in output).
34
+ - **Dependencies**: known-vulnerable and unpinned packages in `package.json`.
35
+
36
+ Every finding includes a plain-English explanation and the fix, plus a **risk grade**
37
+ (A–F) for the whole codebase.
38
+
39
+ ## Options
40
+
41
+ | Flag | Description |
42
+ |------|-------------|
43
+ | `--fail-on <sev>` | Exit non-zero if any finding ≥ severity (`critical`/`high`/`medium`/`low`/`off`; default `high`) |
44
+ | `--json` | Output findings as JSON |
45
+ | `--no-secrets` | Skip secret detection |
46
+ | `-h, --help` | Help |
47
+ | `-v, --version` | Version |
48
+
49
+ ## Languages
50
+
51
+ JavaScript / TypeScript today (AST-based). Secret and dependency scanning are
52
+ language-agnostic. More languages (Python, Go) are on the roadmap.
53
+
54
+ ## Roadmap
55
+
56
+ - More languages via tree-sitter grammars
57
+ - Full CVE coverage via the OSV database
58
+ - Optional AI layer to explain findings and suggest patches
59
+ - `safeey login` to sync results to your Safeey dashboard
60
+
61
+ ## License
62
+
63
+ MIT © Noohra Innovate Ltd.
package/bin/safeey.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // bin/safeey.js — Safeey CLI entry point.
3
+ const { scan, SEV_ORDER } = require("../src/scan");
4
+ const { printReport } = require("../src/report");
5
+ const { getConfig, setConfig, CONFIG_PATH } = require("../src/config");
6
+ const { syncResults } = require("../src/sync");
7
+
8
+ const pkg = require("../package.json");
9
+
10
+ const HELP = `
11
+ Safeey CLI ${pkg.version} — scan your source code for security issues.
12
+
13
+ Usage
14
+ safeey scan [path] Scan a directory or file (default: current dir)
15
+ safeey login <api-key> Save your Safeey API key (for --sync)
16
+ safeey logout Remove the saved API key
17
+
18
+ Options
19
+ --sync Send results to your Safeey dashboard (code stays local)
20
+ --json Output findings as JSON
21
+ --fail-on <severity> Exit non-zero if any finding >= severity
22
+ (critical|high|medium|low|off; default: high)
23
+ --no-secrets Skip secret detection
24
+ -h, --help Show this help
25
+ -v, --version Show version
26
+
27
+ Examples
28
+ safeey scan ./
29
+ safeey scan ./src --fail-on critical
30
+ safeey login sk_safeey_xxx && safeey scan . --sync
31
+ `;
32
+
33
+ function parseArgs(argv) {
34
+ const args = { _: [], failOn: "high", json: false, secrets: true, sync: false };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a === "--json") args.json = true;
38
+ else if (a === "--sync") args.sync = true;
39
+ else if (a === "--no-secrets") args.secrets = false;
40
+ else if (a === "--fail-on") args.failOn = (argv[++i] || "high").toLowerCase();
41
+ else if (a === "-h" || a === "--help") args.help = true;
42
+ else if (a === "-v" || a === "--version") args.version = true;
43
+ else args._.push(a);
44
+ }
45
+ return args;
46
+ }
47
+
48
+ async function main() {
49
+ const args = parseArgs(process.argv.slice(2));
50
+ if (args.version) { console.log(pkg.version); return; }
51
+ if (args.help || args._[0] === "help") { console.log(HELP); return; }
52
+
53
+ const command = args._[0];
54
+
55
+ if (command === "login") {
56
+ const key = args._[1];
57
+ if (!key) { console.error("Usage: safeey login <api-key>"); process.exit(2); }
58
+ setConfig({ apiKey: key });
59
+ console.log(`Saved. API key stored in ${CONFIG_PATH}`);
60
+ return;
61
+ }
62
+ if (command === "logout") {
63
+ setConfig({ apiKey: null });
64
+ console.log("Logged out. API key removed.");
65
+ return;
66
+ }
67
+
68
+ const target = (command === "scan" ? args._[1] : command) || ".";
69
+
70
+ let result;
71
+ try {
72
+ result = scan(target, { secrets: args.secrets });
73
+ } catch (e) {
74
+ console.error("Error:", e.message);
75
+ process.exit(2);
76
+ }
77
+
78
+ if (args.json) {
79
+ console.log(JSON.stringify({ target, ...result, SEV_ORDER: undefined }, null, 2));
80
+ } else {
81
+ printReport(result, target);
82
+ }
83
+
84
+ if (args.sync) {
85
+ const r = await syncResults(result, target);
86
+ if (r.ok) console.log(` \u2191 Synced to your Safeey dashboard${r.url ? `: ${r.url}` : "."}\n`);
87
+ else console.log(` \u26a0 Sync skipped: ${r.reason}\n`);
88
+ }
89
+
90
+ if (args.failOn !== "off") {
91
+ const threshold = SEV_ORDER[args.failOn] ?? SEV_ORDER.high;
92
+ const worst = Math.max(0, ...result.findings.map((f) => SEV_ORDER[f.severity] || 0));
93
+ if (worst >= threshold) process.exit(1);
94
+ }
95
+ process.exit(0);
96
+ }
97
+
98
+ main();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "safeey-cli",
3
+ "version": "0.1.0",
4
+ "description": "Scan your source code for security vulnerabilities — a Safeey product by Noohra Innovate Ltd.",
5
+ "bin": {
6
+ "safeey": "./bin/safeey.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": [
17
+ "security",
18
+ "sast",
19
+ "static-analysis",
20
+ "vulnerability",
21
+ "scanner",
22
+ "safeey"
23
+ ],
24
+ "author": "Noohra Innovate Ltd.",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@babel/parser": "7.25.6",
28
+ "@babel/traverse": "7.25.6"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/davolu/safeey-cli.git"
33
+ },
34
+ "homepage": "https://safeey.io",
35
+ "bugs": {
36
+ "url": "https://github.com/davolu/safeey-cli/issues"
37
+ },
38
+ "scripts": {
39
+ "test": "node bin/safeey.js scan ./test-fixtures --fail-on off",
40
+ "prepublishOnly": "node -e \"require('./src/scan')\""
41
+ }
42
+ }
package/src/config.js ADDED
@@ -0,0 +1,34 @@
1
+ // src/config.js — local CLI config (~/.safeey/config.json).
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ const DIR = path.join(os.homedir(), ".safeey");
7
+ const FILE = path.join(DIR, "config.json");
8
+ const DEFAULT_API_URL = "https://safeey.io";
9
+
10
+ function read() {
11
+ try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; }
12
+ }
13
+
14
+ function getConfig() {
15
+ const file = read();
16
+ return {
17
+ apiKey: process.env.SAFEEY_API_KEY || file.apiKey || null,
18
+ apiUrl: process.env.SAFEEY_API_URL || file.apiUrl || DEFAULT_API_URL,
19
+ };
20
+ }
21
+
22
+ function setConfig(patch) {
23
+ const cur = read();
24
+ const next = { ...cur, ...patch };
25
+ try {
26
+ fs.mkdirSync(DIR, { recursive: true });
27
+ fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
28
+ } catch (e) {
29
+ throw new Error(`Could not write config at ${FILE}: ${e.message}`);
30
+ }
31
+ return next;
32
+ }
33
+
34
+ module.exports = { getConfig, setConfig, CONFIG_PATH: FILE };
@@ -0,0 +1,65 @@
1
+ // src/engine/deps.js — dependency checks from package.json.
2
+ //
3
+ // Two layers:
4
+ // 1. Hygiene: unpinned / wildcard version ranges (supply-chain risk).
5
+ // 2. Known-bad: a small bundled advisory list for demonstration.
6
+ // A production build would additionally query the OSV.dev batch API for full
7
+ // CVE coverage — see queryOsv() below (network call, disabled by default here).
8
+
9
+ // Tiny illustrative advisory list. Real coverage comes from OSV (see below).
10
+ const KNOWN_BAD = {
11
+ "event-stream": { range: "3.3.6", note: "Compromised release (malicious code injected in 2018)." },
12
+ "flatmap-stream": { range: "*", note: "Malicious package used in the event-stream incident." },
13
+ "request": { range: "*", note: "Deprecated and unmaintained; known issues, no security fixes." },
14
+ "lodash": { range: "<4.17.21", note: "Prototype-pollution vulnerabilities in older versions." },
15
+ "minimist": { range: "<1.2.6", note: "Prototype-pollution vulnerability (CVE-2021-44906)." },
16
+ };
17
+
18
+ function analyzeDeps(pkgJsonText, file) {
19
+ const findings = [];
20
+ let pkg;
21
+ try { pkg = JSON.parse(pkgJsonText); } catch { return findings; }
22
+
23
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
24
+ for (const [name, range] of Object.entries(deps)) {
25
+ const ver = String(range);
26
+
27
+ if (KNOWN_BAD[name]) {
28
+ findings.push({
29
+ ruleId: "deps/known-vulnerable",
30
+ title: `Known-vulnerable dependency: ${name}`,
31
+ severity: "high",
32
+ file,
33
+ line: 0,
34
+ column: 0,
35
+ message: `${name}@${ver} — ${KNOWN_BAD[name].note}`,
36
+ fix: `Update ${name} to a patched version, or replace it if unmaintained. Run your package manager's audit for details.`,
37
+ });
38
+ continue;
39
+ }
40
+
41
+ if (ver === "*" || ver === "latest" || ver === "" || /^>=?\s*0/.test(ver)) {
42
+ findings.push({
43
+ ruleId: "deps/unpinned-range",
44
+ title: `Unpinned dependency version: ${name}`,
45
+ severity: "low",
46
+ file,
47
+ line: 0,
48
+ column: 0,
49
+ message: `${name} is pinned to "${ver}", which can pull in unexpected (possibly malicious) versions.`,
50
+ fix: `Pin ${name} to a specific caret/tilde range and commit a lockfile.`,
51
+ });
52
+ }
53
+ }
54
+ return findings;
55
+ }
56
+
57
+ // Optional: full vulnerability coverage via OSV.dev. Enable in the CLI with a
58
+ // flag once you're comfortable making a network call. Left un-wired by default.
59
+ async function queryOsv(deps, ecosystem = "npm") {
60
+ const body = { queries: Object.entries(deps).map(([name, version]) => ({ package: { name, ecosystem }, version: String(version).replace(/^[^\d]*/, "") })) };
61
+ const res = await fetch("https://api.osv.dev/v1/querybatch", { method: "POST", body: JSON.stringify(body) });
62
+ return res.json();
63
+ }
64
+
65
+ module.exports = { analyzeDeps, queryOsv };
@@ -0,0 +1,51 @@
1
+ // src/engine/js.js — parse a JS/TS file and run the AST rules against it.
2
+ const { parse } = require("@babel/parser");
3
+ const traverseModule = require("@babel/traverse");
4
+ const traverse = traverseModule.default || traverseModule;
5
+ const { RULES } = require("../rules/js-rules");
6
+
7
+ // Index rules by the node types they care about, so we only run relevant ones.
8
+ const BY_TYPE = {};
9
+ for (const rule of RULES) {
10
+ for (const t of rule.nodeTypes) (BY_TYPE[t] ||= []).push(rule);
11
+ }
12
+
13
+ const PLUGINS = ["jsx", "typescript", "decorators-legacy", "classProperties", "optionalChaining", "nullishCoalescingOperator", "topLevelAwait"];
14
+
15
+ function analyzeJs(code, file) {
16
+ const findings = [];
17
+ let ast;
18
+ try {
19
+ ast = parse(code, { sourceType: "unambiguous", errorRecovery: true, plugins: PLUGINS });
20
+ } catch {
21
+ return findings; // unparseable file — skip rather than crash the whole scan
22
+ }
23
+
24
+ const visitor = {};
25
+ for (const type of Object.keys(BY_TYPE)) {
26
+ visitor[type] = (path) => {
27
+ for (const rule of BY_TYPE[type]) {
28
+ let hit = false;
29
+ try { hit = rule.test(path.node); } catch { hit = false; }
30
+ if (hit) {
31
+ const loc = path.node.loc && path.node.loc.start;
32
+ findings.push({
33
+ ruleId: rule.id,
34
+ title: rule.title,
35
+ severity: rule.severity,
36
+ file,
37
+ line: loc ? loc.line : 0,
38
+ column: loc ? loc.column + 1 : 0,
39
+ message: rule.message,
40
+ fix: rule.fix,
41
+ });
42
+ }
43
+ }
44
+ };
45
+ }
46
+
47
+ try { traverse(ast, visitor); } catch { /* partial results are fine */ }
48
+ return findings;
49
+ }
50
+
51
+ module.exports = { analyzeJs };
@@ -0,0 +1,57 @@
1
+ // src/engine/secrets.js — regex-based secret detection, language-agnostic.
2
+
3
+ const SECRET_RULES = [
4
+ { id: "secret/aws-access-key", title: "AWS access key ID", severity: "high", re: /\bAKIA[0-9A-Z]{16}\b/ },
5
+ { id: "secret/aws-secret", title: "AWS secret access key", severity: "critical", re: /aws.{0,20}['"][0-9a-zA-Z/+]{40}['"]/i },
6
+ { id: "secret/private-key", title: "Private key", severity: "critical", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
7
+ { id: "secret/stripe-live", title: "Stripe live secret key", severity: "critical", re: /\bsk_live_[0-9a-zA-Z]{20,}\b/ },
8
+ { id: "secret/stripe-test", title: "Stripe test secret key", severity: "medium", re: /\bsk_test_[0-9a-zA-Z]{20,}\b/ },
9
+ { id: "secret/google-api", title: "Google API key", severity: "high", re: /\bAIza[0-9A-Za-z_\-]{35}\b/ },
10
+ { id: "secret/github-token", title: "GitHub token", severity: "high", re: /\bgh[pousr]_[0-9A-Za-z]{36,}\b/ },
11
+ { id: "secret/slack-token", title: "Slack token", severity: "high", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/ },
12
+ { id: "secret/openai", title: "OpenAI API key", severity: "high", re: /\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/ },
13
+ { id: "secret/jwt", title: "Hardcoded JWT", severity: "medium", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
14
+ {
15
+ id: "secret/generic-assignment",
16
+ title: "Hardcoded credential",
17
+ severity: "high",
18
+ re: /(?:api[_-]?key|secret|passwd|password|token|client[_-]?secret|access[_-]?token|private[_-]?key)\s*[:=]\s*['"][A-Za-z0-9_\-./+=]{12,}['"]/i,
19
+ },
20
+ ];
21
+
22
+ // Lines we shouldn't flag: obvious placeholders and env-var references.
23
+ const IGNORE = /(process\.env|import\.meta\.env|example|placeholder|your[_-]?(key|token|secret)|xxxx|<[^>]+>|\$\{)/i;
24
+
25
+ function mask(s) {
26
+ const t = s.replace(/['"]/g, "");
27
+ if (t.length <= 10) return t.slice(0, 3) + "****";
28
+ return t.slice(0, 4) + "…" + t.slice(-3);
29
+ }
30
+
31
+ function analyzeSecrets(code, file) {
32
+ const findings = [];
33
+ const lines = code.split(/\r?\n/);
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i];
36
+ if (line.length > 500) continue;
37
+ for (const rule of SECRET_RULES) {
38
+ const m = rule.re.exec(line);
39
+ if (!m) continue;
40
+ if (IGNORE.test(line) && rule.id === "secret/generic-assignment") continue;
41
+ findings.push({
42
+ ruleId: rule.id,
43
+ title: rule.title,
44
+ severity: rule.severity,
45
+ file,
46
+ line: i + 1,
47
+ column: (m.index ?? 0) + 1,
48
+ message: `A ${rule.title.toLowerCase()} appears to be hardcoded in source (${mask(m[0])}). Anything committed to code is effectively public.`,
49
+ fix: "Move the value to an environment variable or secret manager, remove it from the code and git history, and rotate the credential.",
50
+ });
51
+ break; // one secret finding per line is enough
52
+ }
53
+ }
54
+ return findings;
55
+ }
56
+
57
+ module.exports = { analyzeSecrets };
package/src/report.js ADDED
@@ -0,0 +1,52 @@
1
+ // src/report.js — pretty terminal output (and JSON).
2
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3
+ const c = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
4
+ const bold = c("1"), dim = c("2"), red = c("31"), yellow = c("33"), blue = c("34"), green = c("32"), magenta = c("35"), cyan = c("36");
5
+
6
+ const SEV_STYLE = {
7
+ critical: (s) => c("41;97")(` ${s} `),
8
+ high: red, medium: yellow, low: blue, info: dim,
9
+ };
10
+ const GRADE_STYLE = { A: green, B: cyan, C: yellow, D: magenta, F: red };
11
+
12
+ function printReport({ findings, filesScanned, grade }, target) {
13
+ const g = grade;
14
+ console.log("");
15
+ console.log(bold(` Safeey CLI`) + dim(` · scanned ${filesScanned} file(s) in ${target}`));
16
+ console.log("");
17
+
18
+ if (findings.length === 0) {
19
+ console.log(" " + green("✔ No issues found.") + dim(" (Grade A)"));
20
+ console.log("");
21
+ return;
22
+ }
23
+
24
+ let currentFile = "";
25
+ for (const f of findings) {
26
+ if (f.file !== currentFile) {
27
+ currentFile = f.file;
28
+ console.log(" " + bold(underlineSafe(currentFile)));
29
+ }
30
+ const sev = (SEV_STYLE[f.severity] || dim)(f.severity.toUpperCase());
31
+ const loc = f.line ? dim(`:${f.line}:${f.column}`) : "";
32
+ console.log(` ${sev} ${f.title}${loc} ${dim(f.ruleId)}`);
33
+ console.log(` ${f.message}`);
34
+ console.log(` ${green("fix")} ${dim(f.fix)}`);
35
+ console.log("");
36
+ }
37
+
38
+ const { counts } = g;
39
+ const parts = [];
40
+ if (counts.critical) parts.push(red(`${counts.critical} critical`));
41
+ if (counts.high) parts.push(red(`${counts.high} high`));
42
+ if (counts.medium) parts.push(yellow(`${counts.medium} medium`));
43
+ if (counts.low) parts.push(blue(`${counts.low} low`));
44
+
45
+ console.log(" " + dim("─".repeat(48)));
46
+ console.log(` ${bold("Risk grade")} ${(GRADE_STYLE[g.letter] || dim)(bold(g.letter))} ${dim(`(${g.score}/100)`)} ${parts.join(dim(" · "))}`);
47
+ console.log("");
48
+ }
49
+
50
+ function underlineSafe(s) { return useColor ? `\x1b[4m${s}\x1b[24m` : s; }
51
+
52
+ module.exports = { printReport };
@@ -0,0 +1,59 @@
1
+ // src/rules/helpers.js — small AST helpers shared by the rules.
2
+
3
+ function memberName(node) {
4
+ if (!node) return null;
5
+ if (node.type === "Identifier") return node.name;
6
+ if (node.type === "ThisExpression") return "this";
7
+ if (node.type === "MemberExpression") {
8
+ const obj = memberName(node.object);
9
+ const prop = node.computed ? null : node.property && node.property.name;
10
+ return obj && prop ? `${obj}.${prop}` : prop || null;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function calleeName(call) {
16
+ return call && call.callee ? memberName(call.callee) : null;
17
+ }
18
+
19
+ // A value node is "dynamic" if it isn't a plain literal — i.e. it can carry
20
+ // user-controlled data. Interpolated template strings and concatenations count.
21
+ function isDynamic(node) {
22
+ if (!node) return false;
23
+ switch (node.type) {
24
+ case "StringLiteral":
25
+ case "NumericLiteral":
26
+ case "BooleanLiteral":
27
+ case "NullLiteral":
28
+ return false;
29
+ case "TemplateLiteral":
30
+ return node.expressions.length > 0;
31
+ default:
32
+ return true;
33
+ }
34
+ }
35
+
36
+ // True when the value is built by string interpolation/concatenation (the
37
+ // classic injection shape), as opposed to just being some variable.
38
+ function isStringBuilt(node) {
39
+ if (!node) return false;
40
+ if (node.type === "TemplateLiteral") return node.expressions.length > 0;
41
+ if (node.type === "BinaryExpression" && node.operator === "+") return true;
42
+ return false;
43
+ }
44
+
45
+ function containsIdentifier(node, re, depth = 0) {
46
+ if (!node || typeof node !== "object" || depth > 40) return false;
47
+ if (node.type === "Identifier" && re.test(node.name)) return true;
48
+ for (const key of Object.keys(node)) {
49
+ if (key === "loc" || key === "start" || key === "end" || key === "leadingComments" || key === "trailingComments") continue;
50
+ const v = node[key];
51
+ if (Array.isArray(v)) { for (const c of v) if (containsIdentifier(c, re, depth + 1)) return true; }
52
+ else if (v && typeof v.type === "string") { if (containsIdentifier(v, re, depth + 1)) return true; }
53
+ }
54
+ return false;
55
+ }
56
+
57
+ const USER_INPUT = /^(req|request|query|params|body|args|argv|input|userInput|ctx)$/;
58
+
59
+ module.exports = { memberName, calleeName, isDynamic, isStringBuilt, containsIdentifier, USER_INPUT };
@@ -0,0 +1,151 @@
1
+ // src/rules/js-rules.js
2
+ // Data-driven AST rules for JS/TS. Each rule declares the node types it cares
3
+ // about and a test() that returns true when the node is a violation. The engine
4
+ // traverses the file once and runs the matching rules.
5
+
6
+ const { memberName, calleeName, isDynamic, isStringBuilt, containsIdentifier, USER_INPUT } = require("./helpers");
7
+
8
+ const RULES = [
9
+ {
10
+ id: "js/eval-dynamic",
11
+ title: "Dynamic code execution via eval()",
12
+ severity: "critical",
13
+ nodeTypes: ["CallExpression"],
14
+ test: (n) => n.callee.type === "Identifier" && n.callee.name === "eval" && isDynamic(n.arguments[0]),
15
+ message: "eval() is called with a non-literal argument. If any part of it is user-controlled, an attacker can run arbitrary code.",
16
+ fix: "Remove eval(). Parse data with JSON.parse, use a lookup table, or call the intended function directly.",
17
+ },
18
+ {
19
+ id: "js/function-constructor",
20
+ title: "Dynamic code execution via Function constructor",
21
+ severity: "high",
22
+ nodeTypes: ["NewExpression", "CallExpression"],
23
+ test: (n) => n.callee.type === "Identifier" && n.callee.name === "Function" && n.arguments.length > 0,
24
+ message: "The Function constructor compiles a string into code at runtime — the same risk as eval().",
25
+ fix: "Avoid building functions from strings. Use a normal function or a safe, explicit dispatch.",
26
+ },
27
+ {
28
+ id: "js/command-injection",
29
+ title: "Possible command injection",
30
+ severity: "critical",
31
+ nodeTypes: ["CallExpression"],
32
+ test: (n) => {
33
+ const name = calleeName(n) || "";
34
+ const isExec = /(^|\.)(exec|execSync)$/.test(name);
35
+ return isExec && (isStringBuilt(n.arguments[0]) || (n.arguments[0] && n.arguments[0].type === "Identifier"));
36
+ },
37
+ message: "A shell command is built from dynamic input and passed to exec(). User input in a shell string allows command injection.",
38
+ fix: "Use execFile()/spawn() with an argument array (no shell), and never concatenate user input into the command.",
39
+ },
40
+ {
41
+ id: "js/sql-injection",
42
+ title: "Possible SQL injection",
43
+ severity: "high",
44
+ nodeTypes: ["CallExpression"],
45
+ test: (n) => {
46
+ const prop = n.callee.type === "MemberExpression" && !n.callee.computed && n.callee.property.name;
47
+ const isQuery = ["query", "execute", "raw", "prepare"].includes(prop);
48
+ return isQuery && isStringBuilt(n.arguments[0]);
49
+ },
50
+ message: "A SQL query is assembled by string interpolation/concatenation. Injected input can change the query's meaning.",
51
+ fix: "Use parameterised queries (placeholders + a values array), or your ORM's safe query builder. Never interpolate input into SQL.",
52
+ },
53
+ {
54
+ id: "js/innerhtml",
55
+ title: "Possible XSS via innerHTML",
56
+ severity: "high",
57
+ nodeTypes: ["AssignmentExpression"],
58
+ test: (n) =>
59
+ n.left.type === "MemberExpression" &&
60
+ !n.left.computed &&
61
+ ["innerHTML", "outerHTML"].includes(n.left.property.name) &&
62
+ isDynamic(n.right),
63
+ message: "Assigning dynamic content to innerHTML/outerHTML lets injected markup execute as script (XSS).",
64
+ fix: "Use textContent for text, or sanitise HTML with a library like DOMPurify before inserting it.",
65
+ },
66
+ {
67
+ id: "js/react-dangerous-html",
68
+ title: "Possible XSS via dangerouslySetInnerHTML",
69
+ severity: "high",
70
+ nodeTypes: ["JSXAttribute"],
71
+ test: (n) => n.name && n.name.name === "dangerouslySetInnerHTML",
72
+ message: "dangerouslySetInnerHTML bypasses React's built-in escaping. If the HTML contains user input, it's an XSS vector.",
73
+ fix: "Avoid it where possible. If you must render HTML, sanitise it first (e.g. DOMPurify).",
74
+ },
75
+ {
76
+ id: "js/weak-hash",
77
+ title: "Weak hashing algorithm",
78
+ severity: "medium",
79
+ nodeTypes: ["CallExpression"],
80
+ test: (n) => {
81
+ const name = calleeName(n) || "";
82
+ const a = n.arguments[0];
83
+ return /(^|\.)createHash$/.test(name) && a && a.type === "StringLiteral" && /^(md5|sha1)$/i.test(a.value);
84
+ },
85
+ message: "MD5 and SHA-1 are broken for security use (collisions, fast to brute-force).",
86
+ fix: "Use SHA-256+ for hashing, and a dedicated password hash (bcrypt, scrypt, or Argon2) for passwords.",
87
+ },
88
+ {
89
+ id: "js/tls-verification-disabled",
90
+ title: "TLS certificate validation disabled",
91
+ severity: "high",
92
+ nodeTypes: ["ObjectProperty"],
93
+ test: (n) =>
94
+ n.key && (n.key.name === "rejectUnauthorized" || n.key.value === "rejectUnauthorized") &&
95
+ n.value && n.value.type === "BooleanLiteral" && n.value.value === false,
96
+ message: "rejectUnauthorized: false turns off TLS certificate checks, exposing the connection to man-in-the-middle attacks.",
97
+ fix: "Remove this and use valid certificates. If testing locally, trust a local CA instead of disabling verification.",
98
+ },
99
+ {
100
+ id: "js/settimeout-string",
101
+ title: "String argument to setTimeout/setInterval",
102
+ severity: "medium",
103
+ nodeTypes: ["CallExpression"],
104
+ test: (n) => {
105
+ const name = n.callee.type === "Identifier" ? n.callee.name : null;
106
+ const a = n.arguments[0];
107
+ return ["setTimeout", "setInterval"].includes(name) && a && (a.type === "StringLiteral" || a.type === "TemplateLiteral");
108
+ },
109
+ message: "Passing a string to setTimeout/setInterval evaluates it like eval().",
110
+ fix: "Pass a function reference or arrow function instead of a string.",
111
+ },
112
+ {
113
+ id: "js/open-redirect",
114
+ title: "Possible open redirect",
115
+ severity: "medium",
116
+ nodeTypes: ["CallExpression"],
117
+ test: (n) => {
118
+ const name = calleeName(n) || "";
119
+ return /(^|\.)redirect$/.test(name) && n.arguments[0] && containsIdentifier(n.arguments[0], USER_INPUT);
120
+ },
121
+ message: "A redirect target is derived from user input. Attackers can redirect your users to phishing sites via your trusted domain.",
122
+ fix: "Redirect only to a fixed allow-list of paths, or map an identifier to a known URL server-side.",
123
+ },
124
+ {
125
+ id: "js/hardcoded-secret-fallback",
126
+ title: "Hardcoded secret fallback",
127
+ severity: "medium",
128
+ nodeTypes: ["LogicalExpression"],
129
+ test: (n) => {
130
+ if (n.operator !== "||") return false;
131
+ const left = memberName(n.left) || "";
132
+ const right = n.right;
133
+ return /process\.env\./.test(left) && right && right.type === "StringLiteral" && right.value.length >= 8;
134
+ },
135
+ message: "A secret falls back to a hardcoded literal when the env var is missing. That literal ships in your code.",
136
+ fix: "Fail loudly if the env var is missing instead of falling back to a real secret value.",
137
+ },
138
+ {
139
+ id: "js/permissive-cors",
140
+ title: "Permissive CORS (origin: '*')",
141
+ severity: "medium",
142
+ nodeTypes: ["ObjectProperty"],
143
+ test: (n) =>
144
+ n.key && (n.key.name === "origin" || n.key.value === "origin") &&
145
+ n.value && n.value.type === "StringLiteral" && n.value.value === "*",
146
+ message: "origin: '*' lets any website read responses from this API. On authenticated endpoints, that leaks user data.",
147
+ fix: "Allow-list specific trusted origins instead of using a wildcard, especially for endpoints that use credentials.",
148
+ },
149
+ ];
150
+
151
+ module.exports = { RULES };
package/src/scan.js ADDED
@@ -0,0 +1,75 @@
1
+ // src/scan.js — walk a directory, run the analyzers, grade the result.
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { analyzeJs } = require("./engine/js");
5
+ const { analyzeSecrets } = require("./engine/secrets");
6
+ const { analyzeDeps } = require("./engine/deps");
7
+
8
+ const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage", ".turbo", ".cache"]);
9
+ const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
10
+ const TEXT_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".env", ".yml", ".yaml", ".py", ".rb", ".go", ".php", ".java", ".txt", ".sh", ".config"]);
11
+ const MAX_FILE_BYTES = 1_500_000;
12
+
13
+ const SEV_WEIGHT = { critical: 25, high: 12, medium: 5, low: 1, info: 0 };
14
+ const SEV_ORDER = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
15
+
16
+ function walk(dir, files = []) {
17
+ let entries = [];
18
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
19
+ for (const e of entries) {
20
+ if (e.name.startsWith(".") && e.name !== ".env") { if (e.isDirectory()) continue; }
21
+ const full = path.join(dir, e.name);
22
+ if (e.isDirectory()) { if (!IGNORE_DIRS.has(e.name)) walk(full, files); }
23
+ else files.push(full);
24
+ }
25
+ return files;
26
+ }
27
+
28
+ function grade(findings) {
29
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
30
+ let penalty = 0;
31
+ for (const f of findings) { counts[f.severity] = (counts[f.severity] || 0) + 1; penalty += SEV_WEIGHT[f.severity] || 0; }
32
+ const score = Math.max(0, 100 - penalty);
33
+ const letter = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 55 ? "D" : "F";
34
+ return { score, letter, counts };
35
+ }
36
+
37
+ function scan(target, opts = {}) {
38
+ const root = path.resolve(target);
39
+ const stat = fs.existsSync(root) && fs.statSync(root);
40
+ if (!stat) throw new Error(`Path not found: ${root}`);
41
+
42
+ const files = stat.isDirectory() ? walk(root) : [root];
43
+ const findings = [];
44
+ let scanned = 0;
45
+
46
+ for (const file of files) {
47
+ const ext = path.extname(file).toLowerCase();
48
+ const base = path.basename(file);
49
+ let size = 0;
50
+ try { size = fs.statSync(file).size; } catch { continue; }
51
+ if (size > MAX_FILE_BYTES) continue;
52
+ if (!TEXT_EXT.has(ext) && base !== "package.json" && base !== ".env") continue;
53
+
54
+ let code = "";
55
+ try { code = fs.readFileSync(file, "utf8"); } catch { continue; }
56
+ scanned++;
57
+ const rel = path.relative(root, file) || base;
58
+
59
+ if (JS_EXT.has(ext)) for (const f of analyzeJs(code, rel)) findings.push(f);
60
+ if (base === "package.json") for (const f of analyzeDeps(code, rel)) findings.push(f);
61
+ if (opts.secrets !== false) for (const f of analyzeSecrets(code, rel)) findings.push(f);
62
+ }
63
+
64
+ // De-duplicate identical findings (same rule at the same spot).
65
+ const seen = new Set();
66
+ const unique = findings.filter((f) => { const k = `${f.ruleId}|${f.file}|${f.line}|${f.column}`; if (seen.has(k)) return false; seen.add(k); return true; });
67
+ findings.length = 0; findings.push(...unique);
68
+
69
+ // Sort worst-first, then by file/line.
70
+ findings.sort((a, b) => (SEV_ORDER[b.severity] - SEV_ORDER[a.severity]) || a.file.localeCompare(b.file) || a.line - b.line);
71
+
72
+ return { findings, filesScanned: scanned, filesTotal: files.length, grade: grade(findings), SEV_ORDER };
73
+ }
74
+
75
+ module.exports = { scan, grade, SEV_ORDER };
package/src/sync.js ADDED
@@ -0,0 +1,55 @@
1
+ // src/sync.js — push scan *results* (never source code) to the Safeey dashboard.
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { getConfig } = require("./config");
5
+
6
+ function projectName(target) {
7
+ const root = path.resolve(target);
8
+ try {
9
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
10
+ if (pkg.name) return pkg.name;
11
+ } catch { /* no package.json */ }
12
+ return path.basename(root) || "project";
13
+ }
14
+
15
+ // We deliberately send only metadata: rule id, severity, file path, line —
16
+ // enough to show the report in the dashboard. No source code ever leaves the machine.
17
+ async function syncResults(result, target) {
18
+ const { apiKey, apiUrl } = getConfig();
19
+ if (!apiKey) {
20
+ return { ok: false, reason: "Not logged in. Run `safeey login <api-key>` first (get a key from your Safeey dashboard)." };
21
+ }
22
+
23
+ const payload = {
24
+ project: projectName(target),
25
+ grade: result.grade.letter,
26
+ score: result.grade.score,
27
+ counts: result.grade.counts,
28
+ filesScanned: result.filesScanned,
29
+ findings: result.findings.map((f) => ({
30
+ ruleId: f.ruleId,
31
+ title: f.title,
32
+ severity: f.severity,
33
+ file: f.file,
34
+ line: f.line,
35
+ column: f.column,
36
+ })),
37
+ cliVersion: require("../package.json").version,
38
+ };
39
+
40
+ try {
41
+ const res = await fetch(`${apiUrl}/api/cli/ingest`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
44
+ body: JSON.stringify(payload),
45
+ });
46
+ if (res.status === 401) return { ok: false, reason: "Invalid or expired API key. Run `safeey login <api-key>` again." };
47
+ if (!res.ok) return { ok: false, reason: `Sync failed (HTTP ${res.status}).` };
48
+ const data = await res.json().catch(() => ({}));
49
+ return { ok: true, url: data.url };
50
+ } catch (e) {
51
+ return { ok: false, reason: `Could not reach ${apiUrl}: ${e.message}` };
52
+ }
53
+ }
54
+
55
+ module.exports = { syncResults };