pinata-security-cli 0.1.6 → 0.2.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/dist/cli/index.js +480 -53
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/categories/definitions/data/precision-loss.yml +2 -2
- package/src/categories/definitions/performance/memory-bloat.yml +2 -2
- package/src/categories/definitions/security/timing-attack.yml +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import fs, { mkdir, writeFile, readFile,
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import fs, { mkdir, writeFile, readFile, stat, readdir } from 'fs/promises';
|
|
4
4
|
import path, { dirname, resolve, basename, relative, join, extname } from 'path';
|
|
5
5
|
import { useState } from 'react';
|
|
6
6
|
import { render, useApp, useInput, Box, Text } from 'ink';
|
|
7
7
|
import Spinner from 'ink-spinner';
|
|
8
8
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
|
|
9
10
|
import { homedir } from 'os';
|
|
10
|
-
import { z } from 'zod';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
import chalk5 from 'chalk';
|
|
13
13
|
import { Command } from 'commander';
|
|
@@ -124,6 +124,35 @@ var init_result = __esm({
|
|
|
124
124
|
"src/lib/result.ts"() {
|
|
125
125
|
}
|
|
126
126
|
});
|
|
127
|
+
var SEVERITY_WEIGHTS, CONFIDENCE_WEIGHTS, PRIORITY_WEIGHTS, DEFAULT_TEST_PATTERNS;
|
|
128
|
+
var init_types = __esm({
|
|
129
|
+
"src/core/scanner/types.ts"() {
|
|
130
|
+
SEVERITY_WEIGHTS = {
|
|
131
|
+
critical: 4,
|
|
132
|
+
high: 3,
|
|
133
|
+
medium: 2,
|
|
134
|
+
low: 1
|
|
135
|
+
};
|
|
136
|
+
CONFIDENCE_WEIGHTS = {
|
|
137
|
+
high: 1,
|
|
138
|
+
medium: 0.7,
|
|
139
|
+
low: 0.4
|
|
140
|
+
};
|
|
141
|
+
PRIORITY_WEIGHTS = {
|
|
142
|
+
P0: 3,
|
|
143
|
+
P1: 2,
|
|
144
|
+
P2: 1
|
|
145
|
+
};
|
|
146
|
+
DEFAULT_TEST_PATTERNS = {
|
|
147
|
+
python: ["test_*.py", "*_test.py", "tests/**/*.py", "test/**/*.py"],
|
|
148
|
+
typescript: ["*.test.ts", "*.spec.ts", "__tests__/**/*.ts", "tests/**/*.ts"],
|
|
149
|
+
javascript: ["*.test.js", "*.spec.js", "__tests__/**/*.js", "tests/**/*.js"],
|
|
150
|
+
go: ["*_test.go"],
|
|
151
|
+
java: ["*Test.java", "*Tests.java", "src/test/**/*.java"],
|
|
152
|
+
rust: ["tests/**/*.rs"]
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
});
|
|
127
156
|
function getCachePath(projectRoot) {
|
|
128
157
|
return resolve(projectRoot, CACHE_DIR, CACHE_FILE);
|
|
129
158
|
}
|
|
@@ -131,9 +160,7 @@ async function saveScanResults(projectRoot, result) {
|
|
|
131
160
|
try {
|
|
132
161
|
const cacheDir = resolve(projectRoot, CACHE_DIR);
|
|
133
162
|
const cachePath = getCachePath(projectRoot);
|
|
134
|
-
|
|
135
|
-
await mkdir(cacheDir, { recursive: true });
|
|
136
|
-
}
|
|
163
|
+
await mkdir(cacheDir, { recursive: true });
|
|
137
164
|
const cached = {
|
|
138
165
|
timestamp: result.completedAt.toISOString(),
|
|
139
166
|
targetDirectory: result.targetDirectory,
|
|
@@ -158,7 +185,10 @@ async function saveScanResults(projectRoot, result) {
|
|
|
158
185
|
async function loadScanResults(projectRoot) {
|
|
159
186
|
try {
|
|
160
187
|
const cachePath = getCachePath(projectRoot);
|
|
161
|
-
|
|
188
|
+
let content;
|
|
189
|
+
try {
|
|
190
|
+
content = await readFile(cachePath, "utf-8");
|
|
191
|
+
} catch {
|
|
162
192
|
return err(
|
|
163
193
|
new PinataError(
|
|
164
194
|
"No cached scan results found. Run `pinata analyze` first.",
|
|
@@ -166,7 +196,6 @@ async function loadScanResults(projectRoot) {
|
|
|
166
196
|
)
|
|
167
197
|
);
|
|
168
198
|
}
|
|
169
|
-
const content = await readFile(cachePath, "utf-8");
|
|
170
199
|
const cached = JSON.parse(content);
|
|
171
200
|
if (cached.version !== CACHE_VERSION) {
|
|
172
201
|
return err(
|
|
@@ -712,6 +741,388 @@ var init_junit_formatter = __esm({
|
|
|
712
741
|
"src/cli/junit-formatter.ts"() {
|
|
713
742
|
}
|
|
714
743
|
});
|
|
744
|
+
|
|
745
|
+
// src/core/verifier/ai-verifier.ts
|
|
746
|
+
var SKIP_PATTERNS, BATCH_PROMPT, SINGLE_ITEM_TEMPLATE, AIVerifier;
|
|
747
|
+
var init_ai_verifier = __esm({
|
|
748
|
+
"src/core/verifier/ai-verifier.ts"() {
|
|
749
|
+
init_types();
|
|
750
|
+
SKIP_PATTERNS = {
|
|
751
|
+
paths: [
|
|
752
|
+
/\.test\.(ts|js|tsx|jsx)$/,
|
|
753
|
+
/\.spec\.(ts|js|tsx|jsx)$/,
|
|
754
|
+
/tests?\//i,
|
|
755
|
+
/fixtures?\//i,
|
|
756
|
+
/mocks?\//i,
|
|
757
|
+
/__tests__\//,
|
|
758
|
+
/node_modules\//,
|
|
759
|
+
/dist\//,
|
|
760
|
+
/\.d\.ts$/,
|
|
761
|
+
/examples?\//i
|
|
762
|
+
],
|
|
763
|
+
// Content patterns that indicate false positive
|
|
764
|
+
content: [
|
|
765
|
+
/\/\/ SAFE:/i,
|
|
766
|
+
// Explicit safe marker
|
|
767
|
+
/\/\/ nosec/i,
|
|
768
|
+
// Security ignore
|
|
769
|
+
/eslint-disable/i,
|
|
770
|
+
/sanitized?|escaped?|validated?/i
|
|
771
|
+
// Near sanitization
|
|
772
|
+
]
|
|
773
|
+
};
|
|
774
|
+
BATCH_PROMPT = `You are a security code reviewer. Analyze these potential vulnerabilities and determine which are real issues vs false positives.
|
|
775
|
+
|
|
776
|
+
For each item, consider:
|
|
777
|
+
- Is user input actually reaching this code?
|
|
778
|
+
- Is there sanitization, validation, or encoding nearby?
|
|
779
|
+
- Is this test code, example code, or production code?
|
|
780
|
+
- Is there context that makes this safe?
|
|
781
|
+
|
|
782
|
+
Be rigorous. Most pattern matches are false positives.
|
|
783
|
+
|
|
784
|
+
ITEMS TO ANALYZE:
|
|
785
|
+
{{items}}
|
|
786
|
+
|
|
787
|
+
Respond with a JSON array. Each object MUST have these exact fields:
|
|
788
|
+
[
|
|
789
|
+
{
|
|
790
|
+
"id": "1",
|
|
791
|
+
"isVulnerable": true/false,
|
|
792
|
+
"confidence": "high"/"medium"/"low",
|
|
793
|
+
"reasoning": "brief explanation"
|
|
794
|
+
},
|
|
795
|
+
...
|
|
796
|
+
]
|
|
797
|
+
|
|
798
|
+
Only return the JSON array, no other text.`;
|
|
799
|
+
SINGLE_ITEM_TEMPLATE = `
|
|
800
|
+
---
|
|
801
|
+
ID: {{id}}
|
|
802
|
+
CATEGORY: {{category}}
|
|
803
|
+
FILE: {{filePath}}:{{lineNumber}}
|
|
804
|
+
CODE:
|
|
805
|
+
\`\`\`{{language}}
|
|
806
|
+
{{codeContext}}
|
|
807
|
+
\`\`\`
|
|
808
|
+
FLAGGED LINE: {{flaggedLine}}
|
|
809
|
+
---`;
|
|
810
|
+
AIVerifier = class {
|
|
811
|
+
config;
|
|
812
|
+
batchSize;
|
|
813
|
+
concurrency;
|
|
814
|
+
constructor(config2) {
|
|
815
|
+
this.config = config2;
|
|
816
|
+
this.batchSize = config2.batchSize ?? 10;
|
|
817
|
+
this.concurrency = config2.concurrency ?? 3;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Verify multiple gaps efficiently using filtering, batching, and parallelism.
|
|
821
|
+
*
|
|
822
|
+
* Flow:
|
|
823
|
+
* 1. Pre-filter obvious false positives (test files, etc.)
|
|
824
|
+
* 2. Group remaining gaps into batches of 10
|
|
825
|
+
* 3. Process 3 batches in parallel
|
|
826
|
+
* 4. Return verified gaps and dismissed with reasons
|
|
827
|
+
*/
|
|
828
|
+
async verifyAll(gaps, getFileContent) {
|
|
829
|
+
const verified = [];
|
|
830
|
+
const dismissed = [];
|
|
831
|
+
const { toVerify, preFiltered } = this.preFilter(gaps);
|
|
832
|
+
dismissed.push(...preFiltered);
|
|
833
|
+
if (toVerify.length === 0) {
|
|
834
|
+
return {
|
|
835
|
+
verified: [],
|
|
836
|
+
dismissed,
|
|
837
|
+
stats: {
|
|
838
|
+
total: gaps.length,
|
|
839
|
+
preFiltered: preFiltered.length,
|
|
840
|
+
aiDismissed: 0,
|
|
841
|
+
aiVerified: 0
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
console.log(`Pre-filtered ${preFiltered.length} gaps. Verifying ${toVerify.length} with AI...`);
|
|
846
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
847
|
+
const uniquePaths = [...new Set(toVerify.map((g) => g.filePath))];
|
|
848
|
+
await Promise.all(
|
|
849
|
+
uniquePaths.map(async (path2) => {
|
|
850
|
+
try {
|
|
851
|
+
fileContents.set(path2, await getFileContent(path2));
|
|
852
|
+
} catch {
|
|
853
|
+
fileContents.set(path2, "");
|
|
854
|
+
}
|
|
855
|
+
})
|
|
856
|
+
);
|
|
857
|
+
const batches = this.createBatches(toVerify, fileContents);
|
|
858
|
+
console.log(`Created ${batches.length} batches of ~${this.batchSize} gaps each`);
|
|
859
|
+
const results = await this.processParallel(batches, toVerify);
|
|
860
|
+
let aiVerified = 0;
|
|
861
|
+
let aiDismissed = 0;
|
|
862
|
+
for (const gap of toVerify) {
|
|
863
|
+
const gapId = `${gap.filePath}:${gap.lineStart}`;
|
|
864
|
+
const result = results.get(gapId);
|
|
865
|
+
if (!result || result.isVulnerable) {
|
|
866
|
+
verified.push(gap);
|
|
867
|
+
aiVerified++;
|
|
868
|
+
} else {
|
|
869
|
+
dismissed.push({
|
|
870
|
+
gap,
|
|
871
|
+
reason: result.reasoning
|
|
872
|
+
});
|
|
873
|
+
aiDismissed++;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
verified,
|
|
878
|
+
dismissed,
|
|
879
|
+
stats: {
|
|
880
|
+
total: gaps.length,
|
|
881
|
+
preFiltered: preFiltered.length,
|
|
882
|
+
aiDismissed,
|
|
883
|
+
aiVerified
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Pre-filter gaps that are obviously false positives without needing AI.
|
|
889
|
+
*/
|
|
890
|
+
preFilter(gaps) {
|
|
891
|
+
const toVerify = [];
|
|
892
|
+
const preFiltered = [];
|
|
893
|
+
for (const gap of gaps) {
|
|
894
|
+
const pathMatch = SKIP_PATTERNS.paths.find((p) => p.test(gap.filePath));
|
|
895
|
+
if (pathMatch) {
|
|
896
|
+
preFiltered.push({
|
|
897
|
+
gap,
|
|
898
|
+
reason: `Skipped: test/example file (${pathMatch.source})`
|
|
899
|
+
});
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (gap.categoryId === "precision-loss" && gap.filePath.endsWith(".ts")) {
|
|
903
|
+
preFiltered.push({
|
|
904
|
+
gap,
|
|
905
|
+
reason: "TypeScript type annotation, not runtime code"
|
|
906
|
+
});
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
toVerify.push(gap);
|
|
910
|
+
}
|
|
911
|
+
return { toVerify, preFiltered };
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Create batches of gaps for batch API calls.
|
|
915
|
+
*/
|
|
916
|
+
createBatches(gaps, fileContents) {
|
|
917
|
+
const batches = [];
|
|
918
|
+
for (let i = 0; i < gaps.length; i += this.batchSize) {
|
|
919
|
+
const batchGaps = gaps.slice(i, i + this.batchSize);
|
|
920
|
+
const items = [];
|
|
921
|
+
const gapIds = [];
|
|
922
|
+
for (let j = 0; j < batchGaps.length; j++) {
|
|
923
|
+
const gap = batchGaps[j];
|
|
924
|
+
const content = fileContents.get(gap.filePath) ?? "";
|
|
925
|
+
const gapId = `${gap.filePath}:${gap.lineStart}`;
|
|
926
|
+
gapIds.push(gapId);
|
|
927
|
+
const codeContext = this.extractContext(content, gap.lineStart, 10);
|
|
928
|
+
const flaggedLine = this.extractLine(content, gap.lineStart);
|
|
929
|
+
items.push(
|
|
930
|
+
SINGLE_ITEM_TEMPLATE.replace("{{id}}", String(j + 1)).replace("{{category}}", gap.categoryName).replace("{{filePath}}", gap.filePath).replace("{{lineNumber}}", String(gap.lineStart)).replace("{{language}}", this.getLanguage(gap.filePath)).replace("{{codeContext}}", codeContext).replace("{{flaggedLine}}", flaggedLine)
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
const prompt = BATCH_PROMPT.replace("{{items}}", items.join("\n"));
|
|
934
|
+
batches.push({ prompt, gapIds });
|
|
935
|
+
}
|
|
936
|
+
return batches;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Process batches in parallel with limited concurrency.
|
|
940
|
+
*/
|
|
941
|
+
async processParallel(batches, gaps) {
|
|
942
|
+
const results = /* @__PURE__ */ new Map();
|
|
943
|
+
let completed = 0;
|
|
944
|
+
for (let i = 0; i < batches.length; i += this.concurrency) {
|
|
945
|
+
const wave = batches.slice(i, i + this.concurrency);
|
|
946
|
+
const waveResults = await Promise.all(
|
|
947
|
+
wave.map(async (batch) => {
|
|
948
|
+
try {
|
|
949
|
+
const response = await this.callAI(batch.prompt);
|
|
950
|
+
return { gapIds: batch.gapIds, response };
|
|
951
|
+
} catch (error) {
|
|
952
|
+
console.error(`Batch failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
953
|
+
return { gapIds: batch.gapIds, response: null };
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
);
|
|
957
|
+
for (const { gapIds, response } of waveResults) {
|
|
958
|
+
if (response) {
|
|
959
|
+
const parsed = this.parseBatchResponse(response);
|
|
960
|
+
for (let j = 0; j < gapIds.length && j < parsed.length; j++) {
|
|
961
|
+
const gapId = gapIds[j];
|
|
962
|
+
const result = parsed[j];
|
|
963
|
+
if (result) {
|
|
964
|
+
results.set(gapId, result);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
completed += wave.length;
|
|
970
|
+
console.log(`Processed ${completed}/${batches.length} batches...`);
|
|
971
|
+
}
|
|
972
|
+
return results;
|
|
973
|
+
}
|
|
974
|
+
extractContext(content, lineNumber, radius) {
|
|
975
|
+
const lines = content.split("\n");
|
|
976
|
+
const start = Math.max(0, lineNumber - radius - 1);
|
|
977
|
+
const end = Math.min(lines.length, lineNumber + radius);
|
|
978
|
+
return lines.slice(start, end).map((line, i) => {
|
|
979
|
+
const num = start + i + 1;
|
|
980
|
+
const marker = num === lineNumber ? ">" : " ";
|
|
981
|
+
return `${marker}${num.toString().padStart(4)}| ${line}`;
|
|
982
|
+
}).join("\n");
|
|
983
|
+
}
|
|
984
|
+
extractLine(content, lineNumber) {
|
|
985
|
+
const lines = content.split("\n");
|
|
986
|
+
return lines[lineNumber - 1] ?? "";
|
|
987
|
+
}
|
|
988
|
+
getLanguage(filePath) {
|
|
989
|
+
if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) return "typescript";
|
|
990
|
+
if (filePath.endsWith(".js") || filePath.endsWith(".jsx")) return "javascript";
|
|
991
|
+
if (filePath.endsWith(".py")) return "python";
|
|
992
|
+
if (filePath.endsWith(".go")) return "go";
|
|
993
|
+
return "text";
|
|
994
|
+
}
|
|
995
|
+
async callAI(prompt) {
|
|
996
|
+
const apiKey = this.config.apiKey ?? this.getApiKeyFromEnv();
|
|
997
|
+
if (!apiKey) {
|
|
998
|
+
throw new Error(`No API key configured for ${this.config.provider}`);
|
|
999
|
+
}
|
|
1000
|
+
if (this.config.provider === "anthropic") {
|
|
1001
|
+
return this.callAnthropic(prompt, apiKey);
|
|
1002
|
+
} else {
|
|
1003
|
+
return this.callOpenAI(prompt, apiKey);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
async callAnthropic(prompt, apiKey) {
|
|
1007
|
+
const controller = new AbortController();
|
|
1008
|
+
const timeout = setTimeout(() => controller.abort(), 6e4);
|
|
1009
|
+
try {
|
|
1010
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1011
|
+
method: "POST",
|
|
1012
|
+
headers: {
|
|
1013
|
+
"Content-Type": "application/json",
|
|
1014
|
+
"x-api-key": apiKey,
|
|
1015
|
+
"anthropic-version": "2023-06-01"
|
|
1016
|
+
},
|
|
1017
|
+
body: JSON.stringify({
|
|
1018
|
+
model: this.config.model ?? "claude-sonnet-4-20250514",
|
|
1019
|
+
max_tokens: 4096,
|
|
1020
|
+
// Larger for batch responses
|
|
1021
|
+
messages: [{ role: "user", content: prompt }]
|
|
1022
|
+
}),
|
|
1023
|
+
signal: controller.signal
|
|
1024
|
+
});
|
|
1025
|
+
if (!response.ok) {
|
|
1026
|
+
const body = await response.text();
|
|
1027
|
+
throw new Error(`Anthropic API error: ${response.status} - ${body}`);
|
|
1028
|
+
}
|
|
1029
|
+
const data = await response.json();
|
|
1030
|
+
return data.content[0]?.text ?? "";
|
|
1031
|
+
} finally {
|
|
1032
|
+
clearTimeout(timeout);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async callOpenAI(prompt, apiKey) {
|
|
1036
|
+
const controller = new AbortController();
|
|
1037
|
+
const timeout = setTimeout(() => controller.abort(), 6e4);
|
|
1038
|
+
try {
|
|
1039
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1040
|
+
method: "POST",
|
|
1041
|
+
headers: {
|
|
1042
|
+
"Content-Type": "application/json",
|
|
1043
|
+
Authorization: `Bearer ${apiKey}`
|
|
1044
|
+
},
|
|
1045
|
+
body: JSON.stringify({
|
|
1046
|
+
model: this.config.model ?? "gpt-4o",
|
|
1047
|
+
messages: [{ role: "user", content: prompt }],
|
|
1048
|
+
max_tokens: 4096
|
|
1049
|
+
}),
|
|
1050
|
+
signal: controller.signal
|
|
1051
|
+
});
|
|
1052
|
+
if (!response.ok) {
|
|
1053
|
+
const body = await response.text();
|
|
1054
|
+
throw new Error(`OpenAI API error: ${response.status} - ${body}`);
|
|
1055
|
+
}
|
|
1056
|
+
const data = await response.json();
|
|
1057
|
+
return data.choices[0]?.message?.content ?? "";
|
|
1058
|
+
} finally {
|
|
1059
|
+
clearTimeout(timeout);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
getApiKeyFromEnv() {
|
|
1063
|
+
if (this.config.provider === "anthropic") {
|
|
1064
|
+
return process.env["ANTHROPIC_API_KEY"] ?? "";
|
|
1065
|
+
}
|
|
1066
|
+
return process.env["OPENAI_API_KEY"] ?? "";
|
|
1067
|
+
}
|
|
1068
|
+
parseBatchResponse(response) {
|
|
1069
|
+
try {
|
|
1070
|
+
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
|
1071
|
+
if (!jsonMatch) {
|
|
1072
|
+
console.error("No JSON array found in batch response");
|
|
1073
|
+
return [];
|
|
1074
|
+
}
|
|
1075
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1076
|
+
return parsed.map((item) => ({
|
|
1077
|
+
id: String(item.id),
|
|
1078
|
+
isVulnerable: Boolean(item.isVulnerable),
|
|
1079
|
+
confidence: item.confidence ?? "medium",
|
|
1080
|
+
reasoning: item.reasoning ?? "No reasoning provided"
|
|
1081
|
+
}));
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
console.error(`Failed to parse batch response: ${error instanceof Error ? error.message : String(error)}`);
|
|
1084
|
+
return [];
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Legacy single-gap verification (kept for backwards compatibility).
|
|
1089
|
+
*/
|
|
1090
|
+
async verify(gap, fileContent) {
|
|
1091
|
+
const result = await this.verifyAll([gap], async () => fileContent);
|
|
1092
|
+
if (result.verified.length > 0) {
|
|
1093
|
+
return {
|
|
1094
|
+
isVulnerable: true,
|
|
1095
|
+
confidence: "high",
|
|
1096
|
+
reasoning: "AI confirmed vulnerability",
|
|
1097
|
+
mitigatingFactors: [],
|
|
1098
|
+
exploitScenario: null,
|
|
1099
|
+
recommendation: "Fix this issue"
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const dismissal = result.dismissed[0];
|
|
1103
|
+
return {
|
|
1104
|
+
isVulnerable: false,
|
|
1105
|
+
confidence: "high",
|
|
1106
|
+
reasoning: dismissal?.reason ?? "AI dismissed as false positive",
|
|
1107
|
+
mitigatingFactors: [],
|
|
1108
|
+
exploitScenario: null,
|
|
1109
|
+
recommendation: "No action needed"
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
// src/core/verifier/index.ts
|
|
1117
|
+
var verifier_exports = {};
|
|
1118
|
+
__export(verifier_exports, {
|
|
1119
|
+
AIVerifier: () => AIVerifier
|
|
1120
|
+
});
|
|
1121
|
+
var init_verifier = __esm({
|
|
1122
|
+
"src/core/verifier/index.ts"() {
|
|
1123
|
+
init_ai_verifier();
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
715
1126
|
function App({ results, loading, error }) {
|
|
716
1127
|
const { exit } = useApp();
|
|
717
1128
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
@@ -1062,15 +1473,10 @@ __export(config_exports, {
|
|
|
1062
1473
|
validateApiKey: () => validateApiKey
|
|
1063
1474
|
});
|
|
1064
1475
|
function ensureConfigDir() {
|
|
1065
|
-
|
|
1066
|
-
mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
1067
|
-
}
|
|
1476
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
1068
1477
|
}
|
|
1069
1478
|
function loadConfig() {
|
|
1070
1479
|
try {
|
|
1071
|
-
if (!existsSync(CONFIG_FILE)) {
|
|
1072
|
-
return {};
|
|
1073
|
-
}
|
|
1074
1480
|
const content = readFileSync(CONFIG_FILE, "utf-8");
|
|
1075
1481
|
const parsed = JSON.parse(content);
|
|
1076
1482
|
const result = ConfigSchema.safeParse(parsed);
|
|
@@ -2556,32 +2962,7 @@ function detectLanguage(filePath) {
|
|
|
2556
2962
|
}
|
|
2557
2963
|
init_errors();
|
|
2558
2964
|
init_result();
|
|
2559
|
-
|
|
2560
|
-
critical: 4,
|
|
2561
|
-
high: 3,
|
|
2562
|
-
medium: 2,
|
|
2563
|
-
low: 1
|
|
2564
|
-
};
|
|
2565
|
-
var CONFIDENCE_WEIGHTS = {
|
|
2566
|
-
high: 1,
|
|
2567
|
-
medium: 0.7,
|
|
2568
|
-
low: 0.4
|
|
2569
|
-
};
|
|
2570
|
-
var PRIORITY_WEIGHTS = {
|
|
2571
|
-
P0: 3,
|
|
2572
|
-
P1: 2,
|
|
2573
|
-
P2: 1
|
|
2574
|
-
};
|
|
2575
|
-
var DEFAULT_TEST_PATTERNS = {
|
|
2576
|
-
python: ["test_*.py", "*_test.py", "tests/**/*.py", "test/**/*.py"],
|
|
2577
|
-
typescript: ["*.test.ts", "*.spec.ts", "__tests__/**/*.ts", "tests/**/*.ts"],
|
|
2578
|
-
javascript: ["*.test.js", "*.spec.js", "__tests__/**/*.js", "tests/**/*.js"],
|
|
2579
|
-
go: ["*_test.go"],
|
|
2580
|
-
java: ["*Test.java", "*Tests.java", "src/test/**/*.java"],
|
|
2581
|
-
rust: ["tests/**/*.rs"]
|
|
2582
|
-
};
|
|
2583
|
-
|
|
2584
|
-
// src/core/scanner/scanner.ts
|
|
2965
|
+
init_types();
|
|
2585
2966
|
var DEFAULT_OPTIONS = {
|
|
2586
2967
|
excludeDirs: [
|
|
2587
2968
|
// Package managers
|
|
@@ -2656,7 +3037,12 @@ var Scanner = class {
|
|
|
2656
3037
|
const startedAt = /* @__PURE__ */ new Date();
|
|
2657
3038
|
const opts = this.mergeOptions(targetDirectory, options);
|
|
2658
3039
|
this.log.info(`Starting scan of ${targetDirectory}`);
|
|
2659
|
-
|
|
3040
|
+
try {
|
|
3041
|
+
const dirStat = await stat(targetDirectory);
|
|
3042
|
+
if (!dirStat.isDirectory()) {
|
|
3043
|
+
return err(new AnalysisError(`Not a directory: ${targetDirectory}`));
|
|
3044
|
+
}
|
|
3045
|
+
} catch {
|
|
2660
3046
|
return err(new AnalysisError(`Directory not found: ${targetDirectory}`));
|
|
2661
3047
|
}
|
|
2662
3048
|
const categoriesResult = this.getCategoriesToScan(opts);
|
|
@@ -2852,9 +3238,6 @@ var Scanner = class {
|
|
|
2852
3238
|
*/
|
|
2853
3239
|
readPinataIgnore(targetDirectory) {
|
|
2854
3240
|
const ignorePath = resolve(targetDirectory, ".pinataignore");
|
|
2855
|
-
if (!existsSync(ignorePath)) {
|
|
2856
|
-
return [];
|
|
2857
|
-
}
|
|
2858
3241
|
try {
|
|
2859
3242
|
const { readFileSync: readFileSync2 } = __require("fs");
|
|
2860
3243
|
const content = readFileSync2(ignorePath, "utf-8");
|
|
@@ -3189,8 +3572,11 @@ function createScanner(categoryStore) {
|
|
|
3189
3572
|
return new Scanner(categoryStore);
|
|
3190
3573
|
}
|
|
3191
3574
|
|
|
3575
|
+
// src/core/scanner/index.ts
|
|
3576
|
+
init_types();
|
|
3577
|
+
|
|
3192
3578
|
// src/core/index.ts
|
|
3193
|
-
var VERSION = "0.1
|
|
3579
|
+
var VERSION = "0.2.1";
|
|
3194
3580
|
|
|
3195
3581
|
// src/lib/index.ts
|
|
3196
3582
|
init_errors();
|
|
@@ -4111,11 +4497,11 @@ var AIService = class {
|
|
|
4111
4497
|
*/
|
|
4112
4498
|
getApiKeyFromConfig(provider) {
|
|
4113
4499
|
try {
|
|
4114
|
-
const { existsSync:
|
|
4500
|
+
const { existsSync: existsSync4, readFileSync: readFileSync2 } = __require("fs");
|
|
4115
4501
|
const { homedir: homedir2 } = __require("os");
|
|
4116
4502
|
const { join: join3 } = __require("path");
|
|
4117
4503
|
const configPath = join3(homedir2(), ".pinata", "config.json");
|
|
4118
|
-
if (!
|
|
4504
|
+
if (!existsSync4(configPath)) {
|
|
4119
4505
|
return "";
|
|
4120
4506
|
}
|
|
4121
4507
|
const content = readFileSync2(configPath, "utf-8");
|
|
@@ -4762,13 +5148,13 @@ async function writeGeneratedTests(tests, basePath, outputDir) {
|
|
|
4762
5148
|
for (const [outputPath, fileTests] of testsByFile) {
|
|
4763
5149
|
try {
|
|
4764
5150
|
const dir = dirname(outputPath);
|
|
4765
|
-
|
|
4766
|
-
await mkdir(dir, { recursive: true });
|
|
4767
|
-
}
|
|
4768
|
-
const fileExists = existsSync(outputPath);
|
|
5151
|
+
await mkdir(dir, { recursive: true });
|
|
4769
5152
|
let existingContent = "";
|
|
4770
|
-
|
|
5153
|
+
let fileExists = false;
|
|
5154
|
+
try {
|
|
4771
5155
|
existingContent = await readFile(outputPath, "utf-8");
|
|
5156
|
+
fileExists = true;
|
|
5157
|
+
} catch {
|
|
4772
5158
|
}
|
|
4773
5159
|
const contentParts = [];
|
|
4774
5160
|
const allImports = /* @__PURE__ */ new Set();
|
|
@@ -5502,7 +5888,7 @@ function getDefinitionsPath() {
|
|
|
5502
5888
|
}
|
|
5503
5889
|
var program = new Command();
|
|
5504
5890
|
program.name("pinata").description("AI-powered test coverage analysis and generation").version(VERSION);
|
|
5505
|
-
program.command("analyze [path]").description("Analyze codebase for test coverage gaps").option("-o, --output <format>", "Output format: terminal, json, markdown, sarif, html, junit-xml", "terminal").option("-d, --domains <domains>", "Filter to specific domains (comma-separated)").option("-s, --severity <level>", "Minimum severity: critical, high, medium, low", "low").option("-c, --confidence <level>", "Minimum confidence: high, medium, low", "high").option("--fail-on <level>", "Exit non-zero if gaps at level: critical, high, medium").option("--exclude <dirs>", "Directories to exclude (comma-separated)").option("-v, --verbose", "Verbose output").option("-q, --quiet", "Quiet mode (errors only)").action(async (targetPath, options) => {
|
|
5891
|
+
program.command("analyze [path]").description("Analyze codebase for test coverage gaps").option("-o, --output <format>", "Output format: terminal, json, markdown, sarif, html, junit-xml", "terminal").option("-d, --domains <domains>", "Filter to specific domains (comma-separated)").option("-s, --severity <level>", "Minimum severity: critical, high, medium, low", "low").option("-c, --confidence <level>", "Minimum confidence: high, medium, low", "high").option("--fail-on <level>", "Exit non-zero if gaps at level: critical, high, medium").option("--exclude <dirs>", "Directories to exclude (comma-separated)").option("--verify", "Use AI to verify each match (reduces false positives)").option("-v, --verbose", "Verbose output").option("-q, --quiet", "Quiet mode (errors only)").action(async (targetPath, options) => {
|
|
5506
5892
|
const isQuiet = Boolean(options["quiet"]);
|
|
5507
5893
|
const isVerbose = Boolean(options["verbose"]);
|
|
5508
5894
|
if (isQuiet) {
|
|
@@ -5586,6 +5972,47 @@ program.command("analyze [path]").description("Analyze codebase for test coverag
|
|
|
5586
5972
|
process.exit(1);
|
|
5587
5973
|
}
|
|
5588
5974
|
spinner?.stop();
|
|
5975
|
+
const shouldVerify = Boolean(options["verify"]);
|
|
5976
|
+
if (shouldVerify && scanResult.data.gaps.length > 0) {
|
|
5977
|
+
const verifySpinner = showSpinner ? ora("Verifying gaps with AI...").start() : null;
|
|
5978
|
+
try {
|
|
5979
|
+
const { AIVerifier: AIVerifier2 } = await Promise.resolve().then(() => (init_verifier(), verifier_exports));
|
|
5980
|
+
const { readFile: readFile5 } = await import('fs/promises');
|
|
5981
|
+
const verifier = new AIVerifier2({ provider: "anthropic" });
|
|
5982
|
+
const { verified, dismissed, stats } = await verifier.verifyAll(
|
|
5983
|
+
scanResult.data.gaps,
|
|
5984
|
+
async (path2) => readFile5(path2, "utf-8")
|
|
5985
|
+
);
|
|
5986
|
+
scanResult.data.gaps = verified;
|
|
5987
|
+
const severityWeights = { critical: 10, high: 5, medium: 2, low: 1 };
|
|
5988
|
+
let deduction = 0;
|
|
5989
|
+
for (const gap of verified) {
|
|
5990
|
+
deduction += severityWeights[gap.severity] ?? 1;
|
|
5991
|
+
}
|
|
5992
|
+
const newOverall = Math.max(0, 100 - deduction);
|
|
5993
|
+
const newGrade = newOverall >= 90 ? "A" : newOverall >= 80 ? "B" : newOverall >= 70 ? "C" : newOverall >= 60 ? "D" : "F";
|
|
5994
|
+
scanResult.data.score.overall = newOverall;
|
|
5995
|
+
scanResult.data.score.grade = newGrade;
|
|
5996
|
+
verifySpinner?.succeed(
|
|
5997
|
+
`AI Verification: ${stats.total} total \u2192 ${stats.preFiltered} pre-filtered \u2192 ${stats.aiVerified} verified, ${stats.aiDismissed} AI-dismissed`
|
|
5998
|
+
);
|
|
5999
|
+
if (isVerbose && dismissed.length > 0) {
|
|
6000
|
+
console.log(chalk5.gray("\nDismissed as false positives:"));
|
|
6001
|
+
for (const { gap, reason } of dismissed.slice(0, 5)) {
|
|
6002
|
+
console.log(chalk5.gray(` - ${gap.categoryName} at ${gap.filePath}:${gap.lineStart}`));
|
|
6003
|
+
console.log(chalk5.gray(` Reason: ${reason.slice(0, 100)}...`));
|
|
6004
|
+
}
|
|
6005
|
+
if (dismissed.length > 5) {
|
|
6006
|
+
console.log(chalk5.gray(` ... and ${dismissed.length - 5} more`));
|
|
6007
|
+
}
|
|
6008
|
+
}
|
|
6009
|
+
} catch (error) {
|
|
6010
|
+
verifySpinner?.fail("AI verification failed (results unverified)");
|
|
6011
|
+
if (isVerbose) {
|
|
6012
|
+
console.error(chalk5.yellow(`Verification error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6013
|
+
}
|
|
6014
|
+
}
|
|
6015
|
+
}
|
|
5589
6016
|
const cacheResult = await saveScanResults(process.cwd(), scanResult.data);
|
|
5590
6017
|
if (!cacheResult.success) {
|
|
5591
6018
|
logger.debug(`Failed to cache results: ${cacheResult.error.message}`);
|