secanix 0.1.5 → 0.1.6
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/README.md +18 -9
- package/dist/checks/exposedFirebaseAdminKey.js +3 -1
- package/dist/checks/exposedServiceRoleKey.js +5 -2
- package/dist/checks/gitUtils.js +37 -0
- package/dist/cli.js +22 -3
- package/dist/licenseCheck.js +37 -0
- package/dist/rules/gitleaks-config.toml +13 -13
- package/dist/rules/nextjs-api-auth-missing.yaml +24 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).
|
|
4
4
|
|
|
5
|
-
Checks
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
5
|
+
## Checks
|
|
6
|
+
|
|
7
|
+
| Check | ruleId | Severity | Triggers on | Fix |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| Leaked secrets (gitleaks) | varies per gitleaks rule | Critical | Hardcoded API key/token/credential in a git-tracked or about-to-be-tracked file | Rotate the secret now, strip it from code & git history (not just a new commit), move it to an env var / secret manager |
|
|
10
|
+
| Exposed Supabase service role key | `supabase-service-role-key-public-env` | Critical | `NEXT_PUBLIC_*` env var holding a Supabase service role key — gets inlined into the client bundle at build time | Move it to a server-only env var, rotate the key in the Supabase dashboard if it's ever been deployed |
|
|
11
|
+
| Missing auth on Next.js API route | `nextjs-api-route-missing-auth` | High | A Pages/App Router API handler with no session/token check before it runs any logic | Add an auth check (`getServerSession`/`getToken`/etc.) at the top of the handler |
|
|
12
|
+
| Supabase RLS disabled | `supabase-rls-disabled` | Critical | A table where RLS was explicitly turned off | Re-enable RLS (`ALTER TABLE ... ENABLE ROW LEVEL SECURITY`) and add matching policies |
|
|
13
|
+
| Supabase RLS missing | `supabase-rls-missing` | High | A table created with no RLS enable statement at all | Add `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` plus a policy for the table |
|
|
14
|
+
| CORS wildcard origin | `cors-wildcard-origin` | Medium | `Access-Control-Allow-Origin: *` (or equivalent) on an API response | Replace `*` with an explicit origin allowlist, or validate the origin dynamically server-side |
|
|
15
|
+
| Open Firebase security rules | `firebase-rules-open` | Critical | Firestore/Realtime Database/Storage rules using `if true` / `.read`/`.write: true` | Replace the allow-all rule with one that checks `request.auth != null` etc. — Firebase's default is deny-all, don't override it to allow-all |
|
|
16
|
+
| Exposed Firebase Admin key (env) | `firebase-admin-key-public-env` | Critical | `NEXT_PUBLIC_*` env var holding a Firebase Admin SDK key — gets inlined into the client bundle at build time | Move it to a server-only env var, rotate the key in Firebase Console if it's ever been deployed |
|
|
17
|
+
| Committed Firebase service account key | `firebase-service-account-key-committed` | Critical | A literal Admin SDK service-account JSON key file checked into the repo | Delete the file from the repo & git history, rotate the key in Firebase Console, store the new one in a secret manager / env var |
|
|
18
|
+
| Vulnerable dependency (osv-scanner) | varies (GHSA id) | Derived from CVSS score (≥9 critical, ≥7 high, ≥4 medium, else low) | A dependency with a known CVE | Update to the patched version named in the advisory |
|
|
19
|
+
|
|
20
|
+
Two checks (leaked secrets, vulnerable dependencies) delegate to gitleaks/osv-scanner, so
|
|
21
|
+
their `ruleId` varies per finding instead of being fixed — everything else in this table is
|
|
22
|
+
custom, Next.js/Supabase/Firebase-specific pattern matching.
|
|
14
23
|
|
|
15
24
|
## CLI Usage
|
|
16
25
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, extname, relative, sep } from "node:path";
|
|
3
3
|
import { collectFiles, SOURCE_EXTENSIONS } from "./fsWalk.js";
|
|
4
|
+
import { envFileTrackingContext, listTrackedFiles } from "./gitUtils.js";
|
|
4
5
|
// NEXT_PUBLIC_* env vars get inlined into the client bundle at build time —
|
|
5
6
|
// a Firebase Admin SDK key under that prefix is a guaranteed leak.
|
|
6
7
|
const PUBLIC_ADMIN_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:FIREBASE_PRIVATE_KEY|FIREBASE_ADMIN|SERVICE_ACCOUNT)[A-Z0-9_]*/i;
|
|
@@ -34,6 +35,7 @@ export function scanTextForCommittedServiceAccountKey(content) {
|
|
|
34
35
|
}
|
|
35
36
|
export async function findExposedFirebaseAdminKeys(targetDir) {
|
|
36
37
|
const files = await collectFiles(targetDir, isScannableFile);
|
|
38
|
+
const tracked = await listTrackedFiles(targetDir);
|
|
37
39
|
const findings = [];
|
|
38
40
|
for (const file of files) {
|
|
39
41
|
const content = await readFile(file, "utf8");
|
|
@@ -43,7 +45,7 @@ export async function findExposedFirebaseAdminKeys(targetDir) {
|
|
|
43
45
|
file: relFile,
|
|
44
46
|
line: match.line,
|
|
45
47
|
ruleId: "firebase-admin-key-public-env",
|
|
46
|
-
description: `Env var publik "${match.variableName}" kelihatan nyimpen Firebase Admin SDK key — Next.js bakal inline ini ke client bundle
|
|
48
|
+
description: `Env var publik "${match.variableName}" kelihatan nyimpen Firebase Admin SDK key — Next.js bakal inline ini ke client bundle.${envFileTrackingContext(relFile, tracked)}`,
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
for (const match of scanTextForCommittedServiceAccountKey(content)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, extname, relative, sep } from "node:path";
|
|
3
3
|
import { collectFiles, SOURCE_EXTENSIONS } from "./fsWalk.js";
|
|
4
|
+
import { envFileTrackingContext, listTrackedFiles } from "./gitUtils.js";
|
|
4
5
|
// NEXT_PUBLIC_* env vars get inlined into the client bundle at build time —
|
|
5
6
|
// a Supabase service role key under that prefix is a guaranteed leak.
|
|
6
7
|
const EXPOSED_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:SERVICE_ROLE|SERVICE_KEY)[A-Z0-9_]*/i;
|
|
@@ -23,15 +24,17 @@ function isScannableFile(filePath) {
|
|
|
23
24
|
}
|
|
24
25
|
export async function findExposedServiceRoleKeys(targetDir) {
|
|
25
26
|
const files = await collectFiles(targetDir, isScannableFile);
|
|
27
|
+
const tracked = await listTrackedFiles(targetDir);
|
|
26
28
|
const findings = [];
|
|
27
29
|
for (const file of files) {
|
|
28
30
|
const content = await readFile(file, "utf8");
|
|
31
|
+
const relFile = relative(targetDir, file).split(sep).join("/");
|
|
29
32
|
for (const match of scanTextForExposedServiceRoleKey(content)) {
|
|
30
33
|
findings.push({
|
|
31
|
-
file:
|
|
34
|
+
file: relFile,
|
|
32
35
|
line: match.line,
|
|
33
36
|
ruleId: "supabase-service-role-key-public-env",
|
|
34
|
-
description: `Env var publik "${match.variableName}" kelihatan nyimpen Supabase service role key — Next.js bakal inline ini ke client bundle
|
|
37
|
+
description: `Env var publik "${match.variableName}" kelihatan nyimpen Supabase service role key — Next.js bakal inline ini ke client bundle.${envFileTrackingContext(relFile, tracked)}`,
|
|
35
38
|
});
|
|
36
39
|
}
|
|
37
40
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
// Returns the set of forward-slash relative paths staged/committed in
|
|
4
|
+
// targetDir's git index, or null when targetDir isn't a git repo (no
|
|
5
|
+
// tracking info to report).
|
|
6
|
+
export function listTrackedFiles(targetDir) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const child = spawn("git", ["-C", targetDir, "ls-files", "-z", "--cached"], {
|
|
9
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
child.stdout.on("data", (chunk) => {
|
|
13
|
+
stdout += chunk.toString();
|
|
14
|
+
});
|
|
15
|
+
child.on("error", () => resolve(null));
|
|
16
|
+
child.on("close", (code) => {
|
|
17
|
+
if (code !== 0) {
|
|
18
|
+
resolve(null);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
resolve(new Set(stdout.split("\0").filter((f) => f.length > 0)));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const TRACKED_CONTEXT = " File ini ke-track di git — kemungkinan udah ke-commit ke repo.";
|
|
26
|
+
const UNTRACKED_CONTEXT = " File ini gitignored atau belum ke-commit — tapi kalau var ini pernah dipasang di production (Vercel dst) dengan prefix NEXT_PUBLIC_, tetap ke-inline ke client bundle.";
|
|
27
|
+
// Two checks (exposedServiceRoleKey, exposedFirebaseAdminKey) scan raw
|
|
28
|
+
// filesystem .env files without git-awareness, so a gitignored/local-only
|
|
29
|
+
// var reads the same as a committed one. Appending this note keeps their
|
|
30
|
+
// CRITICAL severity honest about which case it actually is.
|
|
31
|
+
export function envFileTrackingContext(relFile, tracked) {
|
|
32
|
+
if (!basename(relFile).startsWith(".env"))
|
|
33
|
+
return "";
|
|
34
|
+
if (tracked === null)
|
|
35
|
+
return "";
|
|
36
|
+
return tracked.has(relFile) ? TRACKED_CONTEXT : UNTRACKED_CONTEXT;
|
|
37
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { findExposedServiceRoleKeys } from "./checks/exposedServiceRoleKey.js";
|
|
|
11
11
|
import { findOpenFirebaseRules } from "./checks/firebaseRulesOpen.js";
|
|
12
12
|
import { findRlsDisabledTables } from "./checks/rlsDisabled.js";
|
|
13
13
|
import { GitleaksNotFoundError, runSecretScan } from "./checks/secretScan.js";
|
|
14
|
+
import { checkLicense } from "./licenseCheck.js";
|
|
14
15
|
import { applyIgnoreRules, buildReport, formatReport } from "./report.js";
|
|
15
16
|
const IGNORE_FILE = ".secanix.json";
|
|
16
17
|
// Missing file = no suppression (default, zero-friction). Malformed file
|
|
@@ -115,10 +116,28 @@ export async function run(targetDir = process.cwd(), options = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
return 0;
|
|
117
118
|
}
|
|
119
|
+
export async function runLicenseCheck(key) {
|
|
120
|
+
const result = await checkLicense(key);
|
|
121
|
+
if (result.status === "valid") {
|
|
122
|
+
console.log("secanix Pro license valid.");
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
if (result.status === "invalid") {
|
|
126
|
+
console.error(`secanix Pro license invalid: ${result.message}`);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
console.error(`secanix Pro license check inconclusive: ${result.message}`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
118
132
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
119
133
|
if (isMain) {
|
|
120
134
|
const args = process.argv.slice(2);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
if (args[0] === "license-check") {
|
|
136
|
+
process.exitCode = await runLicenseCheck(process.env.SECANIX_LICENSE_KEY);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const json = args.includes("--json");
|
|
140
|
+
const targetDir = args.find((arg) => arg !== "--json" && !arg.startsWith("-"));
|
|
141
|
+
process.exitCode = await run(targetDir, { json });
|
|
142
|
+
}
|
|
124
143
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const VALIDATE_URL = "https://api.lemonsqueezy.com/v1/licenses/validate";
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
export async function checkLicense(key) {
|
|
4
|
+
if (!key) {
|
|
5
|
+
return { status: "invalid", message: "SECANIX_LICENSE_KEY is missing or empty." };
|
|
6
|
+
}
|
|
7
|
+
let response;
|
|
8
|
+
try {
|
|
9
|
+
response = await fetch(VALIDATE_URL, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { Accept: "application/json" },
|
|
12
|
+
body: new URLSearchParams({ license_key: key }),
|
|
13
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
return { status: "error", message: `Could not reach LemonSqueezy: ${err.message}` };
|
|
18
|
+
}
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
body = await response.json();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { status: "error", message: "LemonSqueezy returned a non-JSON response." };
|
|
25
|
+
}
|
|
26
|
+
if (typeof body !== "object" || body === null || !("valid" in body)) {
|
|
27
|
+
return { status: "error", message: "Unexpected response shape from LemonSqueezy." };
|
|
28
|
+
}
|
|
29
|
+
const { valid, error } = body;
|
|
30
|
+
if (valid === true) {
|
|
31
|
+
return { status: "valid" };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
status: "invalid",
|
|
35
|
+
message: typeof error === "string" ? error : "License key is invalid or expired.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
[extend]
|
|
2
|
-
useDefault = true
|
|
3
|
-
|
|
4
|
-
[allowlist]
|
|
5
|
-
paths = [
|
|
6
|
-
'''(^|/)node_modules(/|$)''',
|
|
7
|
-
'''(^|/)\.git(/|$)''',
|
|
8
|
-
'''(^|/)\.next(/|$)''',
|
|
9
|
-
'''(^|/)dist(/|$)''',
|
|
10
|
-
'''(^|/)build(/|$)''',
|
|
11
|
-
'''(^|/)out(/|$)''',
|
|
12
|
-
'''(^|/)coverage(/|$)''',
|
|
13
|
-
]
|
|
1
|
+
[extend]
|
|
2
|
+
useDefault = true
|
|
3
|
+
|
|
4
|
+
[allowlist]
|
|
5
|
+
paths = [
|
|
6
|
+
'''(^|/)node_modules(/|$)''',
|
|
7
|
+
'''(^|/)\.git(/|$)''',
|
|
8
|
+
'''(^|/)\.next(/|$)''',
|
|
9
|
+
'''(^|/)dist(/|$)''',
|
|
10
|
+
'''(^|/)build(/|$)''',
|
|
11
|
+
'''(^|/)out(/|$)''',
|
|
12
|
+
'''(^|/)coverage(/|$)''',
|
|
13
|
+
]
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
rules:
|
|
2
|
-
- id: nextjs-api-route-missing-auth
|
|
3
|
-
languages: [typescript, javascript]
|
|
4
|
-
severity: WARNING
|
|
5
|
-
message: >-
|
|
6
|
-
API route Next.js ini kelihatan gak ada auth check (session/token) sebelum jalanin logic.
|
|
7
|
-
paths:
|
|
8
|
-
include:
|
|
9
|
-
- "pages/api/**"
|
|
10
|
-
- "src/pages/api/**"
|
|
11
|
-
- "app/**/route.ts"
|
|
12
|
-
- "app/**/route.js"
|
|
13
|
-
- "src/app/**/route.ts"
|
|
14
|
-
- "src/app/**/route.js"
|
|
15
|
-
patterns:
|
|
16
|
-
- pattern-either:
|
|
17
|
-
- pattern: export default function $HANDLER(...) { ... }
|
|
18
|
-
- pattern: export default async function $HANDLER(...) { ... }
|
|
19
|
-
- pattern: export async function GET(...) { ... }
|
|
20
|
-
- pattern: export async function POST(...) { ... }
|
|
21
|
-
- pattern: export async function PUT(...) { ... }
|
|
22
|
-
- pattern: export async function PATCH(...) { ... }
|
|
23
|
-
- pattern: export async function DELETE(...) { ... }
|
|
24
|
-
- pattern-not-regex: (?i)(getServerSession|getToken|currentUser|verifyAuth|requireAuth|withAuth|auth\(\)|\.auth\.getUser|\.auth\.getSession|jwt\.verify|isAuthenticated|checkAuth)
|
|
1
|
+
rules:
|
|
2
|
+
- id: nextjs-api-route-missing-auth
|
|
3
|
+
languages: [typescript, javascript]
|
|
4
|
+
severity: WARNING
|
|
5
|
+
message: >-
|
|
6
|
+
API route Next.js ini kelihatan gak ada auth check (session/token) sebelum jalanin logic.
|
|
7
|
+
paths:
|
|
8
|
+
include:
|
|
9
|
+
- "pages/api/**"
|
|
10
|
+
- "src/pages/api/**"
|
|
11
|
+
- "app/**/route.ts"
|
|
12
|
+
- "app/**/route.js"
|
|
13
|
+
- "src/app/**/route.ts"
|
|
14
|
+
- "src/app/**/route.js"
|
|
15
|
+
patterns:
|
|
16
|
+
- pattern-either:
|
|
17
|
+
- pattern: export default function $HANDLER(...) { ... }
|
|
18
|
+
- pattern: export default async function $HANDLER(...) { ... }
|
|
19
|
+
- pattern: export async function GET(...) { ... }
|
|
20
|
+
- pattern: export async function POST(...) { ... }
|
|
21
|
+
- pattern: export async function PUT(...) { ... }
|
|
22
|
+
- pattern: export async function PATCH(...) { ... }
|
|
23
|
+
- pattern: export async function DELETE(...) { ... }
|
|
24
|
+
- pattern-not-regex: (?i)(getServerSession|getToken|currentUser|verifyAuth|requireAuth|withAuth|auth\(\)|\.auth\.getUser|\.auth\.getSession|jwt\.verify|isAuthenticated|checkAuth)
|