jstar-reviewer 3.0.0 → 3.0.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/README.md +49 -49
- package/bin/jstar.js +7 -7
- package/dist/scripts/core/deterministic-audit.js +287 -40
- package/dist/scripts/core/review-findings.js +76 -0
- package/dist/scripts/reviewer.js +3 -40
- package/package.json +70 -70
- package/scripts/audit-report.ts +75 -75
- package/scripts/audit.ts +117 -117
- package/scripts/config.ts +6 -6
- package/scripts/core/deterministic-audit.ts +829 -485
- package/scripts/core/project.ts +143 -143
- package/scripts/core/review-findings.ts +88 -0
- package/scripts/core/review-target.ts +120 -120
- package/scripts/dashboard.ts +3 -3
- package/scripts/detective.ts +81 -81
- package/scripts/gemini-embedding.ts +26 -26
- package/scripts/indexer.ts +115 -115
- package/scripts/reviewer.ts +481 -525
- package/scripts/session.ts +10 -10
- package/scripts/types.ts +58 -58
package/README.md
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
# J-Star Code Reviewer
|
|
2
|
-
|
|
3
|
-
Local-first, context-aware code review with a deterministic security audit layer.
|
|
4
|
-
|
|
5
|
-
## What it does
|
|
6
|
-
|
|
7
|
-
- Builds a local vector index for repo-aware reviews
|
|
8
|
-
- Runs hybrid reviews with deterministic checks plus LLM analysis
|
|
9
|
-
- Produces machine-readable review output for automation
|
|
10
|
-
- Runs a standalone deterministic security audit with markdown and JSON reports
|
|
11
|
-
|
|
12
|
-
## Quick start
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
pnpm install
|
|
16
|
-
pnpm run index:init
|
|
17
|
-
git add .
|
|
18
|
-
pnpm run review
|
|
19
|
-
pnpm run audit
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Review output:
|
|
23
|
-
- `.jstar/last-review.md`
|
|
24
|
-
- `.jstar/session.json`
|
|
25
|
-
|
|
26
|
-
Audit output:
|
|
27
|
-
- `.jstar/audit_report.md`
|
|
28
|
-
- `.jstar/audit_report.json`
|
|
29
|
-
|
|
30
|
-
## CLI
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
jstar setup
|
|
34
|
-
jstar init
|
|
35
|
-
jstar review
|
|
36
|
-
jstar review --pr
|
|
37
|
-
jstar audit
|
|
38
|
-
jstar audit --path src
|
|
39
|
-
jstar audit --json
|
|
40
|
-
jstar chat --headless
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Notes
|
|
44
|
-
|
|
45
|
-
- `review` requires `GEMINI_API_KEY` and `GROQ_API_KEY`
|
|
46
|
-
- `audit` and `detect` do not require model keys
|
|
47
|
-
- deterministic audit ignores live in `.jstar/audit-ignore.json`
|
|
48
|
-
|
|
49
|
-
See [ONBOARDING.md](./ONBOARDING.md) and [docs/features/cli-commands.md](./docs/features/cli-commands.md) for details.
|
|
1
|
+
# J-Star Code Reviewer
|
|
2
|
+
|
|
3
|
+
Local-first, context-aware code review with a deterministic security audit layer.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Builds a local vector index for repo-aware reviews
|
|
8
|
+
- Runs hybrid reviews with deterministic checks plus LLM analysis
|
|
9
|
+
- Produces machine-readable review output for automation
|
|
10
|
+
- Runs a standalone deterministic security audit with markdown and JSON reports
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm install
|
|
16
|
+
pnpm run index:init
|
|
17
|
+
git add .
|
|
18
|
+
pnpm run review
|
|
19
|
+
pnpm run audit
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Review output:
|
|
23
|
+
- `.jstar/last-review.md`
|
|
24
|
+
- `.jstar/session.json`
|
|
25
|
+
|
|
26
|
+
Audit output:
|
|
27
|
+
- `.jstar/audit_report.md`
|
|
28
|
+
- `.jstar/audit_report.json`
|
|
29
|
+
|
|
30
|
+
## CLI
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
jstar setup
|
|
34
|
+
jstar init
|
|
35
|
+
jstar review
|
|
36
|
+
jstar review --pr
|
|
37
|
+
jstar audit
|
|
38
|
+
jstar audit --path src
|
|
39
|
+
jstar audit --json
|
|
40
|
+
jstar chat --headless
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
|
|
45
|
+
- `review` requires `GEMINI_API_KEY` and `GROQ_API_KEY`
|
|
46
|
+
- `audit` and `detect` do not require model keys
|
|
47
|
+
- deterministic audit ignores live in `.jstar/audit-ignore.json`
|
|
48
|
+
|
|
49
|
+
See [ONBOARDING.md](./ONBOARDING.md) and [docs/features/cli-commands.md](./docs/features/cli-commands.md) for details.
|
package/bin/jstar.js
CHANGED
|
@@ -29,7 +29,7 @@ function log(msg) {
|
|
|
29
29
|
|
|
30
30
|
function printHelp() {
|
|
31
31
|
log(`
|
|
32
|
-
${COLORS.bold}🌟 J-Star Reviewer v3.0.
|
|
32
|
+
${COLORS.bold}🌟 J-Star Reviewer v3.0.1${COLORS.reset}
|
|
33
33
|
|
|
34
34
|
${COLORS.dim}AI-powered code review with local embeddings${COLORS.reset}
|
|
35
35
|
|
|
@@ -162,12 +162,12 @@ function runScript(scriptName) {
|
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
const REQUIRED_ENV_VARS = {
|
|
166
|
-
'GEMINI_API_KEY': '# Required: Gemini API key (or GOOGLE_API_KEY)\nGEMINI_API_KEY=your_gemini_api_key_here',
|
|
167
|
-
'GROQ_API_KEY': '# Required: Groq API key for LLM reviews\nGROQ_API_KEY=your_groq_api_key_here',
|
|
168
|
-
'GEMINI_EMBEDDING_MODEL': '# Optional: Override the embedding model\n# GEMINI_EMBEDDING_MODEL=gemini-embedding-001',
|
|
169
|
-
'REVIEW_MODEL_NAME': '# Optional: Override the default model\n# REVIEW_MODEL_NAME=moonshotai/kimi-k2-instruct-0905'
|
|
170
|
-
};
|
|
165
|
+
const REQUIRED_ENV_VARS = {
|
|
166
|
+
'GEMINI_API_KEY': '# Required: Gemini API key (or GOOGLE_API_KEY)\nGEMINI_API_KEY=your_gemini_api_key_here',
|
|
167
|
+
'GROQ_API_KEY': '# Required: Groq API key for LLM reviews\nGROQ_API_KEY=your_groq_api_key_here',
|
|
168
|
+
'GEMINI_EMBEDDING_MODEL': '# Optional: Override the embedding model\n# GEMINI_EMBEDDING_MODEL=gemini-embedding-001',
|
|
169
|
+
'REVIEW_MODEL_NAME': '# Optional: Override the default model\n# REVIEW_MODEL_NAME=moonshotai/kimi-k2-instruct-0905'
|
|
170
|
+
};
|
|
171
171
|
|
|
172
172
|
function createSetupFiles() {
|
|
173
173
|
const cwd = process.cwd();
|
|
@@ -44,19 +44,237 @@ exports.runDeterministicAudit = runDeterministicAudit;
|
|
|
44
44
|
const fs = __importStar(require("fs"));
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
46
|
const simple_git_1 = __importDefault(require("simple-git"));
|
|
47
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
48
|
+
const logger_1 = require("../utils/logger");
|
|
47
49
|
const project_1 = require("./project");
|
|
48
50
|
exports.RULES_VERSION = "security-audit-v1";
|
|
51
|
+
function readStringLiteral(source, start) {
|
|
52
|
+
const quote = source[start];
|
|
53
|
+
if (quote !== '"' && quote !== "'") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
let value = "";
|
|
57
|
+
for (let index = start + 1; index < source.length; index++) {
|
|
58
|
+
const char = source[index];
|
|
59
|
+
if (char === "\r" || char === "\n") {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (char === "\\") {
|
|
63
|
+
if (index + 1 >= source.length) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
value += source.slice(index, index + 2);
|
|
67
|
+
index++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (char === quote) {
|
|
71
|
+
return { value, end: index + 1 };
|
|
72
|
+
}
|
|
73
|
+
value += char;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function isStatementTerminated(source, index) {
|
|
78
|
+
let cursor = index;
|
|
79
|
+
while (cursor < source.length) {
|
|
80
|
+
const char = source[cursor];
|
|
81
|
+
if (char === " " || char === "\t" || char === "\v" || char === "\f") {
|
|
82
|
+
cursor++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === ";") {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (source.startsWith("//", cursor)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (source.startsWith("/*", cursor)) {
|
|
92
|
+
const commentEnd = source.indexOf("*/", cursor + 2);
|
|
93
|
+
if (commentEnd === -1) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
cursor = commentEnd + 2;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
function isUseClientDirectiveStatement(statement) {
|
|
104
|
+
const directive = readStringLiteral(statement, 0);
|
|
105
|
+
return directive?.value === "use client" && isStatementTerminated(statement, directive.end);
|
|
106
|
+
}
|
|
107
|
+
function collectStatementLines(content) {
|
|
108
|
+
const statements = [];
|
|
109
|
+
const lines = content.split(/\r?\n/);
|
|
110
|
+
let inBlockComment = false;
|
|
111
|
+
nextLine: for (let index = 0; index < lines.length; index++) {
|
|
112
|
+
const rawLine = lines[index];
|
|
113
|
+
const line = index === 0 ? rawLine.replace(/^\uFEFF/, "") : rawLine;
|
|
114
|
+
if (!inBlockComment && index === 0 && line.startsWith("#!")) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let cursor = 0;
|
|
118
|
+
while (cursor < line.length) {
|
|
119
|
+
if (inBlockComment) {
|
|
120
|
+
const commentEnd = line.indexOf("*/", cursor);
|
|
121
|
+
if (commentEnd === -1) {
|
|
122
|
+
continue nextLine;
|
|
123
|
+
}
|
|
124
|
+
inBlockComment = false;
|
|
125
|
+
cursor = commentEnd + 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const char = line[cursor];
|
|
129
|
+
if (char === " " || char === "\t" || char === "\v" || char === "\f") {
|
|
130
|
+
cursor++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (line.startsWith("//", cursor)) {
|
|
134
|
+
continue nextLine;
|
|
135
|
+
}
|
|
136
|
+
if (line.startsWith("/*", cursor)) {
|
|
137
|
+
inBlockComment = true;
|
|
138
|
+
cursor += 2;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
statements.push({
|
|
142
|
+
line: index + 1,
|
|
143
|
+
text: line.slice(cursor),
|
|
144
|
+
});
|
|
145
|
+
continue nextLine;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return statements;
|
|
149
|
+
}
|
|
150
|
+
function hasUseClientAsFirstStatement(content) {
|
|
151
|
+
const firstStatement = collectStatementLines(content)[0];
|
|
152
|
+
return firstStatement ? isUseClientDirectiveStatement(firstStatement.text) : false;
|
|
153
|
+
}
|
|
154
|
+
function findUseClientDirectiveLine(content) {
|
|
155
|
+
return collectStatementLines(content).find((statement) => isUseClientDirectiveStatement(statement.text))?.line;
|
|
156
|
+
}
|
|
157
|
+
function getLanguageVariant(normalizedPath) {
|
|
158
|
+
return /\.(?:[jt]sx)$/i.test(normalizedPath) ? typescript_1.default.LanguageVariant.JSX : typescript_1.default.LanguageVariant.Standard;
|
|
159
|
+
}
|
|
160
|
+
function getScriptKind(normalizedPath) {
|
|
161
|
+
if (/\.tsx$/i.test(normalizedPath)) {
|
|
162
|
+
return typescript_1.default.ScriptKind.TSX;
|
|
163
|
+
}
|
|
164
|
+
if (/\.jsx$/i.test(normalizedPath)) {
|
|
165
|
+
return typescript_1.default.ScriptKind.JSX;
|
|
166
|
+
}
|
|
167
|
+
if (/\.js$/i.test(normalizedPath)) {
|
|
168
|
+
return typescript_1.default.ScriptKind.JS;
|
|
169
|
+
}
|
|
170
|
+
return typescript_1.default.ScriptKind.TS;
|
|
171
|
+
}
|
|
172
|
+
function maskTriviaText(text) {
|
|
173
|
+
return text.replace(/[^\r\n]/g, " ");
|
|
174
|
+
}
|
|
175
|
+
function shouldMaskInCodeView(kind) {
|
|
176
|
+
return (kind === typescript_1.default.SyntaxKind.SingleLineCommentTrivia ||
|
|
177
|
+
kind === typescript_1.default.SyntaxKind.MultiLineCommentTrivia ||
|
|
178
|
+
kind === typescript_1.default.SyntaxKind.ShebangTrivia ||
|
|
179
|
+
kind === typescript_1.default.SyntaxKind.StringLiteral ||
|
|
180
|
+
kind === typescript_1.default.SyntaxKind.NoSubstitutionTemplateLiteral ||
|
|
181
|
+
kind === typescript_1.default.SyntaxKind.TemplateHead ||
|
|
182
|
+
kind === typescript_1.default.SyntaxKind.TemplateMiddle ||
|
|
183
|
+
kind === typescript_1.default.SyntaxKind.TemplateTail ||
|
|
184
|
+
kind === typescript_1.default.SyntaxKind.JsxText ||
|
|
185
|
+
kind === typescript_1.default.SyntaxKind.JsxTextAllWhiteSpaces ||
|
|
186
|
+
kind === typescript_1.default.SyntaxKind.RegularExpressionLiteral);
|
|
187
|
+
}
|
|
188
|
+
function buildCodeView(content, normalizedPath) {
|
|
189
|
+
const scanner = typescript_1.default.createScanner(typescript_1.default.ScriptTarget.Latest, false, getLanguageVariant(normalizedPath), content);
|
|
190
|
+
let masked = "";
|
|
191
|
+
let cursor = 0;
|
|
192
|
+
for (let token = scanner.scan(); token !== typescript_1.default.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
193
|
+
const tokenStart = scanner.getTokenPos();
|
|
194
|
+
const tokenEnd = scanner.getTextPos();
|
|
195
|
+
const tokenText = scanner.getTokenText();
|
|
196
|
+
if (tokenStart > cursor) {
|
|
197
|
+
masked += content.slice(cursor, tokenStart);
|
|
198
|
+
}
|
|
199
|
+
masked += shouldMaskInCodeView(token) ? maskTriviaText(tokenText) : tokenText;
|
|
200
|
+
cursor = tokenEnd;
|
|
201
|
+
}
|
|
202
|
+
if (cursor < content.length) {
|
|
203
|
+
masked += content.slice(cursor);
|
|
204
|
+
}
|
|
205
|
+
return masked;
|
|
206
|
+
}
|
|
207
|
+
function isTriviaToken(kind) {
|
|
208
|
+
return (kind === typescript_1.default.SyntaxKind.SingleLineCommentTrivia ||
|
|
209
|
+
kind === typescript_1.default.SyntaxKind.MultiLineCommentTrivia ||
|
|
210
|
+
kind === typescript_1.default.SyntaxKind.NewLineTrivia ||
|
|
211
|
+
kind === typescript_1.default.SyntaxKind.WhitespaceTrivia ||
|
|
212
|
+
kind === typescript_1.default.SyntaxKind.ShebangTrivia);
|
|
213
|
+
}
|
|
214
|
+
function collectSignificantTokens(content, normalizedPath) {
|
|
215
|
+
const sourceFile = typescript_1.default.createSourceFile(normalizedPath, content, typescript_1.default.ScriptTarget.Latest, false, getScriptKind(normalizedPath));
|
|
216
|
+
const scanner = typescript_1.default.createScanner(typescript_1.default.ScriptTarget.Latest, false, getLanguageVariant(normalizedPath), content);
|
|
217
|
+
const tokens = [];
|
|
218
|
+
for (let kind = scanner.scan(); kind !== typescript_1.default.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
|
|
219
|
+
if (isTriviaToken(kind)) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
tokens.push({
|
|
223
|
+
kind,
|
|
224
|
+
line: typescript_1.default.getLineAndCharacterOfPosition(sourceFile, scanner.getTokenPos()).line + 1,
|
|
225
|
+
text: scanner.getTokenText(),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return tokens;
|
|
229
|
+
}
|
|
230
|
+
const SECRET_NAME_PATTERN = /^(?:api[_-]?key|password|(?:access|auth|bearer|client|refresh|session)?[_-]?(?:secret|token))$/i;
|
|
231
|
+
function normalizeTokenName(token) {
|
|
232
|
+
if (token.kind === typescript_1.default.SyntaxKind.Identifier) {
|
|
233
|
+
return token.text;
|
|
234
|
+
}
|
|
235
|
+
if (token.kind === typescript_1.default.SyntaxKind.StringLiteral) {
|
|
236
|
+
return token.text.slice(1, -1);
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
function readStringTokenValue(token) {
|
|
241
|
+
if (token.kind === typescript_1.default.SyntaxKind.StringLiteral || token.kind === typescript_1.default.SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
242
|
+
return token.text.slice(1, -1);
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function scanHardcodedSecrets(content, normalizedPath) {
|
|
247
|
+
const findings = [];
|
|
248
|
+
const tokens = collectSignificantTokens(content, normalizedPath);
|
|
249
|
+
for (let index = 0; index < tokens.length - 2; index++) {
|
|
250
|
+
const token = tokens[index];
|
|
251
|
+
const candidateName = normalizeTokenName(token);
|
|
252
|
+
if (!candidateName || !SECRET_NAME_PATTERN.test(candidateName)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const operator = tokens[index + 1];
|
|
256
|
+
if (operator.kind !== typescript_1.default.SyntaxKind.EqualsToken && operator.kind !== typescript_1.default.SyntaxKind.ColonToken) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const value = readStringTokenValue(tokens[index + 2]);
|
|
260
|
+
if (!value || value.length < 10) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
findings.push({
|
|
264
|
+
ruleId: "SEC-001",
|
|
265
|
+
title: "Hardcoded secret in source",
|
|
266
|
+
severity: "CRITICAL",
|
|
267
|
+
category: "SECURITY",
|
|
268
|
+
file: normalizedPath,
|
|
269
|
+
line: token.line,
|
|
270
|
+
message: "Possible hardcoded credential detected in source.",
|
|
271
|
+
recommendation: "Move the credential to environment configuration and rotate the exposed secret.",
|
|
272
|
+
source: "deterministic",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return findings;
|
|
276
|
+
}
|
|
49
277
|
const LINE_RULES = [
|
|
50
|
-
{
|
|
51
|
-
id: "SEC-001",
|
|
52
|
-
title: "Hardcoded secret in source",
|
|
53
|
-
severity: "CRITICAL",
|
|
54
|
-
category: "SECURITY",
|
|
55
|
-
recommendation: "Move the credential to environment configuration and rotate the exposed secret.",
|
|
56
|
-
pattern: /(api[_-]?key|secret|password|token)\s*[:=]\s*['"`][A-Za-z0-9._-]{10,}['"`]/i,
|
|
57
|
-
excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
|
|
58
|
-
buildMessage: () => "Possible hardcoded credential detected in source.",
|
|
59
|
-
},
|
|
60
278
|
{
|
|
61
279
|
id: "SEC-002",
|
|
62
280
|
title: "Dynamic code execution",
|
|
@@ -66,6 +284,7 @@ const LINE_RULES = [
|
|
|
66
284
|
pattern: /\b(?:eval|Function)\s*\(/,
|
|
67
285
|
excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
|
|
68
286
|
buildMessage: () => "Dynamic code execution can allow arbitrary code paths and should be avoided.",
|
|
287
|
+
sourceView: "code",
|
|
69
288
|
},
|
|
70
289
|
{
|
|
71
290
|
id: "SEC-003",
|
|
@@ -76,6 +295,7 @@ const LINE_RULES = [
|
|
|
76
295
|
pattern: /\b(?:\$queryRawUnsafe|\$executeRawUnsafe|queryRawUnsafe|executeRawUnsafe)\s*\(/,
|
|
77
296
|
excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
|
|
78
297
|
buildMessage: () => "Unsafe raw SQL helper detected; untrusted input can reach the database without parameterization.",
|
|
298
|
+
sourceView: "code",
|
|
79
299
|
},
|
|
80
300
|
{
|
|
81
301
|
id: "SEC-004",
|
|
@@ -86,6 +306,7 @@ const LINE_RULES = [
|
|
|
86
306
|
pattern: /dangerouslySetInnerHTML\s*=\s*\{\{/,
|
|
87
307
|
excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
|
|
88
308
|
buildMessage: () => "Raw HTML injection sink detected.",
|
|
309
|
+
sourceView: "code",
|
|
89
310
|
},
|
|
90
311
|
{
|
|
91
312
|
id: "QLT-001",
|
|
@@ -96,6 +317,7 @@ const LINE_RULES = [
|
|
|
96
317
|
pattern: /console\.log\(/,
|
|
97
318
|
excludePattern: /(bin\/jstar\.js|scripts\/utils\/logger\.ts|setup\.js|test\/)/i,
|
|
98
319
|
buildMessage: () => "console.log left in source can leak noisy or sensitive runtime details.",
|
|
320
|
+
sourceView: "code",
|
|
99
321
|
},
|
|
100
322
|
{
|
|
101
323
|
id: "QLT-002",
|
|
@@ -113,15 +335,24 @@ const FILE_RULES = [
|
|
|
113
335
|
title: '"use client" is not the first statement',
|
|
114
336
|
severity: "HIGH",
|
|
115
337
|
category: "LOGIC",
|
|
116
|
-
recommendation: 'Move the "use client" directive
|
|
338
|
+
recommendation: 'Move the "use client" directive above imports and executable statements so it stays the first statement in the module.',
|
|
117
339
|
filePattern: /\.tsx?$/i,
|
|
118
340
|
excludePattern: /(scripts|test)\//i,
|
|
119
|
-
test:
|
|
120
|
-
message: 'Next.js requires "use client" to
|
|
121
|
-
line: 1,
|
|
341
|
+
test: (content) => Boolean(findUseClientDirectiveLine(content)) && !hasUseClientAsFirstStatement(content),
|
|
342
|
+
message: 'Next.js requires "use client" to be the first statement in the file.',
|
|
343
|
+
line: (content) => findUseClientDirectiveLine(content) ?? 1,
|
|
122
344
|
},
|
|
123
345
|
];
|
|
124
346
|
const CUSTOM_RULES = [
|
|
347
|
+
{
|
|
348
|
+
id: "SEC-001",
|
|
349
|
+
title: "Hardcoded secret in source",
|
|
350
|
+
severity: "CRITICAL",
|
|
351
|
+
category: "SECURITY",
|
|
352
|
+
recommendation: "Move the credential to environment configuration and rotate the exposed secret.",
|
|
353
|
+
excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
|
|
354
|
+
scan: (content, normalizedPath) => scanHardcodedSecrets(content, normalizedPath),
|
|
355
|
+
},
|
|
125
356
|
{
|
|
126
357
|
id: "SEC-005",
|
|
127
358
|
title: "Server env var referenced in client module",
|
|
@@ -131,38 +362,39 @@ const CUSTOM_RULES = [
|
|
|
131
362
|
filePattern: /\.tsx?$/i,
|
|
132
363
|
excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
|
|
133
364
|
scan: (content, normalizedPath) => {
|
|
134
|
-
if (
|
|
365
|
+
if (!hasUseClientAsFirstStatement(content)) {
|
|
135
366
|
return [];
|
|
136
367
|
}
|
|
137
368
|
const findings = [];
|
|
138
|
-
const
|
|
369
|
+
const codeView = buildCodeView(content, normalizedPath);
|
|
370
|
+
const sourceFile = typescript_1.default.createSourceFile(normalizedPath, content, typescript_1.default.ScriptTarget.Latest, false, getScriptKind(normalizedPath));
|
|
139
371
|
const envPattern = /process\.env\.([A-Z0-9_]+)/g;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
findings.push({
|
|
148
|
-
ruleId: "SEC-005",
|
|
149
|
-
title: "Server env var referenced in client module",
|
|
150
|
-
severity: "CRITICAL",
|
|
151
|
-
category: "SECURITY",
|
|
152
|
-
file: normalizedPath,
|
|
153
|
-
line: index + 1,
|
|
154
|
-
message: `Client component references server-only environment variable "${envName}".`,
|
|
155
|
-
recommendation: "Move the access to a server-only boundary or expose a safe NEXT_PUBLIC_ value instead.",
|
|
156
|
-
source: "deterministic",
|
|
157
|
-
});
|
|
372
|
+
let match;
|
|
373
|
+
envPattern.lastIndex = 0;
|
|
374
|
+
while ((match = envPattern.exec(codeView)) !== null) {
|
|
375
|
+
const envName = match[1];
|
|
376
|
+
if (envName.startsWith("NEXT_PUBLIC_")) {
|
|
377
|
+
continue;
|
|
158
378
|
}
|
|
159
|
-
|
|
379
|
+
findings.push({
|
|
380
|
+
ruleId: "SEC-005",
|
|
381
|
+
title: "Server env var referenced in client module",
|
|
382
|
+
severity: "CRITICAL",
|
|
383
|
+
category: "SECURITY",
|
|
384
|
+
file: normalizedPath,
|
|
385
|
+
line: typescript_1.default.getLineAndCharacterOfPosition(sourceFile, match.index).line + 1,
|
|
386
|
+
message: `Client component references server-only environment variable "${envName}".`,
|
|
387
|
+
recommendation: "Move the access to a server-only boundary or expose a safe NEXT_PUBLIC_ value instead.",
|
|
388
|
+
source: "deterministic",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
160
391
|
return findings;
|
|
161
392
|
},
|
|
162
393
|
},
|
|
163
394
|
];
|
|
164
395
|
const AUDIT_IGNORE_FILE = path.join(".jstar", "audit-ignore.json");
|
|
165
396
|
const REQUIRED_GITIGNORE_PATTERNS = [".env", ".env.local", "node_modules", "*.pem", "*.key"];
|
|
397
|
+
const MAX_AUDIT_FILE_SIZE_BYTES = 1024 * 1024;
|
|
166
398
|
const SEVERITY_RANK = {
|
|
167
399
|
CRITICAL: 0,
|
|
168
400
|
HIGH: 1,
|
|
@@ -221,12 +453,15 @@ function mapAuditSeverityToReviewSeverity(severity) {
|
|
|
221
453
|
}
|
|
222
454
|
function scanFileContent(content, normalizedPath) {
|
|
223
455
|
const findings = [];
|
|
224
|
-
const
|
|
456
|
+
const rawLines = content.split(/\r?\n/);
|
|
457
|
+
const codeLines = buildCodeView(content, normalizedPath).split(/\r?\n/);
|
|
225
458
|
for (const rule of LINE_RULES) {
|
|
226
459
|
if (!shouldApplyRule(rule, normalizedPath)) {
|
|
227
460
|
continue;
|
|
228
461
|
}
|
|
462
|
+
const lines = rule.sourceView === "code" ? codeLines : rawLines;
|
|
229
463
|
lines.forEach((line, index) => {
|
|
464
|
+
rule.pattern.lastIndex = 0;
|
|
230
465
|
if (!rule.pattern.test(line)) {
|
|
231
466
|
return;
|
|
232
467
|
}
|
|
@@ -247,16 +482,17 @@ function scanFileContent(content, normalizedPath) {
|
|
|
247
482
|
if (!shouldApplyRule(rule, normalizedPath)) {
|
|
248
483
|
continue;
|
|
249
484
|
}
|
|
250
|
-
if (!rule.test
|
|
485
|
+
if (!rule.test(content)) {
|
|
251
486
|
continue;
|
|
252
487
|
}
|
|
488
|
+
const line = typeof rule.line === "function" ? rule.line(content) : rule.line;
|
|
253
489
|
findings.push({
|
|
254
490
|
ruleId: rule.id,
|
|
255
491
|
title: rule.title,
|
|
256
492
|
severity: rule.severity,
|
|
257
493
|
category: rule.category,
|
|
258
494
|
file: normalizedPath,
|
|
259
|
-
line
|
|
495
|
+
line,
|
|
260
496
|
message: rule.message,
|
|
261
497
|
recommendation: rule.recommendation,
|
|
262
498
|
source: "deterministic",
|
|
@@ -381,6 +617,7 @@ async function runRepositoryChecks(cwd) {
|
|
|
381
617
|
});
|
|
382
618
|
}
|
|
383
619
|
catch {
|
|
620
|
+
logger_1.Logger.warn("Repository checks skipped: unable to access git metadata.");
|
|
384
621
|
// Repository checks are best-effort outside git worktrees.
|
|
385
622
|
}
|
|
386
623
|
return findings;
|
|
@@ -396,12 +633,22 @@ async function runDeterministicAudit(options) {
|
|
|
396
633
|
.filter((filePath) => (0, project_1.isCodeFile)(filePath))
|
|
397
634
|
.sort((left, right) => left.localeCompare(right));
|
|
398
635
|
const rawFindings = [];
|
|
636
|
+
let scannedFiles = 0;
|
|
399
637
|
for (const filePath of uniqueFiles) {
|
|
400
638
|
const absolutePath = path.resolve(cwd, filePath);
|
|
401
|
-
if (!fs.existsSync(absolutePath)
|
|
639
|
+
if (!fs.existsSync(absolutePath)) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const fileStats = fs.statSync(absolutePath);
|
|
643
|
+
if (!fileStats.isFile()) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (fileStats.size > MAX_AUDIT_FILE_SIZE_BYTES) {
|
|
647
|
+
logger_1.Logger.warn(`Skipping deterministic audit for "${filePath}" because it exceeds ${MAX_AUDIT_FILE_SIZE_BYTES} bytes.`);
|
|
402
648
|
continue;
|
|
403
649
|
}
|
|
404
650
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
651
|
+
scannedFiles++;
|
|
405
652
|
rawFindings.push(...scanFileContent(content, filePath));
|
|
406
653
|
}
|
|
407
654
|
if (includeRepositoryChecks) {
|
|
@@ -410,7 +657,7 @@ async function runDeterministicAudit(options) {
|
|
|
410
657
|
const ignores = loadAuditIgnores(cwd);
|
|
411
658
|
const { findings, ignoredFindings } = applyIgnores(sortFindings(rawFindings), ignores);
|
|
412
659
|
const summary = {
|
|
413
|
-
filesScanned:
|
|
660
|
+
filesScanned: scannedFiles,
|
|
414
661
|
findings: findings.length,
|
|
415
662
|
critical: findings.filter((finding) => finding.severity === "CRITICAL").length,
|
|
416
663
|
high: findings.filter((finding) => finding.severity === "HIGH").length,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.severityMax = severityMax;
|
|
4
|
+
exports.mergeFindings = mergeFindings;
|
|
5
|
+
const SEVERITY_RANK = {
|
|
6
|
+
P0_CRITICAL: 0,
|
|
7
|
+
P1_HIGH: 1,
|
|
8
|
+
P2_MEDIUM: 2,
|
|
9
|
+
LGTM: 3,
|
|
10
|
+
};
|
|
11
|
+
function severityMax(left, right) {
|
|
12
|
+
return SEVERITY_RANK[left] <= SEVERITY_RANK[right] ? left : right;
|
|
13
|
+
}
|
|
14
|
+
function buildIssueKey(issue) {
|
|
15
|
+
return `${issue.line ?? 0}:${issue.title.trim().toLowerCase()}`;
|
|
16
|
+
}
|
|
17
|
+
function issuePriority(issue) {
|
|
18
|
+
return (issue.source === "deterministic" ? 10 : 0) + (issue.confidenceScore ?? 0);
|
|
19
|
+
}
|
|
20
|
+
function mergeIssue(existing, incoming) {
|
|
21
|
+
const preferred = issuePriority(existing) >= issuePriority(incoming) ? existing : incoming;
|
|
22
|
+
const fallback = preferred === existing ? incoming : existing;
|
|
23
|
+
const confidenceScore = Math.max(existing.confidenceScore ?? 0, incoming.confidenceScore ?? 0);
|
|
24
|
+
return {
|
|
25
|
+
...fallback,
|
|
26
|
+
...preferred,
|
|
27
|
+
description: preferred.description.length >= fallback.description.length ? preferred.description : fallback.description,
|
|
28
|
+
fixPrompt: preferred.fixPrompt.length >= fallback.fixPrompt.length ? preferred.fixPrompt : fallback.fixPrompt,
|
|
29
|
+
confidenceScore: confidenceScore > 0 ? confidenceScore : undefined,
|
|
30
|
+
ruleId: preferred.ruleId ?? fallback.ruleId,
|
|
31
|
+
source: preferred.source ?? fallback.source,
|
|
32
|
+
status: preferred.status ?? fallback.status,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function dedupeIssues(issues) {
|
|
36
|
+
const deduped = new Map();
|
|
37
|
+
issues.forEach((issue) => {
|
|
38
|
+
const key = buildIssueKey(issue);
|
|
39
|
+
const existing = deduped.get(key);
|
|
40
|
+
if (!existing) {
|
|
41
|
+
deduped.set(key, { ...issue });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
deduped.set(key, mergeIssue(existing, issue));
|
|
45
|
+
});
|
|
46
|
+
return [...deduped.values()];
|
|
47
|
+
}
|
|
48
|
+
function mergeFindings(primary, secondary) {
|
|
49
|
+
const grouped = new Map();
|
|
50
|
+
const insert = (finding) => {
|
|
51
|
+
const existing = grouped.get(finding.file);
|
|
52
|
+
if (!existing) {
|
|
53
|
+
grouped.set(finding.file, {
|
|
54
|
+
...finding,
|
|
55
|
+
issues: dedupeIssues(finding.issues),
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
existing.severity = severityMax(existing.severity, finding.severity);
|
|
60
|
+
existing.issues = dedupeIssues([...existing.issues, ...finding.issues]);
|
|
61
|
+
};
|
|
62
|
+
primary.forEach(insert);
|
|
63
|
+
secondary.forEach(insert);
|
|
64
|
+
return [...grouped.values()]
|
|
65
|
+
.map((finding) => ({
|
|
66
|
+
...finding,
|
|
67
|
+
issues: finding.issues.sort((left, right) => {
|
|
68
|
+
const lineDelta = (left.line ?? 0) - (right.line ?? 0);
|
|
69
|
+
if (lineDelta !== 0) {
|
|
70
|
+
return lineDelta;
|
|
71
|
+
}
|
|
72
|
+
return left.title.localeCompare(right.title);
|
|
73
|
+
}),
|
|
74
|
+
}))
|
|
75
|
+
.sort((left, right) => left.file.localeCompare(right.file));
|
|
76
|
+
}
|