safeey-cli 0.1.0 → 0.2.1

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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "safeey-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Scan your source code for security vulnerabilities — a Safeey product by Noohra Innovate Ltd.",
5
5
  "bin": {
6
- "safeey": "./bin/safeey.js"
6
+ "safeey": "bin/safeey.js"
7
7
  },
8
8
  "files": [
9
9
  "bin",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
32
- "url": "https://github.com/davolu/safeey-cli.git"
32
+ "url": "git+https://github.com/davolu/safeey-cli.git"
33
33
  },
34
34
  "homepage": "https://safeey.io",
35
35
  "bugs": {
@@ -0,0 +1,128 @@
1
+ // src/engine/gitignore.js
2
+ // Answers one question: "would git ignore this path?" We use it to decide
3
+ // whether secrets in a .env file matter. A secret in a *properly ignored* .env
4
+ // is that file doing its job. A secret in a .env that ISN'T ignored is a leak
5
+ // waiting to be committed — that's the finding worth raising.
6
+ //
7
+ // Primary strategy: shell out to `git check-ignore`, which implements the full
8
+ // gitignore spec (nesting, negation, anchoring, core.excludesFile) correctly so
9
+ // we don't have to. Fallback: a focused matcher for trees that aren't git repos
10
+ // (where "no .gitignore at all" correctly resolves to "not ignored" -> flag it).
11
+
12
+ const { execFileSync } = require("child_process");
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage"]);
17
+
18
+ function isGitRepo(root) {
19
+ try {
20
+ const out = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], {
21
+ encoding: "utf8",
22
+ stdio: ["ignore", "pipe", "ignore"],
23
+ });
24
+ return out.trim() === "true";
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ // Returns true (ignored), false (not ignored), or null (couldn't determine).
31
+ function gitCheckIgnore(root, relPath) {
32
+ try {
33
+ execFileSync("git", ["-C", root, "check-ignore", "-q", "--", relPath], { stdio: "ignore" });
34
+ return true; // exit 0 => path is ignored
35
+ } catch (e) {
36
+ if (e && e.status === 1) return false; // exit 1 => not ignored
37
+ return null; // 128 / git missing => unknown
38
+ }
39
+ }
40
+
41
+ // ---- Non-git fallback ------------------------------------------------------
42
+
43
+ // Convert a single gitignore pattern to { re, negate, dirOnly } relative to the
44
+ // directory the .gitignore lives in. Intentionally a pragmatic subset of the
45
+ // spec — enough to resolve the env-file cases the tool cares about.
46
+ function patternToMatcher(raw) {
47
+ let pat = raw.replace(/\r$/, "");
48
+ if (!pat || pat.startsWith("#")) return null;
49
+ // Trim unescaped trailing whitespace.
50
+ pat = pat.replace(/((?:[^\\ ]|\\ )*?)\s+$/, "$1");
51
+ if (!pat) return null;
52
+
53
+ let negate = false;
54
+ if (pat.startsWith("!")) { negate = true; pat = pat.slice(1); }
55
+
56
+ let dirOnly = false;
57
+ if (pat.endsWith("/")) { dirOnly = true; pat = pat.slice(0, -1); }
58
+
59
+ const anchored = pat.startsWith("/") || pat.includes("/");
60
+ if (pat.startsWith("/")) pat = pat.slice(1);
61
+
62
+ // Escape regex metachars, then re-enable glob semantics.
63
+ let re = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&");
64
+ re = re.replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
65
+
66
+ const body = anchored ? `^${re}(?:/.*)?$` : `(?:^|/)${re}(?:/.*)?$`;
67
+ return { re: new RegExp(body), negate, dirOnly };
68
+ }
69
+
70
+ function readGitignores(root) {
71
+ const found = []; // { dir, matchers: [...] }
72
+ (function walk(dir) {
73
+ let entries = [];
74
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
75
+ const gi = path.join(dir, ".gitignore");
76
+ if (fs.existsSync(gi)) {
77
+ try {
78
+ const matchers = fs.readFileSync(gi, "utf8").split(/\n/).map(patternToMatcher).filter(Boolean);
79
+ found.push({ dir, matchers });
80
+ } catch { /* unreadable — skip */ }
81
+ }
82
+ for (const e of entries) {
83
+ if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) walk(path.join(dir, e.name));
84
+ }
85
+ })(root);
86
+ return found;
87
+ }
88
+
89
+ function buildFallbackMatcher(root) {
90
+ const gitignores = readGitignores(root);
91
+ return function isIgnored(absPath) {
92
+ let decision = false;
93
+ for (const { dir, matchers } of gitignores) {
94
+ const rel = path.relative(dir, absPath);
95
+ if (!rel || rel.startsWith("..")) continue; // file not under this .gitignore
96
+ for (const m of matchers) {
97
+ if (m.re.test(rel)) decision = !m.negate;
98
+ }
99
+ }
100
+ return decision;
101
+ };
102
+ }
103
+
104
+ // ---- Public API ------------------------------------------------------------
105
+
106
+ function makeIgnoreChecker(root) {
107
+ const git = isGitRepo(root);
108
+ const fallback = git ? null : buildFallbackMatcher(root);
109
+ const cache = new Map();
110
+ return {
111
+ mode: git ? "git" : "fallback",
112
+ // true | false | null(unknown, git-mode only)
113
+ isIgnored(absPath) {
114
+ if (cache.has(absPath)) return cache.get(absPath);
115
+ let res;
116
+ if (git) {
117
+ const rel = path.relative(root, absPath) || path.basename(absPath);
118
+ res = gitCheckIgnore(root, rel);
119
+ } else {
120
+ res = fallback(absPath);
121
+ }
122
+ cache.set(absPath, res);
123
+ return res;
124
+ },
125
+ };
126
+ }
127
+
128
+ module.exports = { makeIgnoreChecker };
@@ -0,0 +1,69 @@
1
+ // src/engine/patterns.js — run line-based regex rules against a file.
2
+ // Complements the AST engine: covers languages we don't parse, and cheap
3
+ // breadth for JS/TS. Precision is lower, so callers can filter by confidence.
4
+
5
+ const { normalizeAll } = require("../rules/schema");
6
+ const { PATTERN_RULES } = require("../rules/patterns");
7
+
8
+ const RULES = normalizeAll(PATTERN_RULES, "pattern");
9
+
10
+ // Map file extension -> language tag used in rule.languages.
11
+ const EXT_LANG = {
12
+ ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
13
+ ".ts": "ts", ".tsx": "ts",
14
+ ".py": "py", ".go": "go", ".php": "php", ".rb": "rb", ".java": "java",
15
+ };
16
+
17
+ function langForExt(ext) {
18
+ return EXT_LANG[ext] || null;
19
+ }
20
+
21
+ function ruleAppliesTo(rule, lang) {
22
+ if (rule.languages.includes("*")) return true;
23
+ return lang != null && rule.languages.includes(lang);
24
+ }
25
+
26
+ // Skip lines that are clearly comments in common languages, to cut noise on
27
+ // rules that would otherwise match documentation/examples. Cheap heuristic.
28
+ function isCommentLine(line) {
29
+ const t = line.trimStart();
30
+ return t.startsWith("//") || t.startsWith("#") || t.startsWith("*") || t.startsWith("/*");
31
+ }
32
+
33
+ function analyzePatterns(code, file, ext) {
34
+ const lang = langForExt(ext);
35
+ const applicable = RULES.filter((r) => ruleAppliesTo(r, lang));
36
+ if (applicable.length === 0) return [];
37
+
38
+ const findings = [];
39
+ const lines = code.split(/\r?\n/);
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i];
42
+ if (line.length > 1000) continue;
43
+ const comment = isCommentLine(line);
44
+ for (const rule of applicable) {
45
+ // Let a rule opt into scanning comment lines (e.g. secrets); default skip.
46
+ if (comment && !rule.scanComments) continue;
47
+ const m = rule.re.exec(line);
48
+ if (!m) continue;
49
+ if (rule.unless && rule.unless.test(line)) continue;
50
+ findings.push({
51
+ ruleId: rule.id,
52
+ title: rule.title,
53
+ severity: rule.severity,
54
+ confidence: rule.confidence,
55
+ category: rule.category,
56
+ cwe: rule.cwe,
57
+ owasp: rule.owasp,
58
+ file,
59
+ line: i + 1,
60
+ column: (m.index ?? 0) + 1,
61
+ message: rule.message,
62
+ fix: rule.fix,
63
+ });
64
+ }
65
+ }
66
+ return findings;
67
+ }
68
+
69
+ module.exports = { analyzePatterns, langForExt, PATTERN_RULE_COUNT: RULES.length };
@@ -1,31 +1,75 @@
1
1
  // src/engine/secrets.js — regex-based secret detection, language-agnostic.
