secanix 0.1.3 → 0.1.5
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 +25 -0
- package/dist/checks/apiAuthMissing.js +20 -1
- package/dist/checks/exposedFirebaseAdminKey.js +59 -0
- package/dist/checks/firebaseRulesOpen.js +46 -0
- package/dist/checks/rlsDisabled.js +1 -1
- package/dist/checks/secretScan.js +49 -3
- package/dist/cli.js +52 -2
- package/dist/report.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ Checks it runs:
|
|
|
9
9
|
- Disabled Supabase Row Level Security (RLS)
|
|
10
10
|
- CORS wildcard origins
|
|
11
11
|
- Vulnerable dependencies (known CVEs)
|
|
12
|
+
- Open Firebase security rules (Firestore/Realtime Database/Storage)
|
|
13
|
+
- Exposed Firebase Admin SDK keys (env leak or a committed service account JSON file)
|
|
12
14
|
|
|
13
15
|
## CLI Usage
|
|
14
16
|
|
|
@@ -22,6 +24,29 @@ npx -p secanix@latest secanix --json
|
|
|
22
24
|
```
|
|
23
25
|
Same scan, machine-readable JSON output — useful for piping into other tooling.
|
|
24
26
|
|
|
27
|
+
## Suppressing false positives
|
|
28
|
+
|
|
29
|
+
Some findings are correct in general but not in your case — e.g. an API route
|
|
30
|
+
protected by `middleware.ts` instead of an in-handler check, which
|
|
31
|
+
`api-auth-missing` can't see. Add a `.secanix.json` at your project root:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"ignore": [
|
|
36
|
+
{
|
|
37
|
+
"file": "app/api/admin/route.ts",
|
|
38
|
+
"ruleId": "nextjs-api-route-missing-auth",
|
|
39
|
+
"reason": "protected by middleware.ts, matcher /api/admin/*"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Both `file` (relative path) and `ruleId` must match exactly. Suppressed
|
|
46
|
+
findings aren't silently dropped — they're printed separately (with your
|
|
47
|
+
`reason`) so they stay visible for review, not just filtered out of the JSON
|
|
48
|
+
output.
|
|
49
|
+
|
|
25
50
|
## GitHub Action
|
|
26
51
|
|
|
27
52
|
Add to `.github/workflows/security-scan.yml` in your repo:
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
2
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
const MIDDLEWARE_CANDIDATES = [
|
|
6
|
+
"middleware.ts",
|
|
7
|
+
"middleware.js",
|
|
8
|
+
join("src", "middleware.ts"),
|
|
9
|
+
join("src", "middleware.js"),
|
|
10
|
+
];
|
|
11
|
+
// A route can be protected upstream by middleware.ts instead of an in-handler
|
|
12
|
+
// check — this rule only ever sees the handler file, so it can't tell.
|
|
13
|
+
// Rather than guess at matcher-pattern coverage (getting that wrong would
|
|
14
|
+
// silently hide a genuinely unprotected route), we stay honest about the gap.
|
|
15
|
+
const MIDDLEWARE_CAVEAT = ' Kalo route ini diproteksi lewat middleware.ts, ini bisa jadi false positive — cek matcher-nya, atau suppress via .secanix.json kalo emang udah aman.';
|
|
16
|
+
async function hasMiddlewareFile(targetDir) {
|
|
17
|
+
const results = await Promise.all(MIDDLEWARE_CANDIDATES.map((candidate) => access(join(targetDir, candidate)).then(() => true, () => false)));
|
|
18
|
+
return results.some(Boolean);
|
|
19
|
+
}
|
|
4
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
21
|
const RULE_PATH = join(__dirname, "..", "rules", "nextjs-api-auth-missing.yaml");
|
|
6
22
|
export class SemgrepNotFoundError extends Error {
|
|
@@ -71,8 +87,11 @@ export async function findMissingApiAuth(targetDir) {
|
|
|
71
87
|
"--quiet",
|
|
72
88
|
targetDir,
|
|
73
89
|
]);
|
|
74
|
-
|
|
90
|
+
const findings = parseSemgrepReport(stdout).map((finding) => ({
|
|
75
91
|
...finding,
|
|
76
92
|
file: relative(targetDir, resolve(targetDir, finding.file)).split(sep).join("/"),
|
|
77
93
|
}));
|
|
94
|
+
if (findings.length === 0 || !(await hasMiddlewareFile(targetDir)))
|
|
95
|
+
return findings;
|
|
96
|
+
return findings.map((finding) => ({ ...finding, description: finding.description + MIDDLEWARE_CAVEAT }));
|
|
78
97
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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 Firebase Admin SDK key under that prefix is a guaranteed leak.
|
|
6
|
+
const PUBLIC_ADMIN_KEY_PATTERN = /NEXT_PUBLIC_[A-Z0-9_]*(?:FIREBASE_PRIVATE_KEY|FIREBASE_ADMIN|SERVICE_ACCOUNT)[A-Z0-9_]*/i;
|
|
7
|
+
// The literal downloaded Admin SDK key file always carries this exact field.
|
|
8
|
+
// gitleaks' generic PEM detector expects real newlines, not the \n-escaped
|
|
9
|
+
// key JSON.stringify produces, so a committed key file slips past secret-scan.
|
|
10
|
+
const SERVICE_ACCOUNT_JSON_PATTERN = /"type"\s*:\s*"service_account"/;
|
|
11
|
+
function isScannableFile(filePath) {
|
|
12
|
+
const name = basename(filePath);
|
|
13
|
+
if (name.startsWith(".env"))
|
|
14
|
+
return true;
|
|
15
|
+
const ext = extname(filePath);
|
|
16
|
+
return SOURCE_EXTENSIONS.has(ext) || ext === ".json";
|
|
17
|
+
}
|
|
18
|
+
export function scanTextForPublicFirebaseAdminKey(content) {
|
|
19
|
+
const matches = [];
|
|
20
|
+
content.split(/\r?\n/).forEach((line, index) => {
|
|
21
|
+
const match = PUBLIC_ADMIN_KEY_PATTERN.exec(line);
|
|
22
|
+
if (match)
|
|
23
|
+
matches.push({ line: index + 1, variableName: match[0] });
|
|
24
|
+
});
|
|
25
|
+
return matches;
|
|
26
|
+
}
|
|
27
|
+
export function scanTextForCommittedServiceAccountKey(content) {
|
|
28
|
+
const matches = [];
|
|
29
|
+
content.split(/\r?\n/).forEach((line, index) => {
|
|
30
|
+
if (SERVICE_ACCOUNT_JSON_PATTERN.test(line))
|
|
31
|
+
matches.push({ line: index + 1 });
|
|
32
|
+
});
|
|
33
|
+
return matches;
|
|
34
|
+
}
|
|
35
|
+
export async function findExposedFirebaseAdminKeys(targetDir) {
|
|
36
|
+
const files = await collectFiles(targetDir, isScannableFile);
|
|
37
|
+
const findings = [];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const content = await readFile(file, "utf8");
|
|
40
|
+
const relFile = relative(targetDir, file).split(sep).join("/");
|
|
41
|
+
for (const match of scanTextForPublicFirebaseAdminKey(content)) {
|
|
42
|
+
findings.push({
|
|
43
|
+
file: relFile,
|
|
44
|
+
line: match.line,
|
|
45
|
+
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.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const match of scanTextForCommittedServiceAccountKey(content)) {
|
|
50
|
+
findings.push({
|
|
51
|
+
file: relFile,
|
|
52
|
+
line: match.line,
|
|
53
|
+
ruleId: "firebase-service-account-key-committed",
|
|
54
|
+
description: `File ini kelihatan kayak service account key JSON asli dari Firebase Admin SDK — private key literal ke-commit ke repo.`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, relative, sep } from "node:path";
|
|
3
|
+
import { collectFiles } from "./fsWalk.js";
|
|
4
|
+
const OPEN_SECURITY_RULE_PATTERN = /allow\s+[\w,\s]+:\s*if\s+true\s*;/gi;
|
|
5
|
+
const OPEN_DATABASE_JSON_PATTERN = /"\.(?:read|write)"\s*:\s*(?:true|"true")/gi;
|
|
6
|
+
function isFirebaseRulesFile(filePath) {
|
|
7
|
+
const name = basename(filePath);
|
|
8
|
+
return name === "firestore.rules" || name === "storage.rules" || name === "database.rules.json";
|
|
9
|
+
}
|
|
10
|
+
function findMatchesAcrossLines(content, globalPattern) {
|
|
11
|
+
const matches = [];
|
|
12
|
+
const pattern = new RegExp(globalPattern.source, globalPattern.flags);
|
|
13
|
+
let match;
|
|
14
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
15
|
+
const line = content.slice(0, match.index).split(/\r?\n/).length;
|
|
16
|
+
matches.push({ line });
|
|
17
|
+
}
|
|
18
|
+
return matches;
|
|
19
|
+
}
|
|
20
|
+
export function findOpenRulesInSecurityRulesText(content) {
|
|
21
|
+
return findMatchesAcrossLines(content, OPEN_SECURITY_RULE_PATTERN);
|
|
22
|
+
}
|
|
23
|
+
export function findOpenRulesInDatabaseJson(content) {
|
|
24
|
+
return findMatchesAcrossLines(content, OPEN_DATABASE_JSON_PATTERN);
|
|
25
|
+
}
|
|
26
|
+
export async function findOpenFirebaseRules(targetDir) {
|
|
27
|
+
const files = await collectFiles(targetDir, isFirebaseRulesFile);
|
|
28
|
+
const findings = [];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const name = basename(file);
|
|
31
|
+
const content = await readFile(file, "utf8");
|
|
32
|
+
const relFile = relative(targetDir, file).split(sep).join("/");
|
|
33
|
+
const matches = name === "database.rules.json"
|
|
34
|
+
? findOpenRulesInDatabaseJson(content)
|
|
35
|
+
: findOpenRulesInSecurityRulesText(content);
|
|
36
|
+
for (const match of matches) {
|
|
37
|
+
findings.push({
|
|
38
|
+
file: relFile,
|
|
39
|
+
line: match.line,
|
|
40
|
+
ruleId: "firebase-rules-open",
|
|
41
|
+
description: `Rule di "${name}" ngasih akses baca/tulis tanpa syarat (if true / .read atau .write: true) — data bisa diakses siapa aja tanpa auth.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
|
@@ -59,7 +59,7 @@ export async function findRlsDisabledTables(targetDir) {
|
|
|
59
59
|
file: table.file,
|
|
60
60
|
line: table.line,
|
|
61
61
|
ruleId: "supabase-rls-missing",
|
|
62
|
-
description: `Tabel "${table.name}" gak ketemu "ENABLE ROW LEVEL SECURITY" di migration manapun — kemungkinan RLS mati.`,
|
|
62
|
+
description: `Tabel "${table.name}" gak ketemu "ENABLE ROW LEVEL SECURITY" di migration manapun — kemungkinan RLS mati. Ini cuma liat file migration, bukan state Supabase Dashboard asli — kalo RLS-nya emang udah dinyalain lewat Dashboard, suppress lewat .secanix.json.`,
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -61,14 +61,49 @@ export function parseGitleaksReport(json) {
|
|
|
61
61
|
}
|
|
62
62
|
return findings;
|
|
63
63
|
}
|
|
64
|
+
// Returns tracked + untracked-but-not-ignored relative paths, or null when
|
|
65
|
+
// targetDir isn't a git repo (no .gitignore semantics to respect there).
|
|
66
|
+
function listGitRespectedFiles(targetDir) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const child = spawn("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
69
|
+
let stdout = "";
|
|
70
|
+
child.stdout.on("data", (chunk) => {
|
|
71
|
+
stdout += chunk.toString();
|
|
72
|
+
});
|
|
73
|
+
child.on("error", () => resolve(null));
|
|
74
|
+
child.on("close", (code) => {
|
|
75
|
+
if (code !== 0) {
|
|
76
|
+
resolve(null);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
resolve(stdout.split("\0").filter((f) => f.length > 0));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Mirrors the given relative paths into mirrorRoot so gitleaks (run with
|
|
84
|
+
// --no-git) only ever sees files .gitignore would let through.
|
|
85
|
+
async function mirrorFiles(sourceDir, files, mirrorRoot) {
|
|
86
|
+
for (const rel of files) {
|
|
87
|
+
const dest = join(mirrorRoot, rel);
|
|
88
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
89
|
+
await copyFile(join(sourceDir, rel), dest).catch(() => { });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
export async function runSecretScan(targetDir) {
|
|
65
93
|
const tempDir = await mkdtemp(join(tmpdir(), "vibe-secret-scan-"));
|
|
66
94
|
const reportPath = join(tempDir, "gitleaks-report.json");
|
|
67
95
|
try {
|
|
96
|
+
const respectedFiles = await listGitRespectedFiles(targetDir);
|
|
97
|
+
let scanDir = targetDir;
|
|
98
|
+
if (respectedFiles !== null) {
|
|
99
|
+
scanDir = join(tempDir, "mirror");
|
|
100
|
+
await mkdir(scanDir, { recursive: true });
|
|
101
|
+
await mirrorFiles(targetDir, respectedFiles, scanDir);
|
|
102
|
+
}
|
|
68
103
|
await runGitleaksProcess([
|
|
69
104
|
"detect",
|
|
70
105
|
"--source",
|
|
71
|
-
|
|
106
|
+
scanDir,
|
|
72
107
|
"--no-git",
|
|
73
108
|
"--no-banner",
|
|
74
109
|
"--config",
|
|
@@ -81,7 +116,18 @@ export async function runSecretScan(targetDir) {
|
|
|
81
116
|
"0",
|
|
82
117
|
]);
|
|
83
118
|
const content = await readFile(reportPath, "utf8").catch(() => "");
|
|
84
|
-
|
|
119
|
+
const findings = parseGitleaksReport(content);
|
|
120
|
+
if (respectedFiles === null)
|
|
121
|
+
return findings;
|
|
122
|
+
// Mirror dir is deleted below; rewrite paths to point at the real project.
|
|
123
|
+
const mirrorPrefix = scanDir.replace(/\\/g, "/");
|
|
124
|
+
return findings.map((finding) => {
|
|
125
|
+
const fileFwd = finding.file.replace(/\\/g, "/");
|
|
126
|
+
if (!fileFwd.startsWith(mirrorPrefix))
|
|
127
|
+
return finding;
|
|
128
|
+
const rel = fileFwd.slice(mirrorPrefix.length).replace(/^\/+/, "");
|
|
129
|
+
return { ...finding, file: join(targetDir, rel) };
|
|
130
|
+
});
|
|
85
131
|
}
|
|
86
132
|
finally {
|
|
87
133
|
await rm(tempDir, { recursive: true, force: true });
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import { pathToFileURL } from "node:url";
|
|
4
6
|
import { findMissingApiAuth, SemgrepNotFoundError } from "./checks/apiAuthMissing.js";
|
|
5
7
|
import { findCorsWildcard } from "./checks/corsWildcard.js";
|
|
6
8
|
import { findDependencyVulnerabilities, OsvScannerNotFoundError } from "./checks/dependencyVulnerabilities.js";
|
|
9
|
+
import { findExposedFirebaseAdminKeys } from "./checks/exposedFirebaseAdminKey.js";
|
|
7
10
|
import { findExposedServiceRoleKeys } from "./checks/exposedServiceRoleKey.js";
|
|
11
|
+
import { findOpenFirebaseRules } from "./checks/firebaseRulesOpen.js";
|
|
8
12
|
import { findRlsDisabledTables } from "./checks/rlsDisabled.js";
|
|
9
13
|
import { GitleaksNotFoundError, runSecretScan } from "./checks/secretScan.js";
|
|
10
|
-
import { buildReport, formatReport } from "./report.js";
|
|
14
|
+
import { applyIgnoreRules, buildReport, formatReport } from "./report.js";
|
|
15
|
+
const IGNORE_FILE = ".secanix.json";
|
|
16
|
+
// Missing file = no suppression (default, zero-friction). Malformed file
|
|
17
|
+
// warns and falls back to no suppression rather than crashing the scan.
|
|
18
|
+
export async function loadIgnoreRules(targetDir) {
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = await readFile(join(targetDir, IGNORE_FILE), "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(content);
|
|
28
|
+
const ignore = parsed.ignore;
|
|
29
|
+
if (!Array.isArray(ignore))
|
|
30
|
+
return [];
|
|
31
|
+
const rules = [];
|
|
32
|
+
for (const entry of ignore) {
|
|
33
|
+
if (typeof entry === "object" &&
|
|
34
|
+
entry !== null &&
|
|
35
|
+
typeof entry.file === "string" &&
|
|
36
|
+
typeof entry.ruleId === "string") {
|
|
37
|
+
const { file, ruleId, reason } = entry;
|
|
38
|
+
rules.push({ file, ruleId, reason: typeof reason === "string" ? reason : undefined });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return rules;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.error(`${IGNORE_FILE} invalid, diabaikan: ${err.message}`);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
11
48
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
12
49
|
export async function run(targetDir = process.cwd(), options = {}) {
|
|
13
50
|
const { json = false } = options;
|
|
@@ -41,6 +78,11 @@ export async function run(targetDir = process.cwd(), options = {}) {
|
|
|
41
78
|
}
|
|
42
79
|
checkFindings.push({ checkId: "rls-disabled", findings: await findRlsDisabledTables(targetDir) });
|
|
43
80
|
checkFindings.push({ checkId: "cors-wildcard", findings: await findCorsWildcard(targetDir) });
|
|
81
|
+
checkFindings.push({ checkId: "firebase-rules-open", findings: await findOpenFirebaseRules(targetDir) });
|
|
82
|
+
checkFindings.push({
|
|
83
|
+
checkId: "firebase-admin-key-exposed",
|
|
84
|
+
findings: await findExposedFirebaseAdminKeys(targetDir),
|
|
85
|
+
});
|
|
44
86
|
try {
|
|
45
87
|
checkFindings.push({
|
|
46
88
|
checkId: "dependency-cve",
|
|
@@ -54,7 +96,15 @@ export async function run(targetDir = process.cwd(), options = {}) {
|
|
|
54
96
|
}
|
|
55
97
|
throw err;
|
|
56
98
|
}
|
|
57
|
-
const
|
|
99
|
+
const ignoreRules = await loadIgnoreRules(targetDir);
|
|
100
|
+
const { findings: reported, suppressed } = applyIgnoreRules(buildReport(checkFindings), ignoreRules);
|
|
101
|
+
if (suppressed.length > 0) {
|
|
102
|
+
console.error(`${suppressed.length} temuan diabaikan via ${IGNORE_FILE}:`);
|
|
103
|
+
for (const finding of suppressed) {
|
|
104
|
+
const reasonSuffix = finding.reason ? ` (${finding.reason})` : "";
|
|
105
|
+
console.error(` ${finding.file}:${finding.line} — ${finding.ruleId}${reasonSuffix}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
58
108
|
if (json) {
|
|
59
109
|
console.log(JSON.stringify(reported));
|
|
60
110
|
}
|
package/dist/report.js
CHANGED
|
@@ -20,6 +20,18 @@ const RULE_INFO = {
|
|
|
20
20
|
severity: "medium",
|
|
21
21
|
fix: "Ganti '*' jadi daftar origin eksplisit yang emang butuh akses, atau validasi origin secara dinamis di server.",
|
|
22
22
|
},
|
|
23
|
+
"firebase-rules-open": {
|
|
24
|
+
severity: "critical",
|
|
25
|
+
fix: "Ganti 'if true' / '.read'/'.write': true dengan rule yang validasi auth (request.auth != null, dst) — rule default Firebase itu deny-all, jangan di-override jadi allow-all.",
|
|
26
|
+
},
|
|
27
|
+
"firebase-admin-key-public-env": {
|
|
28
|
+
severity: "critical",
|
|
29
|
+
fix: "Jangan expose Firebase Admin key lewat NEXT_PUBLIC_* — pindahin ke env var server-only, lalu rotate key ini di Firebase Console (Project Settings > Service Accounts) kalau udah sempet ke-deploy.",
|
|
30
|
+
},
|
|
31
|
+
"firebase-service-account-key-committed": {
|
|
32
|
+
severity: "critical",
|
|
33
|
+
fix: "Hapus file JSON key ini dari repo & git history, rotate key di Firebase Console, generate key baru & simpan di secret manager / env var server-only.",
|
|
34
|
+
},
|
|
23
35
|
};
|
|
24
36
|
// gitleaks (secret-scan) and osv-scanner (dependency-cve) each emit a
|
|
25
37
|
// ruleId that varies per finding (rule name / GHSA id), so they're
|
|
@@ -60,6 +72,22 @@ export function classify(finding, checkId) {
|
|
|
60
72
|
}
|
|
61
73
|
return { ...finding, severity: "medium", fixSuggestion: DEFAULT_FIX };
|
|
62
74
|
}
|
|
75
|
+
// file+ruleId must both match — narrow on purpose so an ignore entry never
|
|
76
|
+
// silently swallows an unrelated finding that happens to share one field.
|
|
77
|
+
export function applyIgnoreRules(reported, ignoreRules) {
|
|
78
|
+
const findings = [];
|
|
79
|
+
const suppressed = [];
|
|
80
|
+
for (const finding of reported) {
|
|
81
|
+
const rule = ignoreRules.find((r) => r.file === finding.file && r.ruleId === finding.ruleId);
|
|
82
|
+
if (rule) {
|
|
83
|
+
suppressed.push({ ...finding, reason: rule.reason });
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
findings.push(finding);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { findings, suppressed };
|
|
90
|
+
}
|
|
63
91
|
const SEVERITY_ORDER = ["critical", "high", "medium", "low"];
|
|
64
92
|
const SEVERITY_RANK = new Map(SEVERITY_ORDER.map((severity, index) => [severity, index]));
|
|
65
93
|
export function buildReport(checkFindings) {
|