secanix 0.1.2

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 ADDED
@@ -0,0 +1,42 @@
1
+ # Secanix
2
+
3
+ Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).
4
+
5
+ Checks it runs:
6
+ - Leaked secrets (API keys, tokens, credentials committed to the repo)
7
+ - Exposed Supabase service role keys
8
+ - Missing auth on Next.js API routes
9
+ - Disabled Supabase Row Level Security (RLS)
10
+ - CORS wildcard origins
11
+ - Vulnerable dependencies (known CVEs)
12
+
13
+ ## CLI Usage
14
+
15
+ ```
16
+ npx -p secanix@latest secanix
17
+ ```
18
+ Runs all checks against the current directory and prints a human-readable report.
19
+
20
+ ```
21
+ npx -p secanix@latest secanix --json
22
+ ```
23
+ Same scan, machine-readable JSON output — useful for piping into other tooling.
24
+
25
+ ## GitHub Action
26
+
27
+ Add to `.github/workflows/security-scan.yml` in your repo:
28
+
29
+ ```yaml
30
+ name: Security Scan
31
+ on: pull_request
32
+ permissions:
33
+ pull-requests: write
34
+ jobs:
35
+ scan:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: cutryandifonna/secanix@v1
40
+ ```
41
+
42
+ The action fails CI when a critical finding is present (leaked secret, exposed Supabase service role key, or RLS disabled). It posts and updates a single PR comment listing all findings by severity. This requires the consuming workflow to grant `permissions: pull-requests: write` itself, as shown above — without it, comment posting fails with a 403. GitHub-hosted `ubuntu-latest` runners (uses `sudo` for tool installs — self-hosted runners need equivalent permissions).
@@ -0,0 +1,79 @@
1
+ import { spawn } from "node:child_process";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const RULE_PATH = join(__dirname, "..", "rules", "nextjs-api-auth-missing.yaml");
6
+ export class SemgrepNotFoundError extends Error {
7
+ constructor() {
8
+ super("semgrep gak ketemu di PATH. Install: https://semgrep.dev/docs/getting-started/");
9
+ this.name = "SemgrepNotFoundError";
10
+ }
11
+ }
12
+ function runSemgrepProcess(args) {
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn("semgrep", args, { stdio: ["ignore", "pipe", "pipe"] });
15
+ let stdout = "";
16
+ let stderr = "";
17
+ child.stdout.on("data", (chunk) => {
18
+ stdout += chunk.toString();
19
+ });
20
+ child.stderr.on("data", (chunk) => {
21
+ stderr += chunk.toString();
22
+ });
23
+ child.on("error", (err) => {
24
+ if (err.code === "ENOENT") {
25
+ reject(new SemgrepNotFoundError());
26
+ }
27
+ else {
28
+ reject(err);
29
+ }
30
+ });
31
+ child.on("close", (code) => {
32
+ // semgrep exits 1 when it has findings — that's not a run failure.
33
+ if (code === 0 || code === 1) {
34
+ resolve(stdout);
35
+ }
36
+ else {
37
+ reject(new Error(`semgrep exit code ${code}: ${stderr.trim()}`));
38
+ }
39
+ });
40
+ });
41
+ }
42
+ export function parseSemgrepReport(json) {
43
+ const trimmed = json.trim();
44
+ if (trimmed.length === 0)
45
+ return [];
46
+ const raw = JSON.parse(trimmed);
47
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.results)) {
48
+ return [];
49
+ }
50
+ const findings = [];
51
+ for (const entry of raw.results) {
52
+ if (typeof entry.path !== "string" ||
53
+ typeof entry.check_id !== "string" ||
54
+ typeof entry.start?.line !== "number") {
55
+ continue;
56
+ }
57
+ findings.push({
58
+ file: entry.path,
59
+ line: entry.start.line,
60
+ ruleId: entry.check_id.split(".").pop() ?? entry.check_id,
61
+ description: entry.extra?.message?.trim() ?? entry.check_id,
62
+ });
63
+ }
64
+ return findings;
65
+ }
66
+ export async function findMissingApiAuth(targetDir) {
67
+ const stdout = await runSemgrepProcess([
68
+ "--config",
69
+ RULE_PATH,
70
+ "--json",
71
+ "--quiet",
72
+ "--no-git-ignore",
73
+ targetDir,
74
+ ]);
75
+ return parseSemgrepReport(stdout).map((finding) => ({
76
+ ...finding,
77
+ file: relative(targetDir, resolve(targetDir, finding.file)).split(sep).join("/"),
78
+ }));
79
+ }
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, relative, sep } from "node:path";
3
+ import { collectFiles, SOURCE_EXTENSIONS } from "./fsWalk.js";
4
+ // Manual header set: res.setHeader('Access-Control-Allow-Origin', '*'),
5
+ // or an object literal { 'Access-Control-Allow-Origin': '*' }.
6
+ const DIRECT_HEADER_PATTERN = /access-control-allow-origin['"]?\s*[:,]\s*['"]\*['"]/i;
7
+ // next.config.js headers() array format: { key: '...', value: '*' } on
8
+ // separate lines — scanned across the whole file, not line-by-line.
9
+ const NEXT_CONFIG_HEADER_PATTERN = /key\s*:\s*['"]Access-Control-Allow-Origin['"][\s\S]{0,80}?value\s*:\s*['"]\*['"]/gi;
10
+ // cors npm package: cors({ origin: '*' }).
11
+ const CORS_PACKAGE_PATTERN = /\bcors\s*\(\s*\{[\s\S]{0,200}?origin\s*:\s*['"]\*['"]/gi;
12
+ function isScannableFile(filePath) {
13
+ return SOURCE_EXTENSIONS.has(extname(filePath));
14
+ }
15
+ function lineAt(content, index) {
16
+ return content.slice(0, index).split(/\r?\n/).length;
17
+ }
18
+ export function scanTextForCorsWildcard(content) {
19
+ const lines = [];
20
+ content.split(/\r?\n/).forEach((line, index) => {
21
+ if (DIRECT_HEADER_PATTERN.test(line))
22
+ lines.push(index + 1);
23
+ });
24
+ for (const match of content.matchAll(NEXT_CONFIG_HEADER_PATTERN)) {
25
+ lines.push(lineAt(content, match.index));
26
+ }
27
+ for (const match of content.matchAll(CORS_PACKAGE_PATTERN)) {
28
+ lines.push(lineAt(content, match.index));
29
+ }
30
+ return [...new Set(lines)].sort((a, b) => a - b).map((line) => ({ line }));
31
+ }
32
+ export async function findCorsWildcard(targetDir) {
33
+ const files = await collectFiles(targetDir, isScannableFile);
34
+ const findings = [];
35
+ for (const file of files) {
36
+ const content = await readFile(file, "utf8");
37
+ for (const match of scanTextForCorsWildcard(content)) {
38
+ findings.push({
39
+ file: relative(targetDir, file).split(sep).join("/"),
40
+ line: match.line,
41
+ ruleId: "cors-wildcard-origin",
42
+ description: 'CORS "Access-Control-Allow-Origin" di-set ke "*" — semua origin bisa akses endpoint ini, termasuk yang jahat.',
43
+ });
44
+ }
45
+ }
46
+ return findings;
47
+ }
@@ -0,0 +1,112 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { relative, resolve, sep } from "node:path";
4
+ export class OsvScannerNotFoundError extends Error {
5
+ constructor() {
6
+ super("osv-scanner gak ketemu di PATH. Install: https://google.github.io/osv-scanner/installation/");
7
+ this.name = "OsvScannerNotFoundError";
8
+ }
9
+ }
10
+ function runOsvScannerProcess(args) {
11
+ return new Promise((resolvePromise, reject) => {
12
+ const child = spawn("osv-scanner", args, { stdio: ["ignore", "pipe", "pipe"] });
13
+ let stdout = "";
14
+ let stderr = "";
15
+ child.stdout.on("data", (chunk) => {
16
+ stdout += chunk.toString();
17
+ });
18
+ child.stderr.on("data", (chunk) => {
19
+ stderr += chunk.toString();
20
+ });
21
+ child.on("error", (err) => {
22
+ if (err.code === "ENOENT") {
23
+ reject(new OsvScannerNotFoundError());
24
+ }
25
+ else {
26
+ reject(err);
27
+ }
28
+ });
29
+ child.on("close", (code) => {
30
+ // osv-scanner exits 1 when it finds vulnerabilities — not a run failure.
31
+ if (code === 0 || code === 1) {
32
+ resolvePromise(stdout);
33
+ }
34
+ else {
35
+ reject(new Error(`osv-scanner exit code ${code}: ${stderr.trim()}`));
36
+ }
37
+ });
38
+ });
39
+ }
40
+ export function parseOsvReport(json) {
41
+ const trimmed = json.trim();
42
+ if (trimmed.length === 0)
43
+ return [];
44
+ const raw = JSON.parse(trimmed);
45
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.results)) {
46
+ return [];
47
+ }
48
+ const findings = [];
49
+ for (const result of raw.results) {
50
+ if (typeof result.source?.path !== "string")
51
+ continue;
52
+ for (const pkg of result.packages ?? []) {
53
+ if (typeof pkg.package?.name !== "string" || typeof pkg.package?.version !== "string")
54
+ continue;
55
+ for (const group of pkg.groups ?? []) {
56
+ const ruleId = group.ids?.[0];
57
+ if (typeof ruleId !== "string")
58
+ continue;
59
+ const aliases = (group.aliases ?? []).join(", ") || ruleId;
60
+ const severity = group.max_severity ? ` (severity ${group.max_severity})` : "";
61
+ findings.push({
62
+ file: result.source.path,
63
+ packageName: pkg.package.name,
64
+ ruleId,
65
+ description: `${pkg.package.name}@${pkg.package.version} kena kerentanan dikenal: ${aliases}${severity}.`,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ return findings;
71
+ }
72
+ // osv-scanner's JSON output doesn't carry line numbers — best-effort locate
73
+ // the package name's first mention in its own lockfile as an anchor.
74
+ function findLineForPackage(content, packageName) {
75
+ const needle = `"${packageName}"`;
76
+ const lines = content.split(/\r?\n/);
77
+ const index = lines.findIndex((line) => line.includes(needle));
78
+ return index === -1 ? 1 : index + 1;
79
+ }
80
+ export async function findDependencyVulnerabilities(targetDir) {
81
+ const stdout = await runOsvScannerProcess([
82
+ "scan",
83
+ "source",
84
+ "-r",
85
+ "--allow-no-lockfiles",
86
+ "--format",
87
+ "json",
88
+ targetDir,
89
+ ]);
90
+ const rawFindings = parseOsvReport(stdout);
91
+ const contentCache = new Map();
92
+ const findings = [];
93
+ for (const raw of rawFindings) {
94
+ // osv-scanner's source.path may be relative to targetDir rather than to
95
+ // our own cwd (e.g. when targetDir is a subdirectory) — resolve once and
96
+ // reuse it for both the line lookup and the reported file path so they
97
+ // agree on the same file.
98
+ const absolutePath = resolve(targetDir, raw.file);
99
+ let content = contentCache.get(raw.file);
100
+ if (content === undefined) {
101
+ content = await readFile(absolutePath, "utf8").catch(() => "");
102
+ contentCache.set(raw.file, content);
103
+ }
104
+ findings.push({
105
+ file: relative(targetDir, absolutePath).split(sep).join("/"),
106
+ line: findLineForPackage(content, raw.packageName),
107
+ ruleId: raw.ruleId,
108
+ description: raw.description,
109
+ });
110
+ }
111
+ return findings;
112
+ }
@@ -0,0 +1,39 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, extname, relative, sep } from "node:path";
3
+ import { collectFiles, SOURCE_EXTENSIONS } from "./fsWalk.js";
4
+ // NEXT_PUBLIC_* env vars get inlined into the client bundle at build time —
5
+ // a Supabase service role key under that prefix is a guaranteed leak.
6
+ const EXPOSED_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:SERVICE_ROLE|SERVICE_KEY)[A-Z0-9_]*/i;
7
+ export function scanTextForExposedServiceRoleKey(content) {
8
+ const matches = [];
9
+ const lines = content.split(/\r?\n/);
10
+ lines.forEach((line, index) => {
11
+ const match = EXPOSED_KEY_PATTERN.exec(line);
12
+ if (match) {
13
+ matches.push({ line: index + 1, variableName: match[0] });
14
+ }
15
+ });
16
+ return matches;
17
+ }
18
+ function isScannableFile(filePath) {
19
+ const name = basename(filePath);
20
+ if (name.startsWith(".env"))
21
+ return true;
22
+ return SOURCE_EXTENSIONS.has(extname(filePath));
23
+ }
24
+ export async function findExposedServiceRoleKeys(targetDir) {
25
+ const files = await collectFiles(targetDir, isScannableFile);
26
+ const findings = [];
27
+ for (const file of files) {
28
+ const content = await readFile(file, "utf8");
29
+ for (const match of scanTextForExposedServiceRoleKey(content)) {
30
+ findings.push({
31
+ file: relative(targetDir, file).split(sep).join("/"),
32
+ line: match.line,
33
+ 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.`,
35
+ });
36
+ }
37
+ }
38
+ return findings;
39
+ }
@@ -0,0 +1,20 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const IGNORED_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "out", "coverage"]);
4
+ export const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
5
+ export async function collectFiles(dir, isScannable) {
6
+ const entries = await readdir(dir, { withFileTypes: true });
7
+ const files = [];
8
+ for (const entry of entries) {
9
+ const fullPath = join(dir, entry.name);
10
+ if (entry.isDirectory()) {
11
+ if (IGNORED_DIRS.has(entry.name))
12
+ continue;
13
+ files.push(...(await collectFiles(fullPath, isScannable)));
14
+ }
15
+ else if (entry.isFile() && isScannable(fullPath)) {
16
+ files.push(fullPath);
17
+ }
18
+ }
19
+ return files;
20
+ }
@@ -0,0 +1,67 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, relative, sep } from "node:path";
3
+ import { collectFiles } from "./fsWalk.js";
4
+ const CREATE_TABLE_PATTERN = /create\s+table\s+(?:if\s+not\s+exists\s+)?(?:"?\w+"?\.)?"?(\w+)"?/i;
5
+ const ENABLE_RLS_PATTERN = /alter\s+table\s+(?:only\s+)?(?:"?\w+"?\.)?"?(\w+)"?\s+enable\s+row\s+level\s+security/gi;
6
+ const DISABLE_RLS_PATTERN = /alter\s+table\s+(?:only\s+)?(?:"?\w+"?\.)?"?(\w+)"?\s+disable\s+row\s+level\s+security/gi;
7
+ function isSqlFile(filePath) {
8
+ return extname(filePath) === ".sql";
9
+ }
10
+ // Migrations put one statement per line in practice — scanning line-by-line
11
+ // is enough to recover a usable line number without a real SQL parser.
12
+ export function findCreatedTables(content) {
13
+ const tables = [];
14
+ content.split(/\r?\n/).forEach((line, index) => {
15
+ const match = CREATE_TABLE_PATTERN.exec(line);
16
+ if (match)
17
+ tables.push({ name: match[1].toLowerCase(), line: index + 1 });
18
+ });
19
+ return tables;
20
+ }
21
+ function collectTableNames(content, pattern) {
22
+ const names = new Set();
23
+ for (const match of content.matchAll(pattern)) {
24
+ names.add(match[1].toLowerCase());
25
+ }
26
+ return names;
27
+ }
28
+ // RLS enable/disable statements are often in a different migration file than
29
+ // the CREATE TABLE — so this correlates table names across every .sql file
30
+ // in the project rather than checking each file in isolation.
31
+ export async function findRlsDisabledTables(targetDir) {
32
+ const files = await collectFiles(targetDir, isSqlFile);
33
+ const createdTables = [];
34
+ const enabledTables = new Set();
35
+ const disabledTables = new Set();
36
+ for (const file of files) {
37
+ const content = await readFile(file, "utf8");
38
+ const relFile = relative(targetDir, file).split(sep).join("/");
39
+ for (const table of findCreatedTables(content)) {
40
+ createdTables.push({ ...table, file: relFile });
41
+ }
42
+ for (const name of collectTableNames(content, ENABLE_RLS_PATTERN))
43
+ enabledTables.add(name);
44
+ for (const name of collectTableNames(content, DISABLE_RLS_PATTERN))
45
+ disabledTables.add(name);
46
+ }
47
+ const findings = [];
48
+ for (const table of createdTables) {
49
+ if (disabledTables.has(table.name)) {
50
+ findings.push({
51
+ file: table.file,
52
+ line: table.line,
53
+ ruleId: "supabase-rls-disabled",
54
+ description: `Tabel "${table.name}" RLS-nya di-disable eksplisit — data bisa diakses tanpa policy.`,
55
+ });
56
+ }
57
+ else if (!enabledTables.has(table.name)) {
58
+ findings.push({
59
+ file: table.file,
60
+ line: table.line,
61
+ ruleId: "supabase-rls-missing",
62
+ description: `Tabel "${table.name}" gak ketemu "ENABLE ROW LEVEL SECURITY" di migration manapun — kemungkinan RLS mati.`,
63
+ });
64
+ }
65
+ }
66
+ return findings;
67
+ }
@@ -0,0 +1,84 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ export class GitleaksNotFoundError extends Error {
6
+ constructor() {
7
+ super("gitleaks gak ketemu di PATH. Install: https://github.com/gitleaks/gitleaks#installing");
8
+ this.name = "GitleaksNotFoundError";
9
+ }
10
+ }
11
+ function runGitleaksProcess(args) {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn("gitleaks", args, { stdio: ["ignore", "ignore", "pipe"] });
14
+ let stderr = "";
15
+ child.stderr.on("data", (chunk) => {
16
+ stderr += chunk.toString();
17
+ });
18
+ child.on("error", (err) => {
19
+ if (err.code === "ENOENT") {
20
+ reject(new GitleaksNotFoundError());
21
+ }
22
+ else {
23
+ reject(err);
24
+ }
25
+ });
26
+ child.on("close", (code) => {
27
+ if (code === 0) {
28
+ resolve();
29
+ }
30
+ else {
31
+ reject(new Error(`gitleaks exit code ${code}: ${stderr.trim()}`));
32
+ }
33
+ });
34
+ });
35
+ }
36
+ // gitleaks report never carries the raw secret value into a Finding here —
37
+ // printing/logging matched secrets would itself be a leak.
38
+ export function parseGitleaksReport(json) {
39
+ const trimmed = json.trim();
40
+ if (trimmed.length === 0)
41
+ return [];
42
+ const raw = JSON.parse(trimmed);
43
+ if (!Array.isArray(raw))
44
+ return [];
45
+ const findings = [];
46
+ for (const entry of raw) {
47
+ if (typeof entry.File !== "string" ||
48
+ typeof entry.StartLine !== "number" ||
49
+ typeof entry.RuleID !== "string") {
50
+ continue;
51
+ }
52
+ findings.push({
53
+ file: entry.File,
54
+ line: entry.StartLine,
55
+ ruleId: entry.RuleID,
56
+ description: entry.Description ?? entry.RuleID,
57
+ });
58
+ }
59
+ return findings;
60
+ }
61
+ export async function runSecretScan(targetDir) {
62
+ const tempDir = await mkdtemp(join(tmpdir(), "vibe-secret-scan-"));
63
+ const reportPath = join(tempDir, "gitleaks-report.json");
64
+ try {
65
+ await runGitleaksProcess([
66
+ "detect",
67
+ "--source",
68
+ targetDir,
69
+ "--no-git",
70
+ "--no-banner",
71
+ "--report-format",
72
+ "json",
73
+ "--report-path",
74
+ reportPath,
75
+ "--exit-code",
76
+ "0",
77
+ ]);
78
+ const content = await readFile(reportPath, "utf8").catch(() => "");
79
+ return parseGitleaksReport(content);
80
+ }
81
+ finally {
82
+ await rm(tempDir, { recursive: true, force: true });
83
+ }
84
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ import { findMissingApiAuth, SemgrepNotFoundError } from "./checks/apiAuthMissing.js";
5
+ import { findCorsWildcard } from "./checks/corsWildcard.js";
6
+ import { findDependencyVulnerabilities, OsvScannerNotFoundError } from "./checks/dependencyVulnerabilities.js";
7
+ import { findExposedServiceRoleKeys } from "./checks/exposedServiceRoleKey.js";
8
+ import { findRlsDisabledTables } from "./checks/rlsDisabled.js";
9
+ import { GitleaksNotFoundError, runSecretScan } from "./checks/secretScan.js";
10
+ import { buildReport, formatReport } from "./report.js";
11
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
12
+ export async function run(targetDir = process.cwd(), options = {}) {
13
+ const { json = false } = options;
14
+ if (!json) {
15
+ console.log(`Secanix v${version}`);
16
+ }
17
+ const checkFindings = [];
18
+ try {
19
+ checkFindings.push({ checkId: "secret-scan", findings: await runSecretScan(targetDir) });
20
+ }
21
+ catch (err) {
22
+ if (err instanceof GitleaksNotFoundError) {
23
+ console.error(err.message);
24
+ return 1;
25
+ }
26
+ throw err;
27
+ }
28
+ checkFindings.push({
29
+ checkId: "exposed-service-role-key",
30
+ findings: await findExposedServiceRoleKeys(targetDir),
31
+ });
32
+ try {
33
+ checkFindings.push({ checkId: "api-auth-missing", findings: await findMissingApiAuth(targetDir) });
34
+ }
35
+ catch (err) {
36
+ if (err instanceof SemgrepNotFoundError) {
37
+ console.error(err.message);
38
+ return 1;
39
+ }
40
+ throw err;
41
+ }
42
+ checkFindings.push({ checkId: "rls-disabled", findings: await findRlsDisabledTables(targetDir) });
43
+ checkFindings.push({ checkId: "cors-wildcard", findings: await findCorsWildcard(targetDir) });
44
+ try {
45
+ checkFindings.push({
46
+ checkId: "dependency-cve",
47
+ findings: await findDependencyVulnerabilities(targetDir),
48
+ });
49
+ }
50
+ catch (err) {
51
+ if (err instanceof OsvScannerNotFoundError) {
52
+ console.error(err.message);
53
+ return 1;
54
+ }
55
+ throw err;
56
+ }
57
+ const reported = buildReport(checkFindings);
58
+ if (json) {
59
+ console.log(JSON.stringify(reported));
60
+ }
61
+ else {
62
+ for (const line of formatReport(reported)) {
63
+ console.log(line);
64
+ }
65
+ }
66
+ return 0;
67
+ }
68
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
69
+ if (isMain) {
70
+ const args = process.argv.slice(2);
71
+ const json = args.includes("--json");
72
+ const targetDir = args.find((arg) => arg !== "--json" && !arg.startsWith("-"));
73
+ process.exit(await run(targetDir, { json }));
74
+ }
package/dist/report.js ADDED
@@ -0,0 +1,89 @@
1
+ // Static lookup for checks whose ruleId is a fixed, known-ahead-of-time set.
2
+ const RULE_INFO = {
3
+ "supabase-service-role-key-public-env": {
4
+ severity: "critical",
5
+ fix: "Jangan expose service role key lewat NEXT_PUBLIC_* — pindahin ke env var server-only, lalu rotate key ini di Supabase dashboard kalau udah sempet ke-deploy.",
6
+ },
7
+ "nextjs-api-route-missing-auth": {
8
+ severity: "high",
9
+ fix: "Tambahin auth check (getServerSession/getToken/dst) di awal handler sebelum jalanin logic apapun.",
10
+ },
11
+ "supabase-rls-disabled": {
12
+ severity: "critical",
13
+ fix: "Enable lagi RLS-nya (ALTER TABLE ... ENABLE ROW LEVEL SECURITY) dan pasang policy yang sesuai — jangan biarin RLS mati di production.",
14
+ },
15
+ "supabase-rls-missing": {
16
+ severity: "high",
17
+ fix: "Tambahin ALTER TABLE ... ENABLE ROW LEVEL SECURITY + policy buat tabel ini.",
18
+ },
19
+ "cors-wildcard-origin": {
20
+ severity: "medium",
21
+ fix: "Ganti '*' jadi daftar origin eksplisit yang emang butuh akses, atau validasi origin secara dinamis di server.",
22
+ },
23
+ };
24
+ // gitleaks (secret-scan) and osv-scanner (dependency-cve) each emit a
25
+ // ruleId that varies per finding (rule name / GHSA id), so they're
26
+ // classified by checkId instead of an exhaustive per-ruleId table.
27
+ const SECRET_SCAN_FIX = "Cabut/rotate secret ini sekarang, hapus dari kode & git history (bukan cuma commit baru), pindahin ke env var / secret manager.";
28
+ const DEPENDENCY_CVE_FIX = "Update dependency ini ke versi yang udah di-patch (cek advisory-nya buat versi aman).";
29
+ const DEFAULT_FIX = "Cek temuan ini manual — belum ada saran otomatis buat rule ini.";
30
+ function cvssToSeverity(score) {
31
+ if (score >= 9)
32
+ return "critical";
33
+ if (score >= 7)
34
+ return "high";
35
+ if (score >= 4)
36
+ return "medium";
37
+ return "low";
38
+ }
39
+ // dependencyVulnerabilities.ts embeds the CVSS score it got from
40
+ // osv-scanner into its own description as "(severity 8.1)" — that format
41
+ // is ours to rely on since we control where it's written.
42
+ function severityFromDescription(description) {
43
+ const match = /severity (\d+(?:\.\d+)?)/.exec(description);
44
+ return match ? cvssToSeverity(Number(match[1])) : undefined;
45
+ }
46
+ export function classify(finding, checkId) {
47
+ const ruleInfo = RULE_INFO[finding.ruleId];
48
+ if (ruleInfo) {
49
+ return { ...finding, severity: ruleInfo.severity, fixSuggestion: ruleInfo.fix };
50
+ }
51
+ if (checkId === "secret-scan") {
52
+ return { ...finding, severity: "critical", fixSuggestion: SECRET_SCAN_FIX };
53
+ }
54
+ if (checkId === "dependency-cve") {
55
+ return {
56
+ ...finding,
57
+ severity: severityFromDescription(finding.description) ?? "high",
58
+ fixSuggestion: DEPENDENCY_CVE_FIX,
59
+ };
60
+ }
61
+ return { ...finding, severity: "medium", fixSuggestion: DEFAULT_FIX };
62
+ }
63
+ const SEVERITY_ORDER = ["critical", "high", "medium", "low"];
64
+ const SEVERITY_RANK = new Map(SEVERITY_ORDER.map((severity, index) => [severity, index]));
65
+ export function buildReport(checkFindings) {
66
+ const reported = checkFindings.flatMap(({ checkId, findings }) => findings.map((finding) => classify(finding, checkId)));
67
+ return reported.sort((a, b) => SEVERITY_RANK.get(a.severity) - SEVERITY_RANK.get(b.severity));
68
+ }
69
+ const SEVERITY_LABEL = {
70
+ critical: "CRITICAL",
71
+ high: "HIGH",
72
+ medium: "MEDIUM",
73
+ low: "LOW",
74
+ };
75
+ export function formatReport(reported) {
76
+ if (reported.length === 0)
77
+ return ["Nol temuan."];
78
+ const lines = [`${reported.length} temuan:`];
79
+ for (const severity of SEVERITY_ORDER) {
80
+ const group = reported.filter((finding) => finding.severity === severity);
81
+ if (group.length === 0)
82
+ continue;
83
+ lines.push(`[${SEVERITY_LABEL[severity]}] (${group.length})`);
84
+ for (const finding of group) {
85
+ lines.push(` ${finding.file}:${finding.line} — ${finding.description} Fix: ${finding.fixSuggestion}`);
86
+ }
87
+ }
88
+ return lines;
89
+ }
@@ -0,0 +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)
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "secanix",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "description": "Security scanner buat app hasil vibe-coding (Next.js + Supabase/Firebase).",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cutryandifonna/secanix.git"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "secanix": "dist/cli.js"
14
+ },
15
+ "main": "./dist/cli.js",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json && node scripts/copy-rules.mjs",
24
+ "dev": "tsx src/cli.ts",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc --noEmit"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.10.2",
30
+ "tsx": "^4.19.2",
31
+ "typescript": "^5.7.2",
32
+ "vitest": "^4.1.10"
33
+ }
34
+ }