@dailephd/my-dev-kit-lab 0.3.0 → 0.3.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 +19 -6
- package/dist/scripts/audits/runAudit.js +9 -2
- package/dist/scripts/security/validate.js +8 -29
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +138 -11
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +93 -1
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +176 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +50 -0
- package/dist/src/audits/codeRot/utils/sourceFactsLookup.js +53 -0
- package/dist/src/audits/core/auditConfig.js +2 -2
- package/dist/src/audits/core/auditRunner.js +23 -1
- package/dist/src/audits/core/auditTypes.js +14 -7
- package/dist/src/audits/core/collectSourceFacts.js +142 -0
- package/dist/src/audits/core/languageAnalyzerRegistry.js +36 -0
- package/dist/src/audits/core/projectInventory.js +185 -3
- package/dist/src/audits/core/pythonAnalyzer.js +328 -0
- package/dist/src/audits/core/pythonProjectMetadata.js +80 -0
- package/dist/src/audits/core/sourceFacts.js +17 -0
- package/dist/src/audits/core/typescriptJavaScriptAnalyzer.js +299 -0
- package/dist/src/audits/report/auditReportModel.js +15 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +3 -0
- package/dist/src/audits/report/renderAuditTextReport.js +59 -3
- package/dist/src/audits/security/mapSecurityFindingToAuditIssue.js +70 -0
- package/dist/src/audits/security/securityAuditAdapter.js +67 -0
- package/dist/src/audits/security/securityAuditTypes.js +17 -0
- package/dist/src/securityValidation/report/buildSecurityReport.js +31 -0
- package/dist/src/securityValidation/validate/resolveTarget.js +9 -0
- package/package.json +97 -96
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.3.1 Batch 3 -- TypeScript/JavaScript source analyzer.
|
|
4
|
+
//
|
|
5
|
+
// Single-file, syntax-only analysis via the TypeScript compiler API:
|
|
6
|
+
// - ts.createSourceFile() builds a per-file AST (no Program, no project
|
|
7
|
+
// resolution, no type checking) that is walked once to extract facts.
|
|
8
|
+
// - ts.transpileModule({ reportDiagnostics: true }) is used purely to
|
|
9
|
+
// obtain syntactic diagnostics -- it is itself a single-file operation
|
|
10
|
+
// (no cross-file resolution), matching the "single-file parsing only"
|
|
11
|
+
// requirement for this batch.
|
|
12
|
+
//
|
|
13
|
+
// Parse-status policy (deliberately conservative, see docs on
|
|
14
|
+
// SourceFactParseStatus): zero syntax diagnostics -> "parsed". Any syntax
|
|
15
|
+
// diagnostic -> "parse-error", even though the AST walk below still runs
|
|
16
|
+
// against TypeScript's best-effort (error-tolerant) parse and whatever facts
|
|
17
|
+
// it manages to extract are still attached -- the parseStatus signals
|
|
18
|
+
// "don't treat these facts as fully reliable", not "no facts exist". This
|
|
19
|
+
// mirrors how collectSourceFacts.ts already reports a caught analyzer
|
|
20
|
+
// exception as parse-error with an empty-fact fallback -- this analyzer just
|
|
21
|
+
// reaches parse-error through diagnostics instead of a thrown exception, and
|
|
22
|
+
// still gets to keep best-effort facts (which a thrown-exception parse-error
|
|
23
|
+
// never has, since collectSourceFacts.ts only has the exception itself).
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const TYPESCRIPT_JAVASCRIPT_ANALYZER_ID = "typescript-javascript-analyzer";
|
|
26
|
+
const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
27
|
+
// AST construction is meaningfully more expensive than a line count -- large
|
|
28
|
+
// files (generated bundles, vendored single-file libraries that slipped past
|
|
29
|
+
// role/path exclusions, etc.) are not parsed. Mirrors the size-bounding
|
|
30
|
+
// philosophy of projectInventory.ts's own read limits, just at a tighter
|
|
31
|
+
// threshold appropriate for actually building an AST.
|
|
32
|
+
const MAX_ANALYZABLE_BYTES = 1_000_000;
|
|
33
|
+
export const TYPESCRIPT_JAVASCRIPT_ANALYZER = {
|
|
34
|
+
id: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
35
|
+
supportedLanguages: ["typescript", "javascript"],
|
|
36
|
+
supportedExtensions: SUPPORTED_EXTENSIONS,
|
|
37
|
+
analyzeFile,
|
|
38
|
+
};
|
|
39
|
+
function analyzeFile(input) {
|
|
40
|
+
const { relativePath, language, role, content, inventoryEntry } = input;
|
|
41
|
+
if (Buffer.byteLength(content, "utf8") > MAX_ANALYZABLE_BYTES) {
|
|
42
|
+
return {
|
|
43
|
+
relativePath,
|
|
44
|
+
language,
|
|
45
|
+
role,
|
|
46
|
+
parseStatus: "file-level-only",
|
|
47
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
48
|
+
lineCount: inventoryEntry.lineCount,
|
|
49
|
+
imports: [],
|
|
50
|
+
exports: [],
|
|
51
|
+
declarations: [],
|
|
52
|
+
references: [],
|
|
53
|
+
diagnostics: [
|
|
54
|
+
{
|
|
55
|
+
severity: "info",
|
|
56
|
+
code: "file-too-large-for-analysis",
|
|
57
|
+
message: `File exceeds the ${MAX_ANALYZABLE_BYTES}-byte analyzer bound; only inventory-level facts are available.`,
|
|
58
|
+
path: relativePath,
|
|
59
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const scriptKind = resolveScriptKind(inventoryEntry.extension);
|
|
65
|
+
const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true, scriptKind);
|
|
66
|
+
const diagnostics = getSyntaxDiagnostics(relativePath, sourceFile);
|
|
67
|
+
const imports = [];
|
|
68
|
+
const exports = [];
|
|
69
|
+
const declarations = [];
|
|
70
|
+
const references = [];
|
|
71
|
+
const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
72
|
+
function pushDeclaration(node, name, kind) {
|
|
73
|
+
const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword);
|
|
74
|
+
const isDefault = hasModifier(node, ts.SyntaxKind.DefaultKeyword);
|
|
75
|
+
if (name) {
|
|
76
|
+
declarations.push({ name, kind, exported: isExported, line: lineOf(node) });
|
|
77
|
+
}
|
|
78
|
+
if (isDefault) {
|
|
79
|
+
exports.push({ name: name ?? "default", kind: "default", line: lineOf(node) });
|
|
80
|
+
}
|
|
81
|
+
else if (isExported && name) {
|
|
82
|
+
exports.push({ name, kind: "named", line: lineOf(node) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function handleImportDeclaration(node) {
|
|
86
|
+
if (!ts.isStringLiteralLike(node.moduleSpecifier))
|
|
87
|
+
return;
|
|
88
|
+
const source = node.moduleSpecifier.text;
|
|
89
|
+
const importedNames = [];
|
|
90
|
+
const clause = node.importClause;
|
|
91
|
+
if (clause) {
|
|
92
|
+
if (clause.name)
|
|
93
|
+
importedNames.push(clause.name.text);
|
|
94
|
+
const bindings = clause.namedBindings;
|
|
95
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
96
|
+
importedNames.push(bindings.name.text);
|
|
97
|
+
}
|
|
98
|
+
else if (bindings && ts.isNamedImports(bindings)) {
|
|
99
|
+
for (const element of bindings.elements)
|
|
100
|
+
importedNames.push(element.name.text);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
imports.push({
|
|
104
|
+
source,
|
|
105
|
+
kind: "static",
|
|
106
|
+
line: lineOf(node),
|
|
107
|
+
...(importedNames.length > 0 ? { importedNames } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function handleExportDeclaration(node) {
|
|
111
|
+
const source = node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ? node.moduleSpecifier.text : undefined;
|
|
112
|
+
if (source) {
|
|
113
|
+
imports.push({ source, kind: "re-export", line: lineOf(node) });
|
|
114
|
+
}
|
|
115
|
+
if (!node.exportClause) {
|
|
116
|
+
if (source)
|
|
117
|
+
exports.push({ name: "*", kind: "namespace", line: lineOf(node) });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (ts.isNamespaceExport(node.exportClause)) {
|
|
121
|
+
exports.push({ name: node.exportClause.name.text, kind: "namespace", line: lineOf(node) });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const element of node.exportClause.elements) {
|
|
125
|
+
exports.push({ name: element.name.text, kind: "named", line: lineOf(node) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function handleExportAssignment(node) {
|
|
129
|
+
if (node.isExportEquals) {
|
|
130
|
+
exports.push({ name: "module.exports", kind: "commonjs", line: lineOf(node) });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const expr = node.expression;
|
|
134
|
+
let name = "default";
|
|
135
|
+
if (ts.isIdentifier(expr)) {
|
|
136
|
+
name = expr.text;
|
|
137
|
+
}
|
|
138
|
+
else if ((ts.isFunctionExpression(expr) || ts.isClassExpression(expr)) && expr.name) {
|
|
139
|
+
name = expr.name.text;
|
|
140
|
+
}
|
|
141
|
+
exports.push({ name, kind: "default", line: lineOf(node) });
|
|
142
|
+
}
|
|
143
|
+
function handleVariableStatement(node) {
|
|
144
|
+
const isExported = hasModifier(node, ts.SyntaxKind.ExportKeyword);
|
|
145
|
+
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
|
146
|
+
for (const decl of node.declarationList.declarations) {
|
|
147
|
+
// Destructuring patterns (`const { a, b } = x`) are conservatively
|
|
148
|
+
// skipped -- only simple identifier bindings are recorded.
|
|
149
|
+
if (!ts.isIdentifier(decl.name))
|
|
150
|
+
continue;
|
|
151
|
+
declarations.push({
|
|
152
|
+
name: decl.name.text,
|
|
153
|
+
kind: isConst ? "constant" : "variable",
|
|
154
|
+
exported: isExported,
|
|
155
|
+
line: lineOf(decl),
|
|
156
|
+
});
|
|
157
|
+
if (isExported) {
|
|
158
|
+
exports.push({ name: decl.name.text, kind: "named", line: lineOf(decl) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function handleCommonJsAssignment(node) {
|
|
163
|
+
const left = node.left;
|
|
164
|
+
if (!ts.isPropertyAccessExpression(left) || !ts.isIdentifier(left.expression))
|
|
165
|
+
return;
|
|
166
|
+
if (left.expression.text === "module" && left.name.text === "exports") {
|
|
167
|
+
exports.push({ name: "module.exports", kind: "commonjs", line: lineOf(node) });
|
|
168
|
+
}
|
|
169
|
+
else if (left.expression.text === "exports") {
|
|
170
|
+
exports.push({ name: left.name.text, kind: "commonjs", line: lineOf(node) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function handleCallExpression(node) {
|
|
174
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === "require") {
|
|
175
|
+
const arg = node.arguments[0];
|
|
176
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
177
|
+
imports.push({ source: arg.text, kind: "commonjs", line: lineOf(node) });
|
|
178
|
+
}
|
|
179
|
+
return; // already represented as an import fact -- do not also reference "require".
|
|
180
|
+
}
|
|
181
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
182
|
+
const arg = node.arguments[0];
|
|
183
|
+
if (arg && ts.isStringLiteralLike(arg)) {
|
|
184
|
+
imports.push({ source: arg.text, kind: "dynamic", line: lineOf(node) });
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (ts.isIdentifier(node.expression)) {
|
|
189
|
+
// Conservative reference extraction: only bare-identifier call
|
|
190
|
+
// expressions (`foo()`), not property-access calls (`a.b()`) or any
|
|
191
|
+
// other identifier usage -- no scope analysis, no unused-detection.
|
|
192
|
+
references.push({ name: node.expression.text, kind: "call", line: lineOf(node) });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function visit(node) {
|
|
196
|
+
if (ts.isImportDeclaration(node)) {
|
|
197
|
+
handleImportDeclaration(node);
|
|
198
|
+
}
|
|
199
|
+
else if (ts.isExportDeclaration(node)) {
|
|
200
|
+
handleExportDeclaration(node);
|
|
201
|
+
}
|
|
202
|
+
else if (ts.isExportAssignment(node)) {
|
|
203
|
+
handleExportAssignment(node);
|
|
204
|
+
}
|
|
205
|
+
else if (ts.isFunctionDeclaration(node)) {
|
|
206
|
+
pushDeclaration(node, node.name?.text, "function");
|
|
207
|
+
}
|
|
208
|
+
else if (ts.isClassDeclaration(node)) {
|
|
209
|
+
pushDeclaration(node, node.name?.text, "class");
|
|
210
|
+
}
|
|
211
|
+
else if (ts.isInterfaceDeclaration(node)) {
|
|
212
|
+
pushDeclaration(node, node.name.text, "interface");
|
|
213
|
+
}
|
|
214
|
+
else if (ts.isTypeAliasDeclaration(node)) {
|
|
215
|
+
pushDeclaration(node, node.name.text, "type");
|
|
216
|
+
}
|
|
217
|
+
else if (ts.isEnumDeclaration(node)) {
|
|
218
|
+
pushDeclaration(node, node.name.text, "enum");
|
|
219
|
+
}
|
|
220
|
+
else if (ts.isVariableStatement(node)) {
|
|
221
|
+
handleVariableStatement(node);
|
|
222
|
+
}
|
|
223
|
+
else if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
224
|
+
declarations.push({ name: node.name.text, kind: "method", exported: false, line: lineOf(node) });
|
|
225
|
+
}
|
|
226
|
+
else if (ts.isCallExpression(node)) {
|
|
227
|
+
handleCallExpression(node);
|
|
228
|
+
}
|
|
229
|
+
else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
230
|
+
handleCommonJsAssignment(node);
|
|
231
|
+
}
|
|
232
|
+
ts.forEachChild(node, visit);
|
|
233
|
+
}
|
|
234
|
+
visit(sourceFile);
|
|
235
|
+
const parseStatus = diagnostics.length > 0 ? "parse-error" : "parsed";
|
|
236
|
+
return {
|
|
237
|
+
relativePath,
|
|
238
|
+
language,
|
|
239
|
+
role,
|
|
240
|
+
parseStatus,
|
|
241
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
242
|
+
lineCount: inventoryEntry.lineCount,
|
|
243
|
+
imports,
|
|
244
|
+
exports,
|
|
245
|
+
declarations,
|
|
246
|
+
references,
|
|
247
|
+
diagnostics,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function resolveScriptKind(extension) {
|
|
251
|
+
switch (extension) {
|
|
252
|
+
case ".tsx":
|
|
253
|
+
return ts.ScriptKind.TSX;
|
|
254
|
+
case ".jsx":
|
|
255
|
+
return ts.ScriptKind.JSX;
|
|
256
|
+
case ".js":
|
|
257
|
+
case ".mjs":
|
|
258
|
+
case ".cjs":
|
|
259
|
+
return ts.ScriptKind.JS;
|
|
260
|
+
case ".ts":
|
|
261
|
+
case ".mts":
|
|
262
|
+
case ".cts":
|
|
263
|
+
default:
|
|
264
|
+
return ts.ScriptKind.TS;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function hasModifier(node, kind) {
|
|
268
|
+
if (!ts.canHaveModifiers(node))
|
|
269
|
+
return false;
|
|
270
|
+
const modifiers = ts.getModifiers(node);
|
|
271
|
+
return modifiers?.some((m) => m.kind === kind) ?? false;
|
|
272
|
+
}
|
|
273
|
+
// Reads syntax diagnostics directly off the already-built SourceFile rather
|
|
274
|
+
// than running a second full parse via ts.transpileModule(). `parseDiagnostics`
|
|
275
|
+
// is not part of the public TypeScript.d.ts surface, but it is populated by
|
|
276
|
+
// createSourceFile() itself (every syntax error the parser recovers from is
|
|
277
|
+
// recorded there) and has been stable across TypeScript versions for years --
|
|
278
|
+
// this is a widely-used pattern for exactly this "syntax diagnostics without
|
|
279
|
+
// a second parse/transpile pass" use case. Benchmarked at ~5x faster than
|
|
280
|
+
// transpileModule() over this repo's own ~375 TS/JS files, which matters
|
|
281
|
+
// because collectSourceFacts.ts calls this analyzer once per eligible file
|
|
282
|
+
// on every audit run (including this repo's own self-audit).
|
|
283
|
+
function getSyntaxDiagnostics(relativePath, sourceFile) {
|
|
284
|
+
const parseDiagnostics = sourceFile.parseDiagnostics ?? [];
|
|
285
|
+
return parseDiagnostics.map((diagnostic) => toSourceFactDiagnostic(relativePath, diagnostic));
|
|
286
|
+
}
|
|
287
|
+
function toSourceFactDiagnostic(relativePath, diagnostic) {
|
|
288
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
289
|
+
const line = diagnostic.file && diagnostic.start !== undefined
|
|
290
|
+
? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1
|
|
291
|
+
: undefined;
|
|
292
|
+
return {
|
|
293
|
+
severity: diagnostic.category === ts.DiagnosticCategory.Warning ? "warning" : "error",
|
|
294
|
+
code: `TS${diagnostic.code}`,
|
|
295
|
+
message: line !== undefined ? `Line ${line}: ${message}` : message,
|
|
296
|
+
path: relativePath,
|
|
297
|
+
analyzerId: TYPESCRIPT_JAVASCRIPT_ANALYZER_ID,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -22,6 +22,8 @@ export function summarizeInventory(inventory) {
|
|
|
22
22
|
skippedFileCount: inventory.skippedFileCount,
|
|
23
23
|
filesByCategory: inventory.filesByCategory,
|
|
24
24
|
filesByExtension: inventory.filesByExtension,
|
|
25
|
+
filesByLanguage: inventory.filesByLanguage,
|
|
26
|
+
filesByRole: inventory.filesByRole,
|
|
25
27
|
excludedDirectorySummary: inventory.excludedDirectorySummary,
|
|
26
28
|
warnings: inventory.warnings,
|
|
27
29
|
};
|
|
@@ -47,6 +49,16 @@ export function summarizeSourceOfTruth(sourceOfTruth) {
|
|
|
47
49
|
warnings: sourceOfTruth.warnings,
|
|
48
50
|
};
|
|
49
51
|
}
|
|
52
|
+
export function summarizeSourceFacts(sourceFacts) {
|
|
53
|
+
return {
|
|
54
|
+
totalFilesAnalyzed: sourceFacts.files.length,
|
|
55
|
+
filesByLanguage: sourceFacts.filesByLanguage,
|
|
56
|
+
filesByParseStatus: sourceFacts.filesByParseStatus,
|
|
57
|
+
analyzerDiagnosticCount: sourceFacts.analyzerDiagnostics.length,
|
|
58
|
+
filesWithDiagnosticsCount: sourceFacts.files.filter((f) => f.diagnostics.length > 0).length,
|
|
59
|
+
warnings: sourceFacts.warnings,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
50
62
|
const MAX_JSON_RECOMMENDATIONS = 200;
|
|
51
63
|
const MAX_TEXT_RECOMMENDATIONS = 10;
|
|
52
64
|
const SEVERITY_ORDER = ["blocker", "high", "medium", "low", "info"];
|
|
@@ -176,6 +188,9 @@ export function buildAuditReportModel(result, opts) {
|
|
|
176
188
|
},
|
|
177
189
|
inventory: summarizeInventory(result.inventory),
|
|
178
190
|
sourceOfTruth: summarizeSourceOfTruth(result.sourceOfTruth),
|
|
191
|
+
sourceFacts: summarizeSourceFacts(result.sourceFacts),
|
|
192
|
+
pythonProjectMetadata: result.pythonProjectMetadata,
|
|
193
|
+
securitySummary: result.securitySummary,
|
|
179
194
|
detectors,
|
|
180
195
|
issues,
|
|
181
196
|
skippedDetectors: result.skippedDetectors,
|
|
@@ -18,6 +18,9 @@ export function renderAuditJsonReport(model) {
|
|
|
18
18
|
summary: model.summary,
|
|
19
19
|
inventory: model.inventory,
|
|
20
20
|
sourceOfTruth: model.sourceOfTruth,
|
|
21
|
+
sourceFacts: model.sourceFacts,
|
|
22
|
+
pythonProjectMetadata: model.pythonProjectMetadata,
|
|
23
|
+
securitySummary: model.securitySummary,
|
|
21
24
|
detectors: model.detectors,
|
|
22
25
|
issues: model.issues,
|
|
23
26
|
skippedDetectors: model.skippedDetectors,
|
|
@@ -70,10 +70,34 @@ export function renderAuditTextReport(model) {
|
|
|
70
70
|
lines.push(divider("-"));
|
|
71
71
|
lines.push(` total=${model.inventory.totalFileCount} scanned=${model.inventory.totalScannedFileCount} skipped=${model.inventory.skippedFileCount}`);
|
|
72
72
|
lines.push(` source=${model.inventory.filesByCategory.source} tests=${model.inventory.filesByCategory.tests} docs=${model.inventory.filesByCategory.docs} package=${model.inventory.filesByCategory.package} config=${model.inventory.filesByCategory.config} scripts=${model.inventory.filesByCategory.scripts} ci=${model.inventory.filesByCategory.ci} generated=${model.inventory.filesByCategory.generated} report=${model.inventory.filesByCategory.report} unknown=${model.inventory.filesByCategory.unknown}`);
|
|
73
|
+
lines.push(` languages: typescript=${model.inventory.filesByLanguage.typescript} javascript=${model.inventory.filesByLanguage.javascript} python=${model.inventory.filesByLanguage.python} java=${model.inventory.filesByLanguage.java} kotlin=${model.inventory.filesByLanguage.kotlin} json=${model.inventory.filesByLanguage.json} markdown=${model.inventory.filesByLanguage.markdown} yaml=${model.inventory.filesByLanguage.yaml} xml=${model.inventory.filesByLanguage.xml} toml=${model.inventory.filesByLanguage.toml} unknown=${model.inventory.filesByLanguage.unknown}`);
|
|
74
|
+
lines.push(` roles: source=${model.inventory.filesByRole.source} test=${model.inventory.filesByRole.test} docs=${model.inventory.filesByRole.docs} config=${model.inventory.filesByRole.config} package=${model.inventory.filesByRole.package} generated=${model.inventory.filesByRole.generated} build-output=${model.inventory.filesByRole["build-output"]} vendor=${model.inventory.filesByRole.vendor} report-output=${model.inventory.filesByRole["report-output"]} unknown=${model.inventory.filesByRole.unknown}`);
|
|
73
75
|
if (model.inventory.warnings.length > 0) {
|
|
74
76
|
lines.push(` warnings: ${model.inventory.warnings.length}`);
|
|
75
77
|
}
|
|
76
78
|
lines.push("");
|
|
79
|
+
lines.push("Source facts summary");
|
|
80
|
+
lines.push(divider("-"));
|
|
81
|
+
lines.push(` analyzed=${model.sourceFacts.totalFilesAnalyzed}`);
|
|
82
|
+
lines.push(` parsed=${model.sourceFacts.filesByParseStatus.parsed} file-level-only=${model.sourceFacts.filesByParseStatus["file-level-only"]} unsupported=${model.sourceFacts.filesByParseStatus.unsupported} parse-error=${model.sourceFacts.filesByParseStatus["parse-error"]} skipped=${model.sourceFacts.filesByParseStatus.skipped}`);
|
|
83
|
+
if (model.sourceFacts.analyzerDiagnosticCount > 0) {
|
|
84
|
+
lines.push(` analyzer diagnostics: ${model.sourceFacts.analyzerDiagnosticCount}`);
|
|
85
|
+
}
|
|
86
|
+
if (model.sourceFacts.filesWithDiagnosticsCount > 0) {
|
|
87
|
+
lines.push(` files with a per-file diagnostic: ${model.sourceFacts.filesWithDiagnosticsCount}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
// v0.3.2 Batch 3 -- informational only. Presence booleans and a
|
|
91
|
+
// best-effort project name/pytest-configuration flag, never a
|
|
92
|
+
// release-readiness or dependency-health signal -- see
|
|
93
|
+
// pythonProjectMetadata.ts's own header comment.
|
|
94
|
+
lines.push("Python project metadata");
|
|
95
|
+
lines.push(divider("-"));
|
|
96
|
+
const pm = model.pythonProjectMetadata;
|
|
97
|
+
lines.push(` pyproject.toml=${pm.hasPyprojectToml} requirements.txt=${pm.hasRequirementsTxt} setup.py=${pm.hasSetupPy} setup.cfg=${pm.hasSetupCfg} tox.ini=${pm.hasToxIni} pytest.ini=${pm.hasPytestIni}`);
|
|
98
|
+
lines.push(` project name detected: ${pm.projectName ?? "(none)"}`);
|
|
99
|
+
lines.push(` pytest configuration detected: ${pm.hasPytestConfiguration}`);
|
|
100
|
+
lines.push("");
|
|
77
101
|
lines.push("Source-of-truth summary");
|
|
78
102
|
lines.push(divider("-"));
|
|
79
103
|
lines.push(` package: ${model.sourceOfTruth.packageName ?? "(none)"}@${model.sourceOfTruth.packageVersion ?? "?"}`);
|
|
@@ -83,6 +107,32 @@ export function renderAuditTextReport(model) {
|
|
|
83
107
|
lines.push(` warnings: ${model.sourceOfTruth.warnings.length}`);
|
|
84
108
|
}
|
|
85
109
|
lines.push("");
|
|
110
|
+
// v0.3.2 Batch 4 -- bounded summary only. Never dumps raw scanner
|
|
111
|
+
// output/full finding text here (that lives in the original security
|
|
112
|
+
// validation report at securitySummary.reportPaths) -- this section only
|
|
113
|
+
// links to it and shows counts, matching the same "reference, don't
|
|
114
|
+
// duplicate" convention docs/security section 5 of the task spec asks for.
|
|
115
|
+
lines.push("Security validation summary");
|
|
116
|
+
lines.push(divider("-"));
|
|
117
|
+
if (!model.securitySummary.ran) {
|
|
118
|
+
lines.push(" (not run -- add \"security\" to --types to include it, e.g. --types code-rot,security)");
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const sec = model.securitySummary;
|
|
122
|
+
lines.push(` Verdict: ${sec.verdictLabel ?? "(unknown)"}`);
|
|
123
|
+
lines.push(` Checks: total=${sec.totalChecks} passed=${sec.checksPassed} warning=${sec.checksWarning} failed=${sec.checksFailed} skipped=${sec.checksSkipped}`);
|
|
124
|
+
lines.push(` Findings: blocker=${sec.findingCounts.blocker} major=${sec.findingCounts.major} minor=${sec.findingCounts.minor} informational=${sec.findingCounts.informational} (mapped to ${sec.mappedIssueCount} audit issue(s))`);
|
|
125
|
+
if (sec.targetDescription) {
|
|
126
|
+
lines.push(` Target: ${sanitizeLine(sec.targetDescription)}`);
|
|
127
|
+
}
|
|
128
|
+
if (sec.recommendedNextStep) {
|
|
129
|
+
lines.push(` Next step: ${sanitizeLine(sec.recommendedNextStep)}`);
|
|
130
|
+
}
|
|
131
|
+
if (sec.reportPaths.text || sec.reportPaths.json) {
|
|
132
|
+
lines.push(` Full report: ${sec.reportPaths.text ?? "(none)"} / ${sec.reportPaths.json ?? "(none)"}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
lines.push("");
|
|
86
136
|
lines.push("Issue summary");
|
|
87
137
|
lines.push(divider("-"));
|
|
88
138
|
lines.push(` Total issues: ${model.summary.totalIssues}`);
|
|
@@ -153,12 +203,18 @@ function renderIssueBlock(issue) {
|
|
|
153
203
|
lines.push(` severity=${issue.severity} confidence=${issue.confidence} category=${sanitizeLine(issue.category)}`);
|
|
154
204
|
lines.push(` detector=${issue.detectorId} id=${issue.id}`);
|
|
155
205
|
for (const evidence of issue.evidence.slice(0, MAX_EVIDENCE_PER_ISSUE)) {
|
|
206
|
+
// v0.3.1 Batch 5 -- an evidence entry's `message` (the human-readable
|
|
207
|
+
// "why") and `excerpt` (a quoted snippet/reference list) are
|
|
208
|
+
// independent fields on the same entry -- render both when both are
|
|
209
|
+
// present. Previously an `excerpt` silently suppressed `message`
|
|
210
|
+
// entirely, which dropped the only readable explanation for
|
|
211
|
+
// source-facts-derived findings (e.g. duplicateImplementationDetector's
|
|
212
|
+
// "Source facts: an exported ... was parsed in N distinct files.") and
|
|
213
|
+
// left only a bare file-path excerpt in text output.
|
|
214
|
+
lines.push(` > ${sanitizeLine(evidence.message)}`);
|
|
156
215
|
if (evidence.excerpt) {
|
|
157
216
|
lines.push(...renderQuotedExcerpt(evidence.excerpt, " "));
|
|
158
217
|
}
|
|
159
|
-
else {
|
|
160
|
-
lines.push(` > ${sanitizeLine(evidence.message)}`);
|
|
161
|
-
}
|
|
162
218
|
}
|
|
163
219
|
lines.push(` recommendedAction: ${sanitizeLine(issue.recommendedAction)}`);
|
|
164
220
|
return lines;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { makeAuditEvidence } from "../core/auditIssue.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.3.2 Batch 4 — maps a structured SecurityFinding (already produced by the
|
|
4
|
+
// existing securityValidation framework — see src/securityValidation/types.ts)
|
|
5
|
+
// into an AuditIssue. Pure, no I/O, no re-running of any check: this module
|
|
6
|
+
// only translates one already-computed result shape into another. Skipped
|
|
7
|
+
// optional checks never reach this function at all (see
|
|
8
|
+
// securityAuditAdapter.ts) — only findings attached to executed checks are
|
|
9
|
+
// mapped, so "skipped" never silently becomes an issue or a passed result.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export const SECURITY_AUDIT_DETECTOR_ID = "security-validation-adapter";
|
|
12
|
+
// SecuritySeverity -> AuditSeverity. Conservative mapping: "major" (a
|
|
13
|
+
// mandatory-check-adjacent severity in the security-validation verdict
|
|
14
|
+
// policy, see verdict.ts's calculateVerdict) maps to "high", never "medium",
|
|
15
|
+
// so a security major finding is never understated relative to a code-rot
|
|
16
|
+
// "high". "skipped" is not a real finding severity in practice (skippedCheck()
|
|
17
|
+
// in runSecurityValidation.ts sets it on the SecurityCheckResult, not on a
|
|
18
|
+
// SecurityFinding) but is mapped defensively to "info" for type completeness.
|
|
19
|
+
const SEVERITY_MAP = {
|
|
20
|
+
blocker: "blocker",
|
|
21
|
+
major: "high",
|
|
22
|
+
minor: "medium",
|
|
23
|
+
informational: "info",
|
|
24
|
+
skipped: "info",
|
|
25
|
+
};
|
|
26
|
+
// Security findings originate from already-executed, structured scanners/
|
|
27
|
+
// checks (npm audit, CodeQL, Semgrep, package-content inspection) rather than
|
|
28
|
+
// from a per-finding confidence value — SecurityFinding carries no confidence
|
|
29
|
+
// field of its own. "medium" is a conservative default that neither
|
|
30
|
+
// overstates a scanner's certainty (npm audit CVE matches can be version-range
|
|
31
|
+
// false positives) nor discounts it as unreliable.
|
|
32
|
+
const DEFAULT_CONFIDENCE = "medium";
|
|
33
|
+
const DEFAULT_FALSE_POSITIVE_RISK = "medium";
|
|
34
|
+
export function mapSecurityFindingToAuditIssue(finding) {
|
|
35
|
+
const severity = SEVERITY_MAP[finding.severity];
|
|
36
|
+
// Mirrors verdict.ts's calculateVerdict: blocker and major findings are
|
|
37
|
+
// what drive a "not-ready-security-blocker-remains" verdict — minor/
|
|
38
|
+
// informational findings never block release or implementation on their own.
|
|
39
|
+
const isBlockingSeverity = finding.severity === "blocker" || finding.severity === "major";
|
|
40
|
+
const evidence = finding.evidence
|
|
41
|
+
? [
|
|
42
|
+
makeAuditEvidence({
|
|
43
|
+
kind: "observation",
|
|
44
|
+
message: `Security validation (${finding.category}): ${finding.title}`,
|
|
45
|
+
excerpt: finding.evidence,
|
|
46
|
+
source: `security-validation:${finding.category}`,
|
|
47
|
+
confidence: DEFAULT_CONFIDENCE,
|
|
48
|
+
}),
|
|
49
|
+
]
|
|
50
|
+
: [];
|
|
51
|
+
return {
|
|
52
|
+
id: `security:${finding.id}`,
|
|
53
|
+
auditType: "security",
|
|
54
|
+
detectorId: SECURITY_AUDIT_DETECTOR_ID,
|
|
55
|
+
title: finding.title,
|
|
56
|
+
description: finding.description,
|
|
57
|
+
severity,
|
|
58
|
+
confidence: DEFAULT_CONFIDENCE,
|
|
59
|
+
falsePositiveRisk: DEFAULT_FALSE_POSITIVE_RISK,
|
|
60
|
+
category: finding.category,
|
|
61
|
+
evidence,
|
|
62
|
+
affectedFiles: finding.affectedFiles ?? [],
|
|
63
|
+
recommendedAction: finding.recommendation ?? "Review the security validation report for remediation guidance.",
|
|
64
|
+
suggestedFixStrategy: finding.recommendation ?? "See the linked security validation report for details.",
|
|
65
|
+
validationCommands: ["npm run security:validate"],
|
|
66
|
+
releaseBlocking: isBlockingSeverity,
|
|
67
|
+
implementationBlocking: isBlockingSeverity,
|
|
68
|
+
autoFixEligible: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runSecurityValidation } from "../../securityValidation/validate/runSecurityValidation.js";
|
|
4
|
+
import { reportFilenamePrefix } from "../../securityValidation/validate/resolveTarget.js";
|
|
5
|
+
import { renderJsonReport, renderTextReport } from "../../securityValidation/report/renderSecurityReport.js";
|
|
6
|
+
import { buildSecurityReportFromSummary } from "../../securityValidation/report/buildSecurityReport.js";
|
|
7
|
+
import { DEFAULT_SECURITY_CHECKS, DEFAULT_SECURITY_PROFILE } from "../../securityValidation/validate/cliOptions.js";
|
|
8
|
+
import { verdictToHumanLabel } from "../../securityValidation/validate/verdict.js";
|
|
9
|
+
import { mapSecurityFindingToAuditIssue } from "./mapSecurityFindingToAuditIssue.js";
|
|
10
|
+
export async function runSecurityAuditAdapter(options) {
|
|
11
|
+
const { toolRoot, config } = options;
|
|
12
|
+
const summary = await runSecurityValidation({
|
|
13
|
+
cwd: toolRoot,
|
|
14
|
+
targetPath: config.targetPathArg,
|
|
15
|
+
});
|
|
16
|
+
// Only real findings on executed checks become AuditIssue entries. A
|
|
17
|
+
// skipped optional tool never has findings attached to it (see
|
|
18
|
+
// skippedCheck() in runSecurityValidation.ts), so it can never silently
|
|
19
|
+
// masquerade as an issue or as a passed check here.
|
|
20
|
+
const issues = summary.findings.map(mapSecurityFindingToAuditIssue);
|
|
21
|
+
const report = buildSecurityReportFromSummary(summary, {
|
|
22
|
+
profile: DEFAULT_SECURITY_PROFILE,
|
|
23
|
+
selectedChecks: [...DEFAULT_SECURITY_CHECKS],
|
|
24
|
+
formats: ["text", "json"],
|
|
25
|
+
});
|
|
26
|
+
// Always under my-dev-kit-lab's own reports/ tree (toolRoot), never under
|
|
27
|
+
// the audited target's root — matches the target-safety guarantee
|
|
28
|
+
// AuditTarget.safeReportOutputRoot already documents for code-rot reports.
|
|
29
|
+
const reportDir = path.join(toolRoot, "reports", "security");
|
|
30
|
+
if (!fs.existsSync(reportDir)) {
|
|
31
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
const prefix = reportFilenamePrefix({
|
|
34
|
+
isSelf: summary.isSelf,
|
|
35
|
+
packageName: summary.packageName,
|
|
36
|
+
packageVersion: summary.packageVersion,
|
|
37
|
+
targetRoot: summary.targetRoot,
|
|
38
|
+
});
|
|
39
|
+
const textPath = path.join(reportDir, `${prefix}-security-validation.txt`);
|
|
40
|
+
const jsonPath = path.join(reportDir, `${prefix}-security-validation.json`);
|
|
41
|
+
fs.writeFileSync(textPath, renderTextReport(report), "utf8");
|
|
42
|
+
fs.writeFileSync(jsonPath, renderJsonReport(report), "utf8");
|
|
43
|
+
const findingCounts = {
|
|
44
|
+
blocker: summary.findings.filter((f) => f.severity === "blocker").length,
|
|
45
|
+
major: summary.findings.filter((f) => f.severity === "major").length,
|
|
46
|
+
minor: summary.findings.filter((f) => f.severity === "minor").length,
|
|
47
|
+
informational: summary.findings.filter((f) => f.severity === "informational").length,
|
|
48
|
+
};
|
|
49
|
+
const securitySummary = {
|
|
50
|
+
ran: true,
|
|
51
|
+
verdict: summary.verdict,
|
|
52
|
+
verdictLabel: verdictToHumanLabel(summary.verdict),
|
|
53
|
+
recommendedNextStep: summary.recommendedNextStep,
|
|
54
|
+
targetDescription: summary.targetDescription,
|
|
55
|
+
isSelf: summary.isSelf,
|
|
56
|
+
totalChecks: summary.checks.length,
|
|
57
|
+
checksPassed: summary.checks.filter((c) => c.status === "passed").length,
|
|
58
|
+
checksWarning: summary.checks.filter((c) => c.status === "warning").length,
|
|
59
|
+
checksFailed: summary.checks.filter((c) => c.status === "failed").length,
|
|
60
|
+
checksSkipped: summary.checks.filter((c) => c.status === "skipped").length,
|
|
61
|
+
findingCounts,
|
|
62
|
+
mappedIssueCount: issues.length,
|
|
63
|
+
verdictReasonSummary: summary.verdictReasonSummary ?? null,
|
|
64
|
+
reportPaths: { text: textPath, json: jsonPath },
|
|
65
|
+
};
|
|
66
|
+
return { issues, summary: securitySummary };
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const SECURITY_AUDIT_NOT_RUN_SUMMARY = {
|
|
2
|
+
ran: false,
|
|
3
|
+
verdict: null,
|
|
4
|
+
verdictLabel: null,
|
|
5
|
+
recommendedNextStep: null,
|
|
6
|
+
targetDescription: null,
|
|
7
|
+
isSelf: null,
|
|
8
|
+
totalChecks: 0,
|
|
9
|
+
checksPassed: 0,
|
|
10
|
+
checksWarning: 0,
|
|
11
|
+
checksFailed: 0,
|
|
12
|
+
checksSkipped: 0,
|
|
13
|
+
findingCounts: { blocker: 0, major: 0, minor: 0, informational: 0 },
|
|
14
|
+
mappedIssueCount: 0,
|
|
15
|
+
verdictReasonSummary: null,
|
|
16
|
+
reportPaths: { text: null, json: null },
|
|
17
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function buildSecurityReportFromSummary(summary, options = {}) {
|
|
2
|
+
return {
|
|
3
|
+
metadata: {
|
|
4
|
+
toolRoot: summary.toolRoot,
|
|
5
|
+
toolPackageName: summary.toolPackageName,
|
|
6
|
+
toolPackageVersion: summary.toolPackageVersion,
|
|
7
|
+
targetRoot: summary.targetRoot,
|
|
8
|
+
targetDescription: summary.targetDescription,
|
|
9
|
+
packageName: summary.packageName,
|
|
10
|
+
packageVersion: summary.packageVersion,
|
|
11
|
+
branch: summary.auditedBranch,
|
|
12
|
+
commit: summary.auditedCommit,
|
|
13
|
+
isSelf: summary.isSelf,
|
|
14
|
+
generatedAt: summary.finishedAt,
|
|
15
|
+
totalDurationMs: new Date(summary.finishedAt).getTime() - new Date(summary.startedAt).getTime(),
|
|
16
|
+
profile: options.profile,
|
|
17
|
+
selectedChecks: options.selectedChecks,
|
|
18
|
+
failOnThreshold: options.failOnThreshold,
|
|
19
|
+
formats: options.formats,
|
|
20
|
+
failOnBreached: options.failOnBreached,
|
|
21
|
+
isFullReleaseGate: summary.isFullReleaseGate,
|
|
22
|
+
},
|
|
23
|
+
sections: [],
|
|
24
|
+
allChecks: summary.checks,
|
|
25
|
+
allFindings: summary.findings,
|
|
26
|
+
verdict: summary.verdict,
|
|
27
|
+
recommendedNextStep: summary.recommendedNextStep,
|
|
28
|
+
attackResults: summary.attackResults,
|
|
29
|
+
verdictReasonSummary: summary.verdictReasonSummary,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -8,6 +8,15 @@ export function resolveValidationTarget(targetPathArg, toolRoot) {
|
|
|
8
8
|
// Returns the prefix to use for report filenames based on target metadata.
|
|
9
9
|
// For self-validation: "v<version>" (preserves existing behavior).
|
|
10
10
|
// For external targets: "<sanitized-name>-v<version>" or "<sanitized-dirname>".
|
|
11
|
+
//
|
|
12
|
+
// v0.3.2 Batch 4 — the parameter type is deliberately narrowed to a Pick of
|
|
13
|
+
// SecurityValidationTarget (rather than the full type) so the audit security
|
|
14
|
+
// adapter (src/audits/security/securityAuditAdapter.ts) can call this with a
|
|
15
|
+
// plain object built from a SecurityValidationSummary (which carries these
|
|
16
|
+
// same four fields but isn't itself a SecurityValidationTarget) instead of
|
|
17
|
+
// re-resolving the target a second time. Every existing caller already passes
|
|
18
|
+
// a full SecurityValidationTarget, which satisfies this narrower type
|
|
19
|
+
// unchanged.
|
|
11
20
|
export function reportFilenamePrefix(target) {
|
|
12
21
|
if (target.isSelf && target.packageVersion) {
|
|
13
22
|
return `v${target.packageVersion}`;
|