2
+ //
3
+ // Secret patterns are the one tier where breadth scales cleanly: provider key
4
+ // formats are specific enough that adding a pattern rarely adds false positives.
5
+ // Each rule is pure data — append to PROVIDER_SECRETS to grow coverage.
6
+ //
7
+ // Every finding is masked in output; we never echo a full secret back.
2
8
 
3
- const SECRET_RULES = [
4
- { id: "secret/aws-access-key", title: "AWS access key ID", severity: "high", re: /\bAKIA[0-9A-Z]{16}\b/ },
9
+ // Well-known, high-specificity provider formats. High confidence: the shape is
10
+ // distinctive enough that a match is almost always a real key.
11
+ const PROVIDER_SECRETS = [
12
+ { id: "secret/aws-access-key", title: "AWS access key ID", severity: "high", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
5
13
  { 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/ },
14
+ { id: "secret/gcp-service-account", title: "GCP service account key", severity: "critical", re: /"type"\s*:\s*"service_account"/ },
9
15
  { id: "secret/google-api", title: "Google API key", severity: "high", re: /\bAIza[0-9A-Za-z_\-]{35}\b/ },
16
+ { id: "secret/google-oauth", title: "Google OAuth client secret", severity: "high", re: /\bGOCSPX-[0-9A-Za-z_\-]{28}\b/ },
17
+ { id: "secret/private-key", title: "Private key", severity: "critical", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
18
+ { id: "secret/stripe-live", title: "Stripe live secret key", severity: "critical", re: /\b[rs]k_live_[0-9a-zA-Z]{20,}\b/ },
19
+ { id: "secret/stripe-test", title: "Stripe test secret key", severity: "medium", re: /\b[rs]k_test_[0-9a-zA-Z]{20,}\b/ },
10
20
  { id: "secret/github-token", title: "GitHub token", severity: "high", re: /\bgh[pousr]_[0-9A-Za-z]{36,}\b/ },
21
+ { id: "secret/github-fine-grained", title: "GitHub fine-grained PAT", severity: "high", re: /\bgithub_pat_[0-9A-Za-z_]{22,}\b/ },
22
+ { id: "secret/gitlab-token", title: "GitLab personal access token", severity: "high", re: /\bglpat-[0-9A-Za-z_\-]{20,}\b/ },
11
23
  { 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/ },
24
+ { id: "secret/slack-webhook", title: "Slack webhook URL", severity: "medium", re: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_]+\/B[0-9A-Za-z_]+\/[0-9A-Za-z_]+/ },
25
+ { id: "secret/openai", title: "OpenAI API key", severity: "high", re: /\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}T3BlbkFJ[A-Za-z0-9_\-]{20,}\b/ },
26
+ { id: "secret/anthropic", title: "Anthropic API key", severity: "high", re: /\bsk-ant-[0-9A-Za-z_\-]{20,}\b/ },
27
+ { id: "secret/sendgrid", title: "SendGrid API key", severity: "high", re: /\bSG\.[0-9A-Za-z_\-]{22}\.[0-9A-Za-z_\-]{43}\b/ },
28
+ { id: "secret/twilio", title: "Twilio API key", severity: "high", re: /\bSK[0-9a-fA-F]{32}\b/ },
29
+ { id: "secret/twilio-account-sid", title: "Twilio account SID", severity: "medium", re: /\bAC[0-9a-fA-F]{32}\b/ },
30
+ { id: "secret/mailgun", title: "Mailgun API key", severity: "high", re: /\bkey-[0-9a-fA-F]{32}\b/ },
31
+ { id: "secret/npm-token", title: "npm access token", severity: "high", re: /\bnpm_[0-9A-Za-z]{36}\b/ },
32
+ { id: "secret/pypi-token", title: "PyPI upload token", severity: "high", re: /\bpypi-AgEIcHlwaS5vcmc[0-9A-Za-z_\-]{50,}\b/ },
33
+ { id: "secret/digitalocean", title: "DigitalOcean personal token", severity: "high", re: /\bdop_v1_[0-9a-f]{64}\b/ },
34
+ { id: "secret/square", title: "Square access token", severity: "high", re: /\b(?:sq0atp|EAAA)[0-9A-Za-z_\-]{22,}\b/ },
35
+ { id: "secret/shopify", title: "Shopify access token", severity: "high", re: /\bshp(?:at|ca|pa|ss)_[0-9a-fA-F]{32}\b/ },
36
+ { id: "secret/db-url-password", title: "Database URL with inline password", severity: "high", re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^:@\s]+:[^@\s]+@/ },
13
37
  { 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
38
  ];
21
39
 
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;
40
+ // Generic, lower-confidence catch-all. Guarded by IGNORE to reduce noise.
41
+ const GENERIC_SECRET = {
42
+ id: "secret/generic-assignment",
43
+ title: "Hardcoded credential",
44
+ severity: "high",
45
+ confidence: "low",
46
+ re: /(?:api[_-]?key|secret|passwd|password|token|client[_-]?secret|access[_-]?token|private[_-]?key|auth)\s*[:=]\s*['"][A-Za-z0-9_\-.\/+=]{12,}['"]/i,
47
+ };
48
+
49
+ // Lines we shouldn't flag for the generic rule: placeholders and env references.
50
+ const IGNORE = /(process\.env|import\.meta\.env|os\.environ|getenv|example|placeholder|dummy|your[_-]?(key|token|secret)|xxxx+|<[^>]+>|\$\{|\{\{)/i;
24
51
 
25
52
  function mask(s) {
26
53
  const t = s.replace(/['"]/g, "");
27
54
  if (t.length <= 10) return t.slice(0, 3) + "****";
28
- return t.slice(0, 4) + "" + t.slice(-3);
55
+ return t.slice(0, 4) + "\u2026" + t.slice(-3);
56
+ }
57
+
58
+ function makeFinding(rule, file, lineNo, m) {
59
+ return {
60
+ ruleId: rule.id,
61
+ title: rule.title,
62
+ severity: rule.severity,
63
+ confidence: rule.confidence || "high",
64
+ category: "secrets",
65
+ cwe: "CWE-798",
66
+ owasp: "A07:2021 Identification and Authentication Failures",
67
+ file,
68
+ line: lineNo,
69
+ column: (m.index ?? 0) + 1,
70
+ message: `A ${rule.title.toLowerCase()} appears to be hardcoded in source (${mask(m[0])}). Anything committed to code is effectively public.`,
71
+ fix: "Move the value to an environment variable or secret manager, remove it from code and git history, and rotate the credential.",
72
+ };
29
73
  }
30
74
 
31
75
  function analyzeSecrets(code, file) {
@@ -33,25 +77,23 @@ function analyzeSecrets(code, file) {
33
77
  const lines = code.split(/\r?\n/);
34
78
  for (let i = 0; i < lines.length; i++) {
35
79
  const line = lines[i];
36
- if (line.length > 500) continue;
37
- for (const rule of SECRET_RULES) {
80
+ if (line.length > 1000) continue;
81
+
82
+ let matchedProvider = false;
83
+ for (const rule of PROVIDER_SECRETS) {
38
84
  const m = rule.re.exec(line);
39
85
  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
86
+ findings.push(makeFinding(rule, file, i + 1, m));
87
+ matchedProvider = true;
88
+ break; // one high-confidence provider hit per line is enough
89
+ }
90
+
91
+ if (!matchedProvider) {
92
+ const m = GENERIC_SECRET.re.exec(line);
93
+ if (m && !IGNORE.test(line)) findings.push(makeFinding(GENERIC_SECRET, file, i + 1, m));
52
94
  }
53
95
  }
54
96
  return findings;
55
97
  }
56
98
 
57
- module.exports = { analyzeSecrets };
99
+ module.exports = { analyzeSecrets, SECRET_RULE_COUNT: PROVIDER_SECRETS.length + 1 };
@@ -0,0 +1,164 @@
1
+ // src/rules/patterns.js
2
+ // Regex/source-pattern rules. These are line-based and language-tagged. They're
3
+ // deliberately the "cheap" tier: lower precision than AST rules, but each one is
4
+ // pure data, so this is where breadth grows fastest. Keep each rule tight and
5
+ // give it an `unless` guard where obvious false positives exist.
6
+ //
7
+ // Precision guidance for anyone adding rules here:
8
+ // - Prefer specific, anchored patterns over vague ones.
9
+ // - Set confidence:"low" for heuristic rules so they can be down-weighted.
10
+ // - Add an `unless` regex to skip comments/placeholders/test files where you can.
11
+
12
+ const PATTERN_RULES = [
13
+ // ---- Injection ----------------------------------------------------------
14
+ {
15
+ id: "pattern/python-os-system", title: "Command execution via os.system()",
16
+ severity: "high", confidence: "medium", category: "injection", cwe: "CWE-78",
17
+ languages: ["py"], re: /\bos\.system\s*\(/,
18
+ message: "os.system() runs a string through the shell; dynamic input here allows command injection.",
19
+ fix: "Use subprocess.run([...], shell=False) with an argument list, never a shell string built from input.",
20
+ },
21
+ {
22
+ id: "pattern/python-subprocess-shell", title: "subprocess with shell=True",
23
+ severity: "high", confidence: "medium", category: "injection", cwe: "CWE-78",
24
+ languages: ["py"], re: /subprocess\.(?:run|call|Popen|check_output|check_call)\s*\([^)]*shell\s*=\s*True/,
25
+ message: "shell=True runs the command through a shell; combined with dynamic input this is command injection.",
26
+ fix: "Drop shell=True and pass args as a list. If you must use a shell, never interpolate untrusted input.",
27
+ },
28
+ {
29
+ id: "pattern/python-yaml-load", title: "Unsafe yaml.load()",
30
+ severity: "high", confidence: "high", category: "deserialization", cwe: "CWE-502",
31
+ languages: ["py"], re: /\byaml\.load\s*\((?![^)]*Loader\s*=\s*yaml\.SafeLoader)/,
32
+ message: "yaml.load() without SafeLoader can construct arbitrary Python objects from input.",
33
+ fix: "Use yaml.safe_load(), or pass Loader=yaml.SafeLoader explicitly.",
34
+ },
35
+ {
36
+ id: "pattern/python-pickle-loads", title: "Unsafe pickle deserialization",
37
+ severity: "high", confidence: "medium", category: "deserialization", cwe: "CWE-502",
38
+ languages: ["py"], re: /\bpickle\.(?:load|loads)\s*\(/,
39
+ message: "pickle can execute arbitrary code during deserialization of untrusted data.",
40
+ fix: "Never unpickle untrusted data. Use JSON or a safe schema-validated format.",
41
+ },
42
+ {
43
+ id: "pattern/python-eval-exec", title: "Dynamic code execution (eval/exec)",
44
+ severity: "critical", confidence: "medium", category: "injection", cwe: "CWE-95",
45
+ languages: ["py"], re: /\b(?:eval|exec)\s*\(/, unless: /#.*\b(eval|exec)\b/,
46
+ message: "eval()/exec() run arbitrary Python; any user-controlled part is remote code execution.",
47
+ fix: "Remove eval/exec. Use ast.literal_eval for data, or an explicit dispatch table.",
48
+ },
49
+ {
50
+ id: "pattern/go-exec-command", title: "os/exec with dynamic command",
51
+ severity: "medium", confidence: "low", category: "injection", cwe: "CWE-78",
52
+ languages: ["go"], re: /exec\.Command\s*\(\s*"(?:sh|bash|cmd|powershell)"/,
53
+ message: "Spawning a shell via exec.Command lets any interpolated input become a shell command.",
54
+ fix: "Call the target binary directly with separate args; avoid invoking a shell.",
55
+ },
56
+ {
57
+ id: "pattern/php-system-exec", title: "PHP shell execution",
58
+ severity: "high", confidence: "medium", category: "injection", cwe: "CWE-78",
59
+ languages: ["php"], re: /\b(?:system|exec|shell_exec|passthru|popen|proc_open)\s*\(/,
60
+ message: "PHP shell-execution functions run OS commands; dynamic input is command injection.",
61
+ fix: "Avoid shell calls. If unavoidable, use escapeshellarg() on every argument.",
62
+ },
63
+
64
+ // ---- Crypto / randomness ------------------------------------------------
65
+ {
66
+ id: "pattern/math-random-token", title: "Insecure randomness for security value",
67
+ severity: "medium", confidence: "low", category: "crypto", cwe: "CWE-338",
68
+ languages: ["js", "ts"], re: /Math\.random\s*\(\s*\).{0,40}(token|secret|password|otp|nonce|salt|session|api[_-]?key)/i,
69
+ message: "Math.random() is not cryptographically secure and must not be used to generate secrets/tokens.",
70
+ fix: "Use crypto.randomBytes()/crypto.randomUUID() (Node) or crypto.getRandomValues() (browser).",
71
+ },
72
+ {
73
+ id: "pattern/python-random-token", title: "Insecure randomness for security value",
74
+ severity: "medium", confidence: "low", category: "crypto", cwe: "CWE-338",
75
+ languages: ["py"], re: /\brandom\.(?:random|randint|choice|choices)\s*\(.{0,40}(token|secret|password|otp|nonce|salt|session)/i,
76
+ message: "The `random` module is not cryptographically secure; don't use it for secrets or tokens.",
77
+ fix: "Use the `secrets` module (secrets.token_hex, secrets.choice) for security-sensitive values.",
78
+ },
79
+ {
80
+ id: "pattern/insecure-cipher-des", title: "Weak cipher (DES/RC4/3DES)",
81
+ severity: "medium", confidence: "medium", category: "crypto", cwe: "CWE-327",
82
+ languages: ["*"], re: /\b(?:DES|DES-EDE3|RC4|3DES|Blowfish)\b/,
83
+ message: "DES, 3DES, RC4 and Blowfish are considered broken or weak for modern use.",
84
+ fix: "Use AES-256-GCM (or ChaCha20-Poly1305) for symmetric encryption.",
85
+ },
86
+ {
87
+ id: "pattern/jwt-alg-none", title: "JWT 'none' algorithm accepted",
88
+ severity: "high", confidence: "medium", category: "auth", cwe: "CWE-347",
89
+ languages: ["*"], re: /(?:algorithms?|alg)\s*[:=]\s*\[?\s*['"]none['"]/i,
90
+ message: "Accepting the 'none' JWT algorithm lets attackers forge tokens with no signature.",
91
+ fix: "Pin an explicit allow-list of signing algorithms (e.g. ['HS256'] or ['RS256']); never allow 'none'.",
92
+ },
93
+
94
+ // ---- Auth / cookies / config -------------------------------------------
95
+ {
96
+ id: "pattern/cookie-insecure-flags", title: "Cookie without httpOnly/secure",
97
+ severity: "low", confidence: "low", category: "auth", cwe: "CWE-1004",
98
+ languages: ["js", "ts"], re: /httpOnly\s*:\s*false/i,
99
+ message: "httpOnly:false lets client-side JS read the cookie, exposing it to XSS token theft.",
100
+ fix: "Set httpOnly:true and secure:true on session/auth cookies; add sameSite where appropriate.",
101
+ },
102
+ {
103
+ id: "pattern/csrf-disabled", title: "CSRF protection disabled",
104
+ severity: "medium", confidence: "low", category: "auth", cwe: "CWE-352",
105
+ languages: ["*"], re: /csrf\s*[:=]\s*(?:false|off|None)|@csrf_exempt/i,
106
+ message: "CSRF protection appears to be turned off on a state-changing endpoint.",
107
+ fix: "Keep CSRF protection on for cookie-authenticated state-changing routes; exempt only pure APIs using tokens.",
108
+ },
109
+ {
110
+ id: "pattern/debug-true-prod", title: "Debug mode enabled",
111
+ severity: "low", confidence: "low", category: "config", cwe: "CWE-489",
112
+ languages: ["py"], re: /DEBUG\s*=\s*True/,
113
+ message: "DEBUG=True leaks stack traces, settings and source to users in production.",
114
+ fix: "Drive DEBUG from an environment variable and default it to False.",
115
+ },
116
+ {
117
+ id: "pattern/flask-run-debug", title: "Flask running with debug=True",
118
+ severity: "medium", confidence: "medium", category: "config", cwe: "CWE-489",
119
+ languages: ["py"], re: /\.run\s*\([^)]*debug\s*=\s*True/,
120
+ message: "Flask debug mode exposes the interactive Werkzeug debugger, which can run code.",
121
+ fix: "Never run with debug=True in production; gate it behind an env var.",
122
+ },
123
+ {
124
+ id: "pattern/bind-all-interfaces", title: "Service bound to 0.0.0.0",
125
+ severity: "info", confidence: "low", category: "config", cwe: "CWE-668",
126
+ languages: ["*"], re: /['"]0\.0\.0\.0['"]/,
127
+ message: "Binding to 0.0.0.0 exposes the service on all network interfaces — intentional for containers, risky on hosts.",
128
+ fix: "Bind to 127.0.0.1 for local-only services; only use 0.0.0.0 when you mean to expose the port.",
129
+ },
130
+
131
+ // ---- Path traversal / SSRF ---------------------------------------------
132
+ {
133
+ id: "pattern/path-join-userinput", title: "Possible path traversal",
134
+ severity: "medium", confidence: "low", category: "path", cwe: "CWE-22",
135
+ languages: ["js", "ts"], re: /path\.join\s*\([^)]*\b(req|request|params|query|body|input)\b/,
136
+ message: "A filesystem path is built from request data; '../' sequences can escape the intended directory.",
137
+ fix: "Resolve the path and verify it stays within an allowed base dir (path.resolve + startsWith check); reject '..'.",
138
+ },
139
+ {
140
+ id: "pattern/open-userinput", title: "File opened from user input",
141
+ severity: "medium", confidence: "low", category: "path", cwe: "CWE-22",
142
+ languages: ["py"], re: /\bopen\s*\(\s*(?:request|req|input|params|args)\b/,
143
+ message: "Opening a file whose path comes from user input allows arbitrary file read/write via traversal.",
144
+ fix: "Validate against an allow-list and confirm the resolved path stays under an intended base directory.",
145
+ },
146
+
147
+ // ---- Misc dangerous shapes ---------------------------------------------
148
+ {
149
+ id: "pattern/document-write", title: "document.write with dynamic content",
150
+ severity: "medium", confidence: "low", category: "xss", cwe: "CWE-79",
151
+ languages: ["js", "ts"], re: /document\.write(?:ln)?\s*\(\s*(?!['"`]\s*<)/,
152
+ message: "document.write can inject unescaped markup; with dynamic input it's an XSS sink.",
153
+ fix: "Build DOM nodes and set textContent, or sanitise HTML before insertion.",
154
+ },
155
+ {
156
+ id: "pattern/http-url", title: "Cleartext HTTP endpoint",
157
+ severity: "low", confidence: "low", category: "config", cwe: "CWE-319",
158
+ languages: ["*"], re: /['"]http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0)[a-z0-9.-]+/i,
159
+ message: "A cleartext http:// endpoint transmits data unencrypted and is open to interception/tampering.",
160
+ fix: "Use https:// for anything leaving the machine.",
161
+ },
162
+ ];
163
+
164
+ module.exports = { PATTERN_RULES };
@@ -0,0 +1,111 @@
1
+ // src/rules/schema.js
2
+ // A single, richer rule shape shared by every rule source (AST rules, regex
3
+ // pattern rules, secret rules). Normalizing here means the engines and the
4
+ // report renderer only ever deal with one consistent object, and adding a rule
5
+ // is mostly filling in *data*.
6
+ //
7
+ // This is what lets us scale coverage "little by little" without the code
8
+ // sprawling: new rules are entries, not new engine logic.
9
+
10
+ const SEVERITIES = ["critical", "high", "medium", "low", "info"];
11
+
12
+ // Detection confidence. Lower-confidence rules can be surfaced separately or
13
+ // down-weighted so we can add broad/heuristic rules without wrecking signal.
14
+ const CONFIDENCE = ["high", "medium", "low"];
15
+
16
+ // Coarse categories keep the growing rule set browsable and let us ship/skip
17
+ // whole packs (e.g. --only injection, or --skip style).
18
+ const CATEGORIES = [
19
+ "injection", // SQL/command/code/template/LDAP/NoSQL injection
20
+ "xss", // cross-site scripting sinks
21
+ "crypto", // weak hashing, ciphers, insecure randomness
22
+ "secrets", // hardcoded credentials / keys / tokens
23
+ "auth", // authn/authz mistakes, session/cookie flags
24
+ "deserialization", // unsafe deserialization / prototype pollution
25
+ "ssrf", // server-side request forgery
26
+ "path", // path traversal / arbitrary file access
27
+ "config", // insecure config, TLS off, permissive CORS
28
+ "dos", // ReDoS, unbounded work
29
+ "dependencies", // vulnerable / unpinned packages
30
+ "supply-chain", // install scripts, typosquat shapes
31
+ ];
32
+
33
+ // Map every category to a sensible default OWASP Top 10 (2021) bucket so users
34
+ // get standards mapping for free even before we tag rules individually.
35
+ const OWASP_BY_CATEGORY = {
36
+ injection: "A03:2021 Injection",
37
+ xss: "A03:2021 Injection",
38
+ crypto: "A02:2021 Cryptographic Failures",
39
+ secrets: "A07:2021 Identification and Authentication Failures",
40
+ auth: "A07:2021 Identification and Authentication Failures",
41
+ deserialization: "A08:2021 Software and Data Integrity Failures",
42
+ ssrf: "A10:2021 Server-Side Request Forgery",
43
+ path: "A01:2021 Broken Access Control",
44
+ config: "A05:2021 Security Misconfiguration",
45
+ dos: "A05:2021 Security Misconfiguration",
46
+ dependencies: "A06:2021 Vulnerable and Outdated Components",
47
+ "supply-chain": "A08:2021 Software and Data Integrity Failures",
48
+ };
49
+
50
+ function assert(cond, msg) {
51
+ if (!cond) throw new Error(`Invalid rule: ${msg}`);
52
+ }
53
+
54
+ // Fill in defaults and validate. Called once per rule at load time, so a
55
+ // malformed rule fails loudly during development rather than silently at scan
56
+ // time. Returns a frozen, fully-populated rule.
57
+ function normalizeRule(rule, sourceKind) {
58
+ assert(rule && typeof rule === "object", "not an object");
59
+ assert(typeof rule.id === "string" && rule.id, `missing id (${JSON.stringify(rule)})`);
60
+ assert(SEVERITIES.includes(rule.severity), `${rule.id}: bad severity "${rule.severity}"`);
61
+ assert(typeof rule.title === "string" && rule.title, `${rule.id}: missing title`);
62
+
63
+ const category = rule.category || inferCategory(rule.id);
64
+ assert(CATEGORIES.includes(category), `${rule.id}: unknown category "${category}"`);
65
+
66
+ return Object.freeze({
67
+ id: rule.id,
68
+ title: rule.title,
69
+ severity: rule.severity,
70
+ confidence: rule.confidence && CONFIDENCE.includes(rule.confidence) ? rule.confidence : "medium",
71
+ category,
72
+ // engine kind: how this rule is evaluated ("ast" | "pattern" | "secret" | "deps")
73
+ kind: rule.kind || sourceKind || "pattern",
74
+ cwe: rule.cwe || null, // e.g. "CWE-89"
75
+ owasp: rule.owasp || OWASP_BY_CATEGORY[category] || null,
76
+ languages: rule.languages || ["*"], // ["js","ts"] or ["*"]
77
+ message: rule.message || rule.title,
78
+ fix: rule.fix || "Review this finding and remediate per your security guidelines.",
79
+ references: rule.references || [],
80
+ // engine-specific payload, passed through untouched:
81
+ nodeTypes: rule.nodeTypes, // AST rules
82
+ test: rule.test, // AST rules
83
+ re: rule.re, // pattern/secret rules
84
+ unless: rule.unless, // optional regex: skip line if it also matches
85
+ });
86
+ }
87
+
88
+ function inferCategory(id) {
89
+ const head = String(id).split("/")[0];
90
+ if (CATEGORIES.includes(head)) return head;
91
+ // common prefixes we actually use
92
+ if (head === "js" || head === "py" || head === "go") return "injection"; // fallback; real rules should set category
93
+ if (head === "secret") return "secrets";
94
+ if (head === "deps") return "dependencies";
95
+ return "config";
96
+ }
97
+
98
+ // Normalize a whole array, surfacing the first bad rule with a clear message.
99
+ function normalizeAll(rules, sourceKind) {
100
+ const out = [];
101
+ const seen = new Set();
102
+ for (const r of rules) {
103
+ const n = normalizeRule(r, sourceKind);
104
+ if (seen.has(n.id)) throw new Error(`Duplicate rule id: ${n.id}`);
105
+ seen.add(n.id);
106
+ out.push(n);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ module.exports = { SEVERITIES, CONFIDENCE, CATEGORIES, OWASP_BY_CATEGORY, normalizeRule, normalizeAll };
package/src/scan.js CHANGED
@@ -4,6 +4,45 @@ const path = require("path");
4
4
  const { analyzeJs } = require("./engine/js");
5
5
  const { analyzeSecrets } = require("./engine/secrets");
6
6
  const { analyzeDeps } = require("./engine/deps");
7
+ const { analyzePatterns } = require("./engine/patterns");
8
+ const { makeIgnoreChecker } = require("./engine/gitignore");
9
+
10
+ // Any .env-style file. `.env.example` / `.env.sample` / `.env.template` / .dist
11
+ // are meant to be committed and hold placeholders, so they're scanned normally
12
+ // (a *real* secret in one of those IS a finding). The bare/real env files are
13
+ // the ones whose secrets we suppress-if-ignored, flag-if-not.
14
+ const ENV_RE = /^\.env(\.|$)/;
15
+ const ENV_EXAMPLE_RE = /(example|sample|template|dist|defaults)/i;
16
+
17
+ function classifyEnvFile(base) {
18
+ if (!ENV_RE.test(base)) return null;
19
+ return ENV_EXAMPLE_RE.test(base) ? "example" : "real";
20
+ }
21
+
22
+ // Does the file actually contain KEY=value assignments with a value? Keeps us
23
+ // from flagging an empty/placeholder .env.
24
+ function hasEnvAssignment(code) {
25
+ return /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*\S/m.test(code);
26
+ }
27
+
28
+ function envNotIgnoredFinding(rel, hasSecret, unknown) {
29
+ return {
30
+ ruleId: "config/env-not-ignored",
31
+ title: "Environment file not covered by .gitignore",
32
+ severity: hasSecret ? "high" : "medium",
33
+ confidence: "medium",
34
+ category: "secrets",
35
+ cwe: "CWE-538",
36
+ owasp: "A05:2021 Security Misconfiguration",
37
+ file: rel,
38
+ line: 0,
39
+ column: 0,
40
+ message: unknown
41
+ ? `${rel} holds environment values and no .gitignore was found to protect it. If this directory is (or becomes) a git repo, the file will be committed and any credentials in it leaked.`
42
+ : `${rel} holds environment values but isn't matched by any .gitignore rule, so it can be committed to version control and its credentials leaked.`,
43
+ fix: "Add a matching rule to .gitignore (e.g. `.env` or `.env*`, keeping an explicit `!.env.example`). If the file was already committed, rotate the credentials and purge it from git history (e.g. git filter-repo).",
44
+ };
45
+ }
7
46
 
8
47
  const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage", ".turbo", ".cache"]);
9
48
  const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
@@ -43,22 +82,43 @@ function scan(target, opts = {}) {
43
82
  const findings = [];
44
83
  let scanned = 0;
45
84
 
85
+ // Used to decide whether a .env's secrets are safely ignored or exposed.
86
+ const ignore = makeIgnoreChecker(root);
87
+
46
88
  for (const file of files) {
47
89
  const ext = path.extname(file).toLowerCase();
48
90
  const base = path.basename(file);
49
91
  let size = 0;
50
92
  try { size = fs.statSync(file).size; } catch { continue; }
51
93
  if (size > MAX_FILE_BYTES) continue;
52
- if (!TEXT_EXT.has(ext) && base !== "package.json" && base !== ".env") continue;
94
+ const envClass = classifyEnvFile(base);
95
+ if (!TEXT_EXT.has(ext) && base !== "package.json" && !envClass) continue;
53
96
 
54
97
  let code = "";
55
98
  try { code = fs.readFileSync(file, "utf8"); } catch { continue; }
56
99
  scanned++;
57
100
  const rel = path.relative(root, file) || base;
58
101
 
102
+ // A real .env file is *meant* to hold secrets. The question isn't "are
103
+ // there secrets in it" but "is it protected from being committed". So we
104
+ // suppress its secret findings when it's properly ignored, and raise a
105
+ // single clear finding when it isn't.
106
+ if (envClass === "real") {
107
+ const secretFindings = opts.secrets !== false ? analyzeSecrets(code, rel) : [];
108
+ const ignored = ignore.isIgnored(file); // true | false | null(unknown)
109
+ if (ignored !== true && hasEnvAssignment(code)) {
110
+ findings.push(envNotIgnoredFinding(rel, secretFindings.length > 0, ignored === null));
111
+ for (const f of secretFindings) findings.push(f); // exposure is real — show what's inside
112
+ }
113
+ // ignored === true -> secrets stay local and protected; report nothing.
114
+ continue;
115
+ }
116
+
59
117
  if (JS_EXT.has(ext)) for (const f of analyzeJs(code, rel)) findings.push(f);
60
118
  if (base === "package.json") for (const f of analyzeDeps(code, rel)) findings.push(f);
61
119
  if (opts.secrets !== false) for (const f of analyzeSecrets(code, rel)) findings.push(f);
120
+ // Regex/source-pattern rules run on every text file, gated by language tag.
121
+ for (const f of analyzePatterns(code, rel, ext)) findings.push(f);
62
122
  }
63
123
 
64
124
  // De-duplicate identical findings (same rule at the same spot).