safeey-cli 0.2.1 → 0.6.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/bin/safeey.js +174 -20
- package/package.json +2 -2
- package/src/auth.js +232 -0
- package/src/baseline.js +42 -0
- package/src/config.js +1 -0
- package/src/engine/githistory.js +84 -0
- package/src/engine/infra.js +46 -0
- package/src/engine/osv.js +173 -0
- package/src/engine/patterns.js +2 -1
- package/src/engine/secrets.js +37 -1
- package/src/engine/suppress.js +50 -0
- package/src/engine/taint.js +182 -0
- package/src/fix.js +116 -0
- package/src/report-sarif.js +67 -0
- package/src/rules/framework.js +102 -0
- package/src/rules/infra.js +120 -0
- package/src/rules/js-rules.js +114 -0
- package/src/scan.js +88 -29
- package/src/spinner.js +97 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/report-sarif.js — emit findings as SARIF 2.1.0.
|
|
2
|
+
// SARIF is the format GitHub code scanning ingests, so `safeey scan . --sarif`
|
|
3
|
+
// can be uploaded in CI and findings appear inline on PRs in the Security tab.
|
|
4
|
+
|
|
5
|
+
const pkg = require("../package.json");
|
|
6
|
+
|
|
7
|
+
const LEVEL = { critical: "error", high: "error", medium: "warning", low: "note", info: "note" };
|
|
8
|
+
|
|
9
|
+
function toSarif(result, target) {
|
|
10
|
+
// One rule descriptor per distinct ruleId that actually fired.
|
|
11
|
+
const ruleIndex = new Map();
|
|
12
|
+
const rules = [];
|
|
13
|
+
for (const f of result.findings) {
|
|
14
|
+
if (ruleIndex.has(f.ruleId)) continue;
|
|
15
|
+
ruleIndex.set(f.ruleId, rules.length);
|
|
16
|
+
rules.push({
|
|
17
|
+
id: f.ruleId,
|
|
18
|
+
name: f.ruleId,
|
|
19
|
+
shortDescription: { text: f.title || f.ruleId },
|
|
20
|
+
fullDescription: { text: f.message || f.title || f.ruleId },
|
|
21
|
+
defaultConfiguration: { level: LEVEL[f.severity] || "warning" },
|
|
22
|
+
properties: {
|
|
23
|
+
security_severity: f.severity,
|
|
24
|
+
tags: ["security", f.category].filter(Boolean),
|
|
25
|
+
...(f.cwe ? { cwe: f.cwe } : {}),
|
|
26
|
+
},
|
|
27
|
+
...(f.fix ? { help: { text: f.fix } } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const results = result.findings.map((f) => ({
|
|
32
|
+
ruleId: f.ruleId,
|
|
33
|
+
ruleIndex: ruleIndex.get(f.ruleId),
|
|
34
|
+
level: LEVEL[f.severity] || "warning",
|
|
35
|
+
message: { text: f.message || f.title || f.ruleId },
|
|
36
|
+
locations: [{
|
|
37
|
+
physicalLocation: {
|
|
38
|
+
artifactLocation: { uri: uri(f.file) },
|
|
39
|
+
region: { startLine: Math.max(1, f.line || 1), ...(f.column ? { startColumn: f.column } : {}) },
|
|
40
|
+
},
|
|
41
|
+
}],
|
|
42
|
+
properties: { severity: f.severity, confidence: f.confidence, category: f.category },
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
47
|
+
version: "2.1.0",
|
|
48
|
+
runs: [{
|
|
49
|
+
tool: {
|
|
50
|
+
driver: {
|
|
51
|
+
name: "safeey-cli",
|
|
52
|
+
informationUri: "https://safeey.io",
|
|
53
|
+
version: pkg.version,
|
|
54
|
+
rules,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
results,
|
|
58
|
+
properties: { target, filesScanned: result.filesScanned, grade: result.grade },
|
|
59
|
+
}],
|
|
60
|
+
}, null, 2);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function uri(p) {
|
|
64
|
+
return String(p).replace(/\\/g, "/");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { toSarif };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/rules/framework.js — framework-aware pattern rules.
|
|
2
|
+
// Most real-world vulnerabilities live at framework boundaries, so these target
|
|
3
|
+
// the specific misuse shapes of popular frameworks. Same line-based format as
|
|
4
|
+
// patterns.js; loaded alongside it by the pattern engine.
|
|
5
|
+
|
|
6
|
+
const FRAMEWORK_RULES = [
|
|
7
|
+
// ---- Express / Node ----------------------------------------------------
|
|
8
|
+
{
|
|
9
|
+
id: "express/cors-wildcard-credentials", title: "CORS allows any origin with credentials",
|
|
10
|
+
severity: "high", confidence: "medium", category: "auth", cwe: "CWE-942",
|
|
11
|
+
languages: ["js", "ts"], re: /origin\s*:\s*(?:true|['"]\*['"])[^}]*credentials\s*:\s*true|credentials\s*:\s*true[^}]*origin\s*:\s*(?:true|['"]\*['"])/,
|
|
12
|
+
message: "Reflecting any origin while allowing credentials lets any site make authenticated requests on the user's behalf.",
|
|
13
|
+
fix: "Set an explicit allow-list of trusted origins when credentials:true; never combine credentials with origin '*' or true.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "express/no-helmet", title: "Express app without security headers (helmet)",
|
|
17
|
+
severity: "info", confidence: "low", category: "config", cwe: "CWE-693",
|
|
18
|
+
languages: ["js", "ts"], re: /express\s*\(\s*\)/, unless: /helmet/,
|
|
19
|
+
message: "This looks like an Express app; without helmet() it ships no security response headers by default.",
|
|
20
|
+
fix: "Add `app.use(helmet())` to set sensible security headers (CSP, HSTS, X-Frame-Options, etc.).",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "express/body-no-limit", title: "Body parser without size limit",
|
|
24
|
+
severity: "low", confidence: "low", category: "dos", cwe: "CWE-400",
|
|
25
|
+
languages: ["js", "ts"], re: /express\.(?:json|urlencoded)\s*\(\s*(?:\)|\{\s*\})/,
|
|
26
|
+
message: "A body parser with no size limit lets a client send a huge payload and exhaust memory.",
|
|
27
|
+
fix: "Pass a limit, e.g. express.json({ limit: '100kb' }).",
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// ---- Next.js -----------------------------------------------------------
|
|
31
|
+
{
|
|
32
|
+
id: "next/dangerous-html", title: "dangerouslySetInnerHTML with dynamic value",
|
|
33
|
+
severity: "high", confidence: "low", category: "xss", cwe: "CWE-79",
|
|
34
|
+
languages: ["js", "ts"], re: /dangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:\s*(?!['"`])/,
|
|
35
|
+
message: "dangerouslySetInnerHTML with a non-literal value renders unescaped HTML — an XSS sink if the value is user-influenced.",
|
|
36
|
+
fix: "Render text as children, or sanitise the HTML (e.g. DOMPurify) before passing it to __html.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "next/public-env-secret", title: "Secret-looking value in NEXT_PUBLIC_ env",
|
|
40
|
+
severity: "high", confidence: "low", category: "secrets", cwe: "CWE-200",
|
|
41
|
+
languages: ["js", "ts"], re: /NEXT_PUBLIC_[A-Z0-9_]*(SECRET|KEY|TOKEN|PASSWORD|PRIVATE)/,
|
|
42
|
+
message: "NEXT_PUBLIC_ variables are inlined into the client bundle; a secret named here is shipped to every browser.",
|
|
43
|
+
fix: "Keep secrets in server-only env vars (without the NEXT_PUBLIC_ prefix) and access them only in server code.",
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// ---- Django ------------------------------------------------------------
|
|
47
|
+
{
|
|
48
|
+
id: "django/mark-safe", title: "Django mark_safe() on dynamic content",
|
|
49
|
+
severity: "high", confidence: "low", category: "xss", cwe: "CWE-79",
|
|
50
|
+
languages: ["py"], re: /mark_safe\s*\(\s*(?!['"])/,
|
|
51
|
+
message: "mark_safe() disables autoescaping; on user-influenced content it produces XSS.",
|
|
52
|
+
fix: "Avoid mark_safe on dynamic data; use format_html() with escaped args, or sanitise first.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "django/raw-sql", title: "Django raw()/extra() SQL",
|
|
56
|
+
severity: "high", confidence: "low", category: "injection", cwe: "CWE-89",
|
|
57
|
+
languages: ["py"], re: /\.(?:raw|extra)\s*\(\s*(?:f['"]|['"][^'"]*%|['"][^'"]*\{)/,
|
|
58
|
+
message: ".raw()/.extra() with a formatted string bypasses the ORM's parameterisation and risks SQL injection.",
|
|
59
|
+
fix: "Use parameterised queries: pass params separately (e.g. Model.objects.raw(sql, [params])), never f-strings.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "django/debug-true", title: "Django DEBUG=True",
|
|
63
|
+
severity: "medium", confidence: "medium", category: "config", cwe: "CWE-489",
|
|
64
|
+
languages: ["py"], re: /^\s*DEBUG\s*=\s*True/m,
|
|
65
|
+
message: "DEBUG=True in Django exposes detailed error pages with settings and source to any visitor.",
|
|
66
|
+
fix: "Set DEBUG from an environment variable, defaulting to False in production.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "django/allowed-hosts-wildcard", title: "ALLOWED_HOSTS = ['*']",
|
|
70
|
+
severity: "low", confidence: "medium", category: "config", cwe: "CWE-16",
|
|
71
|
+
languages: ["py"], re: /ALLOWED_HOSTS\s*=\s*\[\s*['"]\*['"]\s*\]/,
|
|
72
|
+
message: "ALLOWED_HOSTS=['*'] disables host-header validation, enabling host-header poisoning.",
|
|
73
|
+
fix: "List your real domains explicitly in ALLOWED_HOSTS.",
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// ---- Flask -------------------------------------------------------------
|
|
77
|
+
{
|
|
78
|
+
id: "flask/render-template-string", title: "Flask render_template_string with dynamic input",
|
|
79
|
+
severity: "high", confidence: "low", category: "injection", cwe: "CWE-94",
|
|
80
|
+
languages: ["py"], re: /render_template_string\s*\(\s*(?:f['"]|[a-zA-Z_]\w*\s*\+|.*%\s*)/,
|
|
81
|
+
message: "Passing user input into render_template_string enables server-side template injection (SSTI) → RCE.",
|
|
82
|
+
fix: "Render a static template file and pass data as context variables; never build the template body from input.",
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// ---- Spring / Java -----------------------------------------------------
|
|
86
|
+
{
|
|
87
|
+
id: "spring/permit-all", title: "Spring Security permitAll() on all requests",
|
|
88
|
+
severity: "medium", confidence: "low", category: "auth", cwe: "CWE-284",
|
|
89
|
+
languages: ["java"], re: /anyRequest\s*\(\s*\)\s*\.\s*permitAll\s*\(\s*\)/,
|
|
90
|
+
message: "permitAll() on anyRequest() leaves every endpoint unauthenticated.",
|
|
91
|
+
fix: "Require authentication by default (anyRequest().authenticated()) and open up only the specific public routes.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "spring/csrf-disable", title: "Spring Security CSRF disabled",
|
|
95
|
+
severity: "medium", confidence: "low", category: "auth", cwe: "CWE-352",
|
|
96
|
+
languages: ["java"], re: /\.csrf\s*\([^)]*\)\s*\.\s*disable\s*\(\s*\)|csrf\s*\(\s*\)\.disable/,
|
|
97
|
+
message: "Disabling CSRF protection exposes cookie-authenticated, state-changing endpoints to cross-site request forgery.",
|
|
98
|
+
fix: "Keep CSRF protection on for browser/session-based flows; disable only for stateless token-authenticated APIs.",
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
module.exports = { FRAMEWORK_RULES };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/rules/infra.js — infrastructure & CI misconfiguration rules.
|
|
2
|
+
// Matched by file *kind* (docker/k8s/terraform/github-actions) rather than by
|
|
3
|
+
// language, because a lot of modern incidents start in infra, not app code.
|
|
4
|
+
// Each rule declares which kinds it applies to.
|
|
5
|
+
|
|
6
|
+
const INFRA_RULES = [
|
|
7
|
+
// ---- Dockerfile --------------------------------------------------------
|
|
8
|
+
{
|
|
9
|
+
id: "docker/run-as-root", title: "Container runs as root",
|
|
10
|
+
kinds: ["dockerfile"], severity: "medium", confidence: "low", category: "config", cwe: "CWE-250",
|
|
11
|
+
re: /^\s*FROM\s/i, whole: true, absentRe: /^\s*USER\s+(?!root\b)/im,
|
|
12
|
+
message: "No non-root USER is set, so the container runs as root — a container escape then has root on the host namespace.",
|
|
13
|
+
fix: "Add a `USER` directive with an unprivileged user before the app runs.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "docker/latest-tag", title: "Base image pinned to :latest",
|
|
17
|
+
kinds: ["dockerfile"], severity: "low", confidence: "medium", category: "supply-chain", cwe: "CWE-1104",
|
|
18
|
+
re: /^\s*FROM\s+\S+:latest\b/im,
|
|
19
|
+
message: "`:latest` makes builds non-reproducible and silently pulls in changed (possibly vulnerable) base images.",
|
|
20
|
+
fix: "Pin to a specific version tag or, better, a digest (FROM image@sha256:...).",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "docker/secret-in-env", title: "Secret baked into image via ENV/ARG",
|
|
24
|
+
kinds: ["dockerfile"], severity: "high", confidence: "low", category: "secrets", cwe: "CWE-798",
|
|
25
|
+
re: /^\s*(?:ENV|ARG)\s+\w*(SECRET|PASSWORD|TOKEN|API_?KEY|PRIVATE_?KEY)\w*\s*[=\s]\s*\S/im,
|
|
26
|
+
message: "Secrets set via ENV/ARG are baked into image layers and readable by anyone who pulls the image.",
|
|
27
|
+
fix: "Inject secrets at runtime (orchestrator secrets, --secret mounts) instead of build-time ENV/ARG.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "docker/curl-pipe-sh", title: "Piping a remote script to a shell",
|
|
31
|
+
kinds: ["dockerfile"], severity: "medium", confidence: "medium", category: "supply-chain", cwe: "CWE-494",
|
|
32
|
+
re: /(?:curl|wget)\s[^|]*\|\s*(?:sudo\s+)?(?:sh|bash)/i,
|
|
33
|
+
message: "curl | sh executes unpinned remote code at build time with no integrity check — a supply-chain risk.",
|
|
34
|
+
fix: "Download to a file, verify a checksum/signature, then execute.",
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// ---- Kubernetes --------------------------------------------------------
|
|
38
|
+
{
|
|
39
|
+
id: "k8s/privileged", title: "Privileged container",
|
|
40
|
+
kinds: ["k8s"], severity: "high", confidence: "medium", category: "config", cwe: "CWE-250",
|
|
41
|
+
re: /privileged\s*:\s*true/i,
|
|
42
|
+
message: "A privileged container has near-host root; a compromise there is effectively a node compromise.",
|
|
43
|
+
fix: "Remove privileged:true; grant only the specific capabilities the workload needs.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "k8s/host-network", title: "Pod uses host network/PID namespace",
|
|
47
|
+
kinds: ["k8s"], severity: "medium", confidence: "medium", category: "config", cwe: "CWE-668",
|
|
48
|
+
re: /host(?:Network|PID|IPC)\s*:\s*true/i,
|
|
49
|
+
message: "Sharing the host network/PID/IPC namespace breaks pod isolation and exposes host resources.",
|
|
50
|
+
fix: "Avoid hostNetwork/hostPID/hostIPC unless strictly required; prefer proper Services.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "k8s/allow-privilege-escalation", title: "allowPrivilegeEscalation not disabled",
|
|
54
|
+
kinds: ["k8s"], severity: "low", confidence: "low", category: "config", cwe: "CWE-250",
|
|
55
|
+
re: /allowPrivilegeEscalation\s*:\s*true/i,
|
|
56
|
+
message: "allowPrivilegeEscalation:true lets a process gain more privileges than its parent.",
|
|
57
|
+
fix: "Set allowPrivilegeEscalation:false in the container securityContext.",
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// ---- Terraform ---------------------------------------------------------
|
|
61
|
+
{
|
|
62
|
+
id: "tf/open-security-group", title: "Security group open to the world",
|
|
63
|
+
kinds: ["terraform"], severity: "high", confidence: "medium", category: "config", cwe: "CWE-284",
|
|
64
|
+
re: /cidr_blocks\s*=\s*\[\s*['"]0\.0\.0\.0\/0['"]/,
|
|
65
|
+
message: "0.0.0.0/0 in a security group exposes the port to the entire internet.",
|
|
66
|
+
fix: "Restrict cidr_blocks to known IP ranges; never expose admin ports (22/3389/database) to 0.0.0.0/0.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "tf/s3-public-acl", title: "S3 bucket with public ACL",
|
|
70
|
+
kinds: ["terraform"], severity: "high", confidence: "medium", category: "config", cwe: "CWE-732",
|
|
71
|
+
re: /acl\s*=\s*['"]public-read(?:-write)?['"]/,
|
|
72
|
+
message: "A public-read/-write ACL exposes bucket contents (or allows writes) to anyone.",
|
|
73
|
+
fix: "Use private ACLs and grant access via bucket policies/IAM; enable Block Public Access.",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "tf/unencrypted-storage", title: "Storage encryption disabled",
|
|
77
|
+
kinds: ["terraform"], severity: "medium", confidence: "low", category: "config", cwe: "CWE-311",
|
|
78
|
+
re: /(?:encrypted|storage_encrypted)\s*=\s*false/,
|
|
79
|
+
message: "Encryption at rest is explicitly disabled on a storage resource.",
|
|
80
|
+
fix: "Set encrypted/storage_encrypted = true (and specify a KMS key where applicable).",
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// ---- GitHub Actions ----------------------------------------------------
|
|
84
|
+
{
|
|
85
|
+
id: "gha/script-injection", title: "Untrusted event data in a run: step",
|
|
86
|
+
kinds: ["github-actions"], severity: "high", confidence: "medium", category: "injection", cwe: "CWE-94",
|
|
87
|
+
re: /\$\{\{\s*github\.event\.(?:issue\.title|issue\.body|pull_request\.title|pull_request\.body|comment\.body|head_ref|head_commit\.message|review\.body)\s*\}\}/,
|
|
88
|
+
message: "Interpolating attacker-controllable github.event fields directly into a run: shell is a script-injection vector.",
|
|
89
|
+
fix: "Pass the value through an env: var and reference \"$VAR\" in the script, rather than inlining ${{ ... }} into the shell.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "gha/pull-request-target", title: "pull_request_target with checkout of PR code",
|
|
93
|
+
kinds: ["github-actions"], severity: "high", confidence: "low", category: "supply-chain", cwe: "CWE-269",
|
|
94
|
+
re: /pull_request_target/,
|
|
95
|
+
message: "pull_request_target runs with repo secrets and write token; checking out untrusted PR code here can leak secrets.",
|
|
96
|
+
fix: "Avoid checking out and running untrusted PR code under pull_request_target; if you must, don't expose secrets to it.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "gha/unpinned-action", title: "Action pinned to a mutable ref",
|
|
100
|
+
kinds: ["github-actions"], severity: "low", confidence: "low", category: "supply-chain", cwe: "CWE-1104",
|
|
101
|
+
re: /uses\s*:\s*[\w.-]+\/[\w.-]+@(?:main|master|v?\d+)\s*$/im,
|
|
102
|
+
message: "Pinning an action to a branch or major tag lets its code change under you; a compromised tag runs in your pipeline.",
|
|
103
|
+
fix: "Pin third-party actions to a full commit SHA.",
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// Classify a file into an infra kind (or null). Uses path + a light content sniff.
|
|
108
|
+
function classifyInfraFile(relPath, base, code) {
|
|
109
|
+
const p = relPath.replace(/\\/g, "/");
|
|
110
|
+
if (/^Dockerfile$|\.dockerfile$|^Dockerfile\./i.test(base)) return "dockerfile";
|
|
111
|
+
if (/\.tf$|\.tf\.json$/i.test(base)) return "terraform";
|
|
112
|
+
if (/\.github\/workflows\/.+\.ya?ml$/i.test(p)) return "github-actions";
|
|
113
|
+
if (/\.ya?ml$/i.test(base)) {
|
|
114
|
+
// crude k8s sniff: has both apiVersion and kind at column 0
|
|
115
|
+
if (/^apiVersion\s*:/m.test(code) && /^kind\s*:/m.test(code)) return "k8s";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { INFRA_RULES, classifyInfraFile };
|
package/src/rules/js-rules.js
CHANGED
|
@@ -146,6 +146,120 @@ const RULES = [
|
|
|
146
146
|
message: "origin: '*' lets any website read responses from this API. On authenticated endpoints, that leaks user data.",
|
|
147
147
|
fix: "Allow-list specific trusted origins instead of using a wildcard, especially for endpoints that use credentials.",
|
|
148
148
|
},
|
|
149
|
+
|
|
150
|
+
// ---- added in 0.3: more vulnerability classes --------------------------
|
|
151
|
+
{
|
|
152
|
+
id: "js/prototype-pollution",
|
|
153
|
+
title: "Possible prototype pollution",
|
|
154
|
+
severity: "high",
|
|
155
|
+
category: "deserialization",
|
|
156
|
+
cwe: "CWE-1321",
|
|
157
|
+
nodeTypes: ["AssignmentExpression"],
|
|
158
|
+
test: (n) => {
|
|
159
|
+
// Flag __proto__ anywhere in the target chain (near-zero false positives),
|
|
160
|
+
// or a direct reassignment of .constructor. We deliberately do NOT flag
|
|
161
|
+
// `X.prototype.method = ...`, which is normal, everyday code.
|
|
162
|
+
let node = n.left;
|
|
163
|
+
let depth = 0;
|
|
164
|
+
let finalProp = null;
|
|
165
|
+
while (node && node.type === "MemberExpression" && depth++ < 20) {
|
|
166
|
+
const prop = node.computed
|
|
167
|
+
? (node.property.type === "StringLiteral" ? node.property.value : null)
|
|
168
|
+
: (node.property && node.property.name);
|
|
169
|
+
if (depth === 1) finalProp = prop;
|
|
170
|
+
if (prop === "__proto__") return true;
|
|
171
|
+
node = node.object;
|
|
172
|
+
}
|
|
173
|
+
return finalProp === "constructor";
|
|
174
|
+
},
|
|
175
|
+
message: "Writing to __proto__/prototype/constructor can pollute Object.prototype and affect every object in the process.",
|
|
176
|
+
fix: "Reject these keys when merging/assigning from external data; use a Map, Object.create(null), or a vetted deep-merge that guards prototype keys.",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "js/redos-literal",
|
|
180
|
+
title: "Regex vulnerable to catastrophic backtracking (ReDoS)",
|
|
181
|
+
severity: "medium",
|
|
182
|
+
category: "dos",
|
|
183
|
+
cwe: "CWE-1333",
|
|
184
|
+
nodeTypes: ["RegExpLiteral"],
|
|
185
|
+
test: (n) => typeof n.pattern === "string" && /(\([^)]*[+*][^)]*\)|\[[^\]]+\]|\.[*+]|\\w[*+]|[^\\]\)[*+])[+*]/.test(n.pattern) && /([+*].*){2,}/.test(n.pattern),
|
|
186
|
+
message: "This regex has nested/adjacent unbounded quantifiers, which can cause exponential backtracking on crafted input (a DoS).",
|
|
187
|
+
fix: "Rewrite to avoid nested quantifiers, add bounds, or use a linear-time engine (e.g. RE2).",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "js/regexp-from-input",
|
|
191
|
+
title: "RegExp built from dynamic input (ReDoS)",
|
|
192
|
+
severity: "medium",
|
|
193
|
+
category: "dos",
|
|
194
|
+
cwe: "CWE-1333",
|
|
195
|
+
nodeTypes: ["NewExpression", "CallExpression"],
|
|
196
|
+
test: (n) => n.callee.type === "Identifier" && n.callee.name === "RegExp" && n.arguments[0] && containsIdentifier(n.arguments[0], USER_INPUT),
|
|
197
|
+
message: "Building a RegExp from user input lets an attacker supply a pattern that backtracks catastrophically.",
|
|
198
|
+
fix: "Don't compile user input into a regex; if unavoidable, validate/escape it and enforce a length limit and timeout.",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "js/ssrf",
|
|
202
|
+
title: "Possible SSRF (server-side request to a dynamic URL)",
|
|
203
|
+
severity: "high",
|
|
204
|
+
category: "ssrf",
|
|
205
|
+
cwe: "CWE-918",
|
|
206
|
+
confidence: "low",
|
|
207
|
+
nodeTypes: ["CallExpression"],
|
|
208
|
+
test: (n) => {
|
|
209
|
+
const name = calleeName(n) || "";
|
|
210
|
+
const isReq = /(^|\.)(fetch|get|post|request|got)$/.test(name) || name === "fetch";
|
|
211
|
+
return isReq && n.arguments[0] && (isStringBuilt(n.arguments[0]) || containsIdentifier(n.arguments[0], USER_INPUT));
|
|
212
|
+
},
|
|
213
|
+
message: "A server-side HTTP request targets a URL built from input. An attacker can point it at internal services or cloud metadata.",
|
|
214
|
+
fix: "Validate the URL against an allow-list of hosts/schemes; block private/link-local ranges (e.g. 169.254.169.254).",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "js/nosql-injection",
|
|
218
|
+
title: "Possible NoSQL injection",
|
|
219
|
+
severity: "high",
|
|
220
|
+
category: "injection",
|
|
221
|
+
cwe: "CWE-943",
|
|
222
|
+
confidence: "low",
|
|
223
|
+
nodeTypes: ["CallExpression"],
|
|
224
|
+
test: (n) => {
|
|
225
|
+
const prop = n.callee.type === "MemberExpression" && !n.callee.computed && n.callee.property.name;
|
|
226
|
+
const isQuery = ["find", "findOne", "update", "updateOne", "updateMany", "deleteOne", "deleteMany", "remove", "findOneAndUpdate", "aggregate"].includes(prop);
|
|
227
|
+
return isQuery && n.arguments[0] && containsIdentifier(n.arguments[0], USER_INPUT);
|
|
228
|
+
},
|
|
229
|
+
message: "A query object is populated directly from request data. Operator objects (e.g. {$gt:''}, {$where}) let an attacker alter the query.",
|
|
230
|
+
fix: "Cast and validate expected fields to primitives before querying; reject object-valued fields where a scalar is expected.",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "js/open-redirect",
|
|
234
|
+
title: "Possible open redirect",
|
|
235
|
+
severity: "medium",
|
|
236
|
+
category: "auth",
|
|
237
|
+
cwe: "CWE-601",
|
|
238
|
+
confidence: "low",
|
|
239
|
+
nodeTypes: ["CallExpression"],
|
|
240
|
+
test: (n) => {
|
|
241
|
+
const name = calleeName(n) || "";
|
|
242
|
+
return /(^|\.)redirect$/.test(name) && n.arguments.length > 0 && containsIdentifier(n.arguments[n.arguments.length - 1], USER_INPUT);
|
|
243
|
+
},
|
|
244
|
+
message: "A redirect target is derived from user input, letting an attacker bounce victims to an arbitrary external site.",
|
|
245
|
+
fix: "Redirect only to allow-listed paths, or validate that the target is a relative path / same-origin URL.",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
id: "js/non-constant-time-compare",
|
|
249
|
+
title: "Non-constant-time comparison of a secret",
|
|
250
|
+
severity: "low",
|
|
251
|
+
category: "crypto",
|
|
252
|
+
cwe: "CWE-208",
|
|
253
|
+
confidence: "low",
|
|
254
|
+
nodeTypes: ["BinaryExpression"],
|
|
255
|
+
test: (n) => {
|
|
256
|
+
if (!["===", "!==", "==", "!="].includes(n.operator)) return false;
|
|
257
|
+
const names = [memberName(n.left) || "", memberName(n.right) || ""];
|
|
258
|
+
return names.some((s) => /(secret|token|password|passwd|hmac|signature|api_?key|digest|mac)\b/i.test(s));
|
|
259
|
+
},
|
|
260
|
+
message: "Comparing a secret/token with ===/== leaks timing information that can enable a byte-by-byte guessing attack.",
|
|
261
|
+
fix: "Compare secrets with a constant-time function such as crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)).",
|
|
262
|
+
},
|
|
149
263
|
];
|
|
150
264
|
|
|
151
265
|
module.exports = { RULES };
|
package/src/scan.js
CHANGED
|
@@ -5,7 +5,12 @@ const { analyzeJs } = require("./engine/js");
|
|
|
5
5
|
const { analyzeSecrets } = require("./engine/secrets");
|
|
6
6
|
const { analyzeDeps } = require("./engine/deps");
|
|
7
7
|
const { analyzePatterns } = require("./engine/patterns");
|
|
8
|
+
const { analyzeTaint } = require("./engine/taint");
|
|
9
|
+
const { analyzeInfra } = require("./engine/infra");
|
|
8
10
|
const { makeIgnoreChecker } = require("./engine/gitignore");
|
|
11
|
+
const { fileSuppressions, isSuppressed, loadPathIgnores } = require("./engine/suppress");
|
|
12
|
+
const { analyzeDependenciesOSV } = require("./engine/osv");
|
|
13
|
+
const { analyzeGitHistory } = require("./engine/githistory");
|
|
9
14
|
|
|
10
15
|
// Any .env-style file. `.env.example` / `.env.sample` / `.env.template` / .dist
|
|
11
16
|
// are meant to be committed and hold placeholders, so they're scanned normally
|
|
@@ -46,17 +51,27 @@ function envNotIgnoredFinding(rel, hasSecret, unknown) {
|
|
|
46
51
|
|
|
47
52
|
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage", ".turbo", ".cache"]);
|
|
48
53
|
const JS_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
|
|
49
|
-
const TEXT_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".env", ".yml", ".yaml", ".py", ".rb", ".go", ".php", ".java", ".txt", ".sh", ".config"]);
|
|
54
|
+
const TEXT_EXT = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".env", ".yml", ".yaml", ".py", ".rb", ".go", ".php", ".java", ".txt", ".sh", ".config", ".tf"]);
|
|
50
55
|
const MAX_FILE_BYTES = 1_500_000;
|
|
56
|
+
const CONF_RANK = { low: 1, medium: 2, high: 3 };
|
|
51
57
|
|
|
52
58
|
const SEV_WEIGHT = { critical: 25, high: 12, medium: 5, low: 1, info: 0 };
|
|
53
59
|
const SEV_ORDER = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
54
60
|
|
|
61
|
+
// Files that should be scanned even without a known text extension.
|
|
62
|
+
function isScannable(base, ext, envClass) {
|
|
63
|
+
if (TEXT_EXT.has(ext)) return true;
|
|
64
|
+
if (base === "package.json") return true;
|
|
65
|
+
if (envClass) return true;
|
|
66
|
+
if (/^Dockerfile($|\.)/i.test(base)) return true; // Dockerfile, Dockerfile.prod
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
function walk(dir, files = []) {
|
|
56
71
|
let entries = [];
|
|
57
72
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
|
58
73
|
for (const e of entries) {
|
|
59
|
-
if (e.name.startsWith(".") && e.name !== ".env") { if (e.isDirectory()) continue; }
|
|
74
|
+
if (e.name.startsWith(".") && e.name !== ".env" && !e.name.startsWith(".env")) { if (e.isDirectory() && e.name !== ".github") continue; }
|
|
60
75
|
const full = path.join(dir, e.name);
|
|
61
76
|
if (e.isDirectory()) { if (!IGNORE_DIRS.has(e.name)) walk(full, files); }
|
|
62
77
|
else files.push(full);
|
|
@@ -73,17 +88,40 @@ function grade(findings) {
|
|
|
73
88
|
return { score, letter, counts };
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
function
|
|
91
|
+
function dedupeSort(findings) {
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const unique = findings.filter((f) => {
|
|
94
|
+
const k = `${f.ruleId}|${f.file}|${f.line}|${f.column}`;
|
|
95
|
+
if (seen.has(k)) return false; seen.add(k); return true;
|
|
96
|
+
});
|
|
97
|
+
unique.sort((a, b) =>
|
|
98
|
+
(SEV_ORDER[b.severity] - SEV_ORDER[a.severity]) ||
|
|
99
|
+
a.file.localeCompare(b.file) || (a.line - b.line));
|
|
100
|
+
return unique;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function scan(target, opts = {}) {
|
|
77
104
|
const root = path.resolve(target);
|
|
78
105
|
const stat = fs.existsSync(root) && fs.statSync(root);
|
|
79
106
|
if (!stat) throw new Error(`Path not found: ${root}`);
|
|
80
107
|
|
|
81
108
|
const files = stat.isDirectory() ? walk(root) : [root];
|
|
82
|
-
|
|
109
|
+
let findings = [];
|
|
83
110
|
let scanned = 0;
|
|
111
|
+
const meta = {};
|
|
112
|
+
const report = typeof opts.onProgress === "function" ? opts.onProgress : () => {};
|
|
113
|
+
report({ phase: "discover", total: files.length });
|
|
84
114
|
|
|
85
|
-
//
|
|
115
|
+
// Decide whether a .env's secrets are safely ignored or exposed.
|
|
86
116
|
const ignore = makeIgnoreChecker(root);
|
|
117
|
+
// .safeeyignore: skip whole paths the user has opted out of.
|
|
118
|
+
const pathIgnored = loadPathIgnores(
|
|
119
|
+
(p) => { try { return fs.readFileSync(p, "utf8"); } catch { return null; } },
|
|
120
|
+
(name) => path.join(root, name)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const minConf = CONF_RANK[opts.confidence] || 0;
|
|
124
|
+
const keepConf = (f) => !minConf || (CONF_RANK[f.confidence || "medium"] >= minConf);
|
|
87
125
|
|
|
88
126
|
for (const file of files) {
|
|
89
127
|
const ext = path.extname(file).toLowerCase();
|
|
@@ -92,44 +130,65 @@ function scan(target, opts = {}) {
|
|
|
92
130
|
try { size = fs.statSync(file).size; } catch { continue; }
|
|
93
131
|
if (size > MAX_FILE_BYTES) continue;
|
|
94
132
|
const envClass = classifyEnvFile(base);
|
|
95
|
-
if (!
|
|
133
|
+
if (!isScannable(base, ext, envClass)) continue;
|
|
134
|
+
|
|
135
|
+
const rel = path.relative(root, file) || base;
|
|
136
|
+
if (pathIgnored(rel)) continue;
|
|
96
137
|
|
|
97
138
|
let code = "";
|
|
98
139
|
try { code = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
99
140
|
scanned++;
|
|
100
|
-
|
|
141
|
+
report({ phase: "scan", current: scanned, total: files.length, file: rel });
|
|
142
|
+
|
|
143
|
+
// Collect this file's findings, then apply inline suppressions once.
|
|
144
|
+
const fileFindings = [];
|
|
101
145
|
|
|
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
146
|
if (envClass === "real") {
|
|
147
|
+
// A real .env is *meant* to hold secrets — the question is whether it's
|
|
148
|
+
// protected from being committed. Suppress when ignored; flag when not.
|
|
107
149
|
const secretFindings = opts.secrets !== false ? analyzeSecrets(code, rel) : [];
|
|
108
150
|
const ignored = ignore.isIgnored(file); // true | false | null(unknown)
|
|
109
151
|
if (ignored !== true && hasEnvAssignment(code)) {
|
|
110
|
-
|
|
111
|
-
for (const f of secretFindings)
|
|
152
|
+
fileFindings.push(envNotIgnoredFinding(rel, secretFindings.length > 0, ignored === null));
|
|
153
|
+
for (const f of secretFindings) fileFindings.push(f);
|
|
112
154
|
}
|
|
113
|
-
|
|
114
|
-
|
|
155
|
+
} else {
|
|
156
|
+
if (JS_EXT.has(ext)) {
|
|
157
|
+
for (const f of analyzeJs(code, rel)) fileFindings.push(f);
|
|
158
|
+
for (const f of analyzeTaint(code, rel)) fileFindings.push(f); // dataflow-backed
|
|
159
|
+
}
|
|
160
|
+
if (base === "package.json") for (const f of analyzeDeps(code, rel)) fileFindings.push(f);
|
|
161
|
+
if (opts.secrets !== false) for (const f of analyzeSecrets(code, rel)) fileFindings.push(f);
|
|
162
|
+
for (const f of analyzePatterns(code, rel, ext)) fileFindings.push(f); // + framework rules
|
|
163
|
+
for (const f of analyzeInfra(code, rel, base)) fileFindings.push(f); // docker/k8s/tf/gha
|
|
115
164
|
}
|
|
116
165
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
166
|
+
// Inline suppressions (// safeey-ignore ...) for this file.
|
|
167
|
+
const sup = fileSuppressions(code);
|
|
168
|
+
for (const f of fileFindings) {
|
|
169
|
+
if (f.line && isSuppressed(sup, f.line, f.ruleId)) continue;
|
|
170
|
+
if (!keepConf(f)) continue;
|
|
171
|
+
findings.push(f);
|
|
172
|
+
}
|
|
122
173
|
}
|
|
123
174
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
175
|
+
// ---- opt-in engines that aren't per-file --------------------------------
|
|
176
|
+
if (opts.osv) {
|
|
177
|
+
report({ phase: "osv" });
|
|
178
|
+
const r = await analyzeDependenciesOSV(root);
|
|
179
|
+
meta.osv = { checked: r.checked, error: r.error };
|
|
180
|
+
for (const f of r.findings) if (keepConf(f)) findings.push(f);
|
|
181
|
+
}
|
|
182
|
+
if (opts.history) {
|
|
183
|
+
report({ phase: "history" });
|
|
184
|
+
const r = analyzeGitHistory(root);
|
|
185
|
+
meta.history = { scannedBlobs: r.scannedBlobs, error: r.error };
|
|
186
|
+
for (const f of r.findings) if (keepConf(f)) findings.push(f);
|
|
187
|
+
}
|
|
131
188
|
|
|
132
|
-
|
|
189
|
+
report({ phase: "grade" });
|
|
190
|
+
findings = dedupeSort(findings);
|
|
191
|
+
return { findings, filesScanned: scanned, filesTotal: files.length, grade: grade(findings), meta, SEV_ORDER };
|
|
133
192
|
}
|
|
134
193
|
|
|
135
|
-
module.exports = { scan, grade, SEV_ORDER };
|
|
194
|
+
module.exports = { scan, grade, dedupeSort, SEV_ORDER };
|