@dailephd/my-dev-kit-lab 0.3.1 → 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 +22 -18
- package/dist/scripts/audits/runAudit.js +8 -2
- package/dist/scripts/security/validate.js +8 -29
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +95 -1
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +24 -9
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +115 -1
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +19 -1
- package/dist/src/audits/core/auditConfig.js +2 -2
- package/dist/src/audits/core/auditRunner.js +19 -0
- package/dist/src/audits/core/auditTypes.js +14 -7
- package/dist/src/audits/core/languageAnalyzerRegistry.js +6 -3
- package/dist/src/audits/core/projectInventory.js +35 -3
- package/dist/src/audits/core/pythonAnalyzer.js +328 -0
- package/dist/src/audits/core/pythonProjectMetadata.js +80 -0
- package/dist/src/audits/report/auditReportModel.js +3 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +2 -0
- package/dist/src/audits/report/renderAuditTextReport.js +40 -0
- 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 +92 -94
|
@@ -356,7 +356,7 @@ function classifyFile(relativePath, basename, extension) {
|
|
|
356
356
|
if (relativePath.startsWith(".github/workflows/") && (extension === ".yml" || extension === ".yaml")) {
|
|
357
357
|
return "ci";
|
|
358
358
|
}
|
|
359
|
-
if (isTestFile(relativePath, basename))
|
|
359
|
+
if (isTestFile(relativePath, basename, extension))
|
|
360
360
|
return "tests";
|
|
361
361
|
// Top-level scripts/ directory -- classified before generic "source" so
|
|
362
362
|
// scripts (which are also .ts/.js) land in their own category.
|
|
@@ -374,11 +374,37 @@ function classifyFile(relativePath, basename, extension) {
|
|
|
374
374
|
return "source";
|
|
375
375
|
return "unknown";
|
|
376
376
|
}
|
|
377
|
-
|
|
377
|
+
// v0.3.2 Batch 1 -- conservative pytest naming conventions. Gated to .py so
|
|
378
|
+
// this can never reclassify a non-Python file. "tests/**/*.py" and
|
|
379
|
+
// "**/tests/**/*.py" are already covered by the generic, extension-agnostic
|
|
380
|
+
// check above; only the singular "test/" directory and the test_*.py /
|
|
381
|
+
// *_test.py filename conventions are Python-specific additions here.
|
|
382
|
+
function isPythonTestFile(relativePath, basename) {
|
|
383
|
+
if (relativePath.startsWith("test/") || relativePath.includes("/test/"))
|
|
384
|
+
return true;
|
|
385
|
+
return /^test_.*\.py$/.test(basename) || /^.*_test\.py$/.test(basename);
|
|
386
|
+
}
|
|
387
|
+
function isTestFile(relativePath, basename, extension) {
|
|
378
388
|
if (relativePath.startsWith("tests/") || relativePath.includes("/tests/"))
|
|
379
389
|
return true;
|
|
380
|
-
|
|
390
|
+
if (/\.(test|spec)\.[jt]sx?$/.test(basename))
|
|
391
|
+
return true;
|
|
392
|
+
if (extension === ".py" && isPythonTestFile(relativePath, basename))
|
|
393
|
+
return true;
|
|
394
|
+
return false;
|
|
381
395
|
}
|
|
396
|
+
// v0.3.2 Batch 1 -- recognized Python project/config metadata filenames.
|
|
397
|
+
// Matched by basename only (same convention as PACKAGE_FILE_NAMES above),
|
|
398
|
+
// not restricted to root, since these are specific enough not to collide
|
|
399
|
+
// with unrelated fixture/benchmark content. Deliberately narrow: does not
|
|
400
|
+
// match arbitrary *.toml/*.ini/*.txt files, only these exact names.
|
|
401
|
+
const PYTHON_PROJECT_METADATA_FILE_NAMES = new Set([
|
|
402
|
+
"pyproject.toml",
|
|
403
|
+
"requirements.txt",
|
|
404
|
+
"setup.cfg",
|
|
405
|
+
"tox.ini",
|
|
406
|
+
"pytest.ini",
|
|
407
|
+
]);
|
|
382
408
|
function isConfigFile(relativePath, basename, extension) {
|
|
383
409
|
if (/^tsconfig(\..+)?\.json$/.test(basename))
|
|
384
410
|
return true;
|
|
@@ -388,6 +414,12 @@ function isConfigFile(relativePath, basename, extension) {
|
|
|
388
414
|
return true;
|
|
389
415
|
if (basename === ".editorconfig" || basename === ".gitignore" || basename === ".npmignore")
|
|
390
416
|
return true;
|
|
417
|
+
// v0.3.2 Batch 1 -- setup.py has a .py extension and would otherwise fall
|
|
418
|
+
// through to the generic KNOWN_SOURCE_EXTENSIONS "source" classification
|
|
419
|
+
// below; the other names have no source-extension conflict but are listed
|
|
420
|
+
// here for the same "project metadata, not source" reasoning.
|
|
421
|
+
if (basename === "setup.py" || PYTHON_PROJECT_METADATA_FILE_NAMES.has(basename))
|
|
422
|
+
return true;
|
|
391
423
|
// Root-level dotfile configs (e.g. .prettierrc, .babelrc) not already
|
|
392
424
|
// covered above -- deliberately conservative (root-only, not recursive)
|
|
393
425
|
// to avoid misclassifying dotfiles inside fixture/benchmark projects.
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.3.2 Batch 1 -- conservative, dependency-free Python source analyzer.
|
|
3
|
+
//
|
|
4
|
+
// There is no Python parser available in this Node/TypeScript project, and
|
|
5
|
+
// per this batch's own scope this analyzer must not add a new parser
|
|
6
|
+
// dependency. Facts are therefore extracted with a deliberately narrow,
|
|
7
|
+
// line-oriented regex scan -- not a real tokenizer, not an AST, and not a
|
|
8
|
+
// syntax validator. This is the single-file, best-effort analogue of
|
|
9
|
+
// typescriptJavaScriptAnalyzer.ts's AST walk: same LanguageAnalyzer contract,
|
|
10
|
+
// same SourceFileFacts shape, same size-bounding philosophy, but confidence
|
|
11
|
+
// is necessarily lower since nothing here proves the file is syntactically
|
|
12
|
+
// valid Python.
|
|
13
|
+
//
|
|
14
|
+
// What this DOES extract (see requirements 3 of the batch spec):
|
|
15
|
+
// - import module[, module2][ as alias]
|
|
16
|
+
// - from [.[.]]module import name[ as alias][, ...] (single physical line)
|
|
17
|
+
// - from module import *
|
|
18
|
+
// - module-level `def`/`async def`/`class` declarations
|
|
19
|
+
// - class-body method declarations, via a simple indentation-based scope
|
|
20
|
+
// stack (not scope-accurate for deeply nested blocks, just "is this def
|
|
21
|
+
// more indented than the nearest enclosing class line")
|
|
22
|
+
// - a single-line `__all__ = [...]`/`(...)` string list, used to set
|
|
23
|
+
// `exported` precisely when present
|
|
24
|
+
//
|
|
25
|
+
// What this explicitly does NOT do (see requirements 3's "do not implement"
|
|
26
|
+
// list): type checking, full AST/semantic resolution, virtualenv/dependency
|
|
27
|
+
// resolution, runtime or dynamic import resolution, decorator/metaclass/
|
|
28
|
+
// fixture semantics, or any claim of dead-code/duplicate-implementation
|
|
29
|
+
// proof. `__import__(...)`/`importlib.import_module(...)` calls are recorded
|
|
30
|
+
// only as an informational diagnostic, never as a resolved import fact.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
export const PYTHON_ANALYZER_ID = "python-analyzer";
|
|
33
|
+
const SUPPORTED_EXTENSIONS = [".py"];
|
|
34
|
+
// Mirrors typescriptJavaScriptAnalyzer.ts's MAX_ANALYZABLE_BYTES bound and
|
|
35
|
+
// reasoning -- a large file is not worth line-scanning, and stays at
|
|
36
|
+
// file-level-only rather than file-level-only masquerading as parsed.
|
|
37
|
+
const MAX_ANALYZABLE_BYTES = 1_000_000;
|
|
38
|
+
const IMPORT_LINE_PATTERN = /^\s*import\s+(.+)$/;
|
|
39
|
+
const FROM_IMPORT_LINE_PATTERN = /^\s*from\s+(\.{0,2}[\w.]*)\s+import\s+(.+)$/;
|
|
40
|
+
const DEF_LINE_PATTERN = /^(\s*)(?:async\s+def|def)\s+([A-Za-z_]\w*)\s*\(/;
|
|
41
|
+
const CLASS_LINE_PATTERN = /^(\s*)class\s+([A-Za-z_]\w*)\s*[:(]/;
|
|
42
|
+
const ALL_ASSIGNMENT_PATTERN = /^\s*__all__\s*=\s*[[(](.*)$/;
|
|
43
|
+
const DYNAMIC_IMPORT_PATTERN = /\b(?:__import__\s*\(|importlib\.import_module\s*\()/;
|
|
44
|
+
const QUOTED_NAME_PATTERN = /(['"])((?:(?!\1).)*)\1/g;
|
|
45
|
+
export const PYTHON_ANALYZER = {
|
|
46
|
+
id: PYTHON_ANALYZER_ID,
|
|
47
|
+
supportedLanguages: ["python"],
|
|
48
|
+
supportedExtensions: SUPPORTED_EXTENSIONS,
|
|
49
|
+
analyzeFile,
|
|
50
|
+
};
|
|
51
|
+
function analyzeFile(input) {
|
|
52
|
+
const { relativePath, language, role, content, inventoryEntry } = input;
|
|
53
|
+
if (Buffer.byteLength(content, "utf8") > MAX_ANALYZABLE_BYTES) {
|
|
54
|
+
return {
|
|
55
|
+
relativePath,
|
|
56
|
+
language,
|
|
57
|
+
role,
|
|
58
|
+
parseStatus: "file-level-only",
|
|
59
|
+
analyzerId: PYTHON_ANALYZER_ID,
|
|
60
|
+
lineCount: inventoryEntry.lineCount,
|
|
61
|
+
imports: [],
|
|
62
|
+
exports: [],
|
|
63
|
+
declarations: [],
|
|
64
|
+
references: [],
|
|
65
|
+
diagnostics: [
|
|
66
|
+
{
|
|
67
|
+
severity: "info",
|
|
68
|
+
code: "file-too-large-for-analysis",
|
|
69
|
+
message: `File exceeds the ${MAX_ANALYZABLE_BYTES}-byte analyzer bound; only inventory-level facts are available.`,
|
|
70
|
+
path: relativePath,
|
|
71
|
+
analyzerId: PYTHON_ANALYZER_ID,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const diagnostics = [];
|
|
77
|
+
const { logical, hadUnterminatedTripleQuote } = buildLogicalLines(content);
|
|
78
|
+
const imports = [];
|
|
79
|
+
for (let i = 0; i < logical.length; i++) {
|
|
80
|
+
const line = logical[i];
|
|
81
|
+
const lineNumber = i + 1;
|
|
82
|
+
if (parseFromImportLine(line, lineNumber, imports, diagnostics, relativePath))
|
|
83
|
+
continue;
|
|
84
|
+
parseImportLine(line, lineNumber, imports);
|
|
85
|
+
}
|
|
86
|
+
const exportedSet = scanAllExport(logical);
|
|
87
|
+
const declarations = scanDeclarations(logical, exportedSet);
|
|
88
|
+
scanDynamicImportPatterns(logical, diagnostics, relativePath);
|
|
89
|
+
const exports = exportedSet ? [...exportedSet].map((name) => ({ name, kind: "named" })) : [];
|
|
90
|
+
if (hadUnterminatedTripleQuote) {
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
severity: "error",
|
|
93
|
+
code: "unterminated-triple-quoted-string",
|
|
94
|
+
message: "File appears to contain an unterminated triple-quoted string (unbalanced \"\"\"/''' delimiters); facts from this best-effort scan may be incomplete.",
|
|
95
|
+
path: relativePath,
|
|
96
|
+
analyzerId: PYTHON_ANALYZER_ID,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const parseStatus = hadUnterminatedTripleQuote ? "parse-error" : "parsed";
|
|
100
|
+
return {
|
|
101
|
+
relativePath,
|
|
102
|
+
language,
|
|
103
|
+
role,
|
|
104
|
+
parseStatus,
|
|
105
|
+
analyzerId: PYTHON_ANALYZER_ID,
|
|
106
|
+
lineCount: inventoryEntry.lineCount,
|
|
107
|
+
imports,
|
|
108
|
+
exports,
|
|
109
|
+
declarations,
|
|
110
|
+
references: [],
|
|
111
|
+
diagnostics,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Comment/triple-quoted-string stripping (heuristic, not a tokenizer).
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Strips a trailing `# comment`, respecting simple single/double-quoted
|
|
118
|
+
// strings on the same line (so `x = "a # b"` is not truncated at the `#`
|
|
119
|
+
// inside the string). Does not understand triple-quoted strings -- those are
|
|
120
|
+
// handled one level up by buildLogicalLines(), which never passes a
|
|
121
|
+
// mid-triple-quote-block line to this function.
|
|
122
|
+
function stripLineComment(line) {
|
|
123
|
+
let inSingle = false;
|
|
124
|
+
let inDouble = false;
|
|
125
|
+
for (let i = 0; i < line.length; i++) {
|
|
126
|
+
const ch = line[i];
|
|
127
|
+
if (ch === "\\" && (inSingle || inDouble)) {
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === "'" && !inDouble)
|
|
132
|
+
inSingle = !inSingle;
|
|
133
|
+
else if (ch === '"' && !inSingle)
|
|
134
|
+
inDouble = !inDouble;
|
|
135
|
+
else if (ch === "#" && !inSingle && !inDouble)
|
|
136
|
+
return line.slice(0, i);
|
|
137
|
+
}
|
|
138
|
+
return line;
|
|
139
|
+
}
|
|
140
|
+
// Produces one "logical" line per physical line: comments stripped, and any
|
|
141
|
+
// content inside a triple-quoted string block (`"""..."""` / `'''...'''`)
|
|
142
|
+
// blanked out so it can never be misread as an import/def/class statement.
|
|
143
|
+
// Deliberately narrow: only tracks a single open triple-quote marker at a
|
|
144
|
+
// time and does not handle more than one triple-quoted segment opening and
|
|
145
|
+
// closing on the same physical line.
|
|
146
|
+
function buildLogicalLines(content) {
|
|
147
|
+
const rawLines = content.split(/\r\n|\r|\n/);
|
|
148
|
+
const logical = [];
|
|
149
|
+
let openQuote = null;
|
|
150
|
+
for (const rawLine of rawLines) {
|
|
151
|
+
if (openQuote) {
|
|
152
|
+
const closeIdx = rawLine.indexOf(openQuote);
|
|
153
|
+
if (closeIdx === -1) {
|
|
154
|
+
logical.push("");
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
openQuote = null;
|
|
158
|
+
logical.push(stripLineComment(rawLine.slice(closeIdx + 3)));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const stripped = stripLineComment(rawLine);
|
|
162
|
+
const tripleMatch = stripped.match(/"""|'''/);
|
|
163
|
+
if (!tripleMatch) {
|
|
164
|
+
logical.push(stripped);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const marker = tripleMatch[0];
|
|
168
|
+
const firstIdx = stripped.indexOf(marker);
|
|
169
|
+
const secondIdx = stripped.indexOf(marker, firstIdx + marker.length);
|
|
170
|
+
if (secondIdx === -1) {
|
|
171
|
+
logical.push(stripped.slice(0, firstIdx));
|
|
172
|
+
openQuote = marker;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
logical.push(stripped.slice(0, firstIdx) + stripped.slice(secondIdx + marker.length));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { logical, hadUnterminatedTripleQuote: openQuote !== null };
|
|
179
|
+
}
|
|
180
|
+
function leadingWhitespaceCount(line) {
|
|
181
|
+
let count = 0;
|
|
182
|
+
for (const ch of line) {
|
|
183
|
+
if (ch === " " || ch === "\t")
|
|
184
|
+
count += 1;
|
|
185
|
+
else
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
return count;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Imports.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
function parseImportLine(line, lineNumber, imports) {
|
|
194
|
+
const match = line.match(IMPORT_LINE_PATTERN);
|
|
195
|
+
if (!match)
|
|
196
|
+
return;
|
|
197
|
+
for (const rawSegment of match[1].split(",")) {
|
|
198
|
+
const segment = rawSegment.trim();
|
|
199
|
+
if (!segment)
|
|
200
|
+
continue;
|
|
201
|
+
const asMatch = segment.match(/^([\w.]+)\s+as\s+(\w+)$/);
|
|
202
|
+
if (asMatch) {
|
|
203
|
+
imports.push({ source: asMatch[1], kind: "static", importedNames: [asMatch[2]], line: lineNumber });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
imports.push({ source: segment, kind: "static", line: lineNumber });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function parseFromImportLine(line, lineNumber, imports, diagnostics, relativePath) {
|
|
211
|
+
const match = line.match(FROM_IMPORT_LINE_PATTERN);
|
|
212
|
+
if (!match)
|
|
213
|
+
return false;
|
|
214
|
+
const modulePath = match[1] || ".";
|
|
215
|
+
let namesPart = match[2].trim();
|
|
216
|
+
if (namesPart.startsWith("(") && namesPart.endsWith(")")) {
|
|
217
|
+
namesPart = namesPart.slice(1, -1);
|
|
218
|
+
}
|
|
219
|
+
else if (namesPart.startsWith("(")) {
|
|
220
|
+
// Multi-line parenthesized from-import list -- this single-line scanner
|
|
221
|
+
// does not follow continuation lines. Recorded as a diagnostic rather
|
|
222
|
+
// than silently dropped or guessed at.
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
severity: "info",
|
|
225
|
+
code: "multiline-from-import-unsupported",
|
|
226
|
+
message: "Multi-line parenthesized from-import list is not analyzed by this conservative single-line scanner.",
|
|
227
|
+
path: relativePath,
|
|
228
|
+
analyzerId: PYTHON_ANALYZER_ID,
|
|
229
|
+
});
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
if (namesPart === "*") {
|
|
233
|
+
imports.push({ source: modulePath, kind: "static", line: lineNumber });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
const importedNames = [];
|
|
237
|
+
for (const rawSegment of namesPart.split(",")) {
|
|
238
|
+
const segment = rawSegment.trim();
|
|
239
|
+
if (!segment)
|
|
240
|
+
continue;
|
|
241
|
+
const asMatch = segment.match(/^(\w+)\s+as\s+(\w+)$/);
|
|
242
|
+
importedNames.push(asMatch ? asMatch[2] : segment);
|
|
243
|
+
}
|
|
244
|
+
imports.push({
|
|
245
|
+
source: modulePath,
|
|
246
|
+
kind: "static",
|
|
247
|
+
line: lineNumber,
|
|
248
|
+
...(importedNames.length > 0 ? { importedNames } : {}),
|
|
249
|
+
});
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// __all__ (optional, single-line only).
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function scanAllExport(logical) {
|
|
256
|
+
for (const line of logical) {
|
|
257
|
+
const match = line.match(ALL_ASSIGNMENT_PATTERN);
|
|
258
|
+
if (!match)
|
|
259
|
+
continue;
|
|
260
|
+
const names = [...match[1].matchAll(QUOTED_NAME_PATTERN)].map((m) => m[2]);
|
|
261
|
+
if (names.length > 0)
|
|
262
|
+
return new Set(names);
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function isExported(name, exportedSet) {
|
|
267
|
+
if (exportedSet)
|
|
268
|
+
return exportedSet.has(name);
|
|
269
|
+
// Conservative convention-based heuristic when no __all__ is present: a
|
|
270
|
+
// leading underscore is Python's own "not public" signal. This is a
|
|
271
|
+
// heuristic, not a semantic guarantee -- see module header comment.
|
|
272
|
+
return !name.startsWith("_");
|
|
273
|
+
}
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Declarations (functions, async functions, classes, class methods).
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
function scanDeclarations(logical, exportedSet) {
|
|
278
|
+
const declarations = [];
|
|
279
|
+
// Indent levels of currently-open `class` blocks, outermost first. A line
|
|
280
|
+
// whose indentation drops to or below the top of this stack has exited
|
|
281
|
+
// that class body.
|
|
282
|
+
const classIndentStack = [];
|
|
283
|
+
for (let i = 0; i < logical.length; i++) {
|
|
284
|
+
const line = logical[i];
|
|
285
|
+
if (line.trim().length === 0)
|
|
286
|
+
continue;
|
|
287
|
+
const lineNumber = i + 1;
|
|
288
|
+
const indent = leadingWhitespaceCount(line);
|
|
289
|
+
while (classIndentStack.length > 0 && indent <= classIndentStack[classIndentStack.length - 1]) {
|
|
290
|
+
classIndentStack.pop();
|
|
291
|
+
}
|
|
292
|
+
const classMatch = line.match(CLASS_LINE_PATTERN);
|
|
293
|
+
if (classMatch) {
|
|
294
|
+
const name = classMatch[2];
|
|
295
|
+
declarations.push({ name, kind: "class", exported: isExported(name, exportedSet), line: lineNumber });
|
|
296
|
+
classIndentStack.push(indent);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const defMatch = line.match(DEF_LINE_PATTERN);
|
|
300
|
+
if (defMatch) {
|
|
301
|
+
const name = defMatch[2];
|
|
302
|
+
const isMethod = classIndentStack.length > 0 && indent > classIndentStack[classIndentStack.length - 1];
|
|
303
|
+
declarations.push({
|
|
304
|
+
name,
|
|
305
|
+
kind: isMethod ? "method" : "function",
|
|
306
|
+
// Methods are not tracked against __all__/underscore-export
|
|
307
|
+
// semantics -- __all__ only ever names module-level symbols in
|
|
308
|
+
// real Python, so claiming a method is "exported" would overclaim.
|
|
309
|
+
exported: isMethod ? false : isExported(name, exportedSet),
|
|
310
|
+
line: lineNumber,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return declarations;
|
|
315
|
+
}
|
|
316
|
+
function scanDynamicImportPatterns(logical, diagnostics, relativePath) {
|
|
317
|
+
for (const line of logical) {
|
|
318
|
+
if (!DYNAMIC_IMPORT_PATTERN.test(line))
|
|
319
|
+
continue;
|
|
320
|
+
diagnostics.push({
|
|
321
|
+
severity: "info",
|
|
322
|
+
code: "dynamic-import-pattern-unsupported",
|
|
323
|
+
message: "Detected a __import__/importlib.import_module call; dynamic imports are not resolved as import facts.",
|
|
324
|
+
path: relativePath,
|
|
325
|
+
analyzerId: PYTHON_ANALYZER_ID,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const PYPROJECT_PROJECT_NAME_PATTERN = /^\s*name\s*=\s*["']([^"']+)["']\s*$/m;
|
|
4
|
+
const PYPROJECT_PYTEST_SECTION_PATTERN = /^\s*\[tool\.pytest\.ini_options\]\s*$/m;
|
|
5
|
+
const SETUP_CFG_PYTEST_SECTION_PATTERN = /^\s*\[tool:pytest\]\s*$/m;
|
|
6
|
+
const INI_SECTION_HEADER_PATTERN = /^\s*\[([^\]]+)\]\s*$/;
|
|
7
|
+
const INI_NAME_KEY_PATTERN = /^\s*name\s*=\s*(.+?)\s*$/;
|
|
8
|
+
// Extracts the `name = ...` value from a setup.cfg-style [metadata] section
|
|
9
|
+
// via a simple line scan (no INI parser dependency) -- stops at the next
|
|
10
|
+
// "[section]" header or end of file.
|
|
11
|
+
function extractSetupCfgProjectName(content) {
|
|
12
|
+
const lines = content.split(/\r\n|\r|\n/);
|
|
13
|
+
let inMetadataSection = false;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const sectionMatch = line.match(INI_SECTION_HEADER_PATTERN);
|
|
16
|
+
if (sectionMatch) {
|
|
17
|
+
inMetadataSection = sectionMatch[1].trim().toLowerCase() === "metadata";
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!inMetadataSection)
|
|
21
|
+
continue;
|
|
22
|
+
const nameMatch = line.match(INI_NAME_KEY_PATTERN);
|
|
23
|
+
if (nameMatch)
|
|
24
|
+
return nameMatch[1];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function collectPythonProjectMetadata(targetRoot, inventory) {
|
|
29
|
+
const warnings = [];
|
|
30
|
+
const knownPaths = new Set(inventory.files.map((f) => f.relativePath));
|
|
31
|
+
const has = (name) => knownPaths.has(name);
|
|
32
|
+
const hasPyprojectToml = has("pyproject.toml");
|
|
33
|
+
const hasRequirementsTxt = has("requirements.txt");
|
|
34
|
+
const hasSetupPy = has("setup.py");
|
|
35
|
+
const hasSetupCfg = has("setup.cfg");
|
|
36
|
+
const hasToxIni = has("tox.ini");
|
|
37
|
+
const hasPytestIni = has("pytest.ini");
|
|
38
|
+
let projectName = null;
|
|
39
|
+
let pytestSectionDetected = false;
|
|
40
|
+
if (hasPyprojectToml) {
|
|
41
|
+
const content = readSafely(targetRoot, "pyproject.toml", warnings);
|
|
42
|
+
if (content !== null) {
|
|
43
|
+
const nameMatch = content.match(PYPROJECT_PROJECT_NAME_PATTERN);
|
|
44
|
+
if (nameMatch)
|
|
45
|
+
projectName = nameMatch[1];
|
|
46
|
+
if (PYPROJECT_PYTEST_SECTION_PATTERN.test(content))
|
|
47
|
+
pytestSectionDetected = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (hasSetupCfg) {
|
|
51
|
+
const content = readSafely(targetRoot, "setup.cfg", warnings);
|
|
52
|
+
if (content !== null) {
|
|
53
|
+
if (projectName === null) {
|
|
54
|
+
projectName = extractSetupCfgProjectName(content);
|
|
55
|
+
}
|
|
56
|
+
if (SETUP_CFG_PYTEST_SECTION_PATTERN.test(content))
|
|
57
|
+
pytestSectionDetected = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
hasPyprojectToml,
|
|
62
|
+
hasRequirementsTxt,
|
|
63
|
+
hasSetupPy,
|
|
64
|
+
hasSetupCfg,
|
|
65
|
+
hasToxIni,
|
|
66
|
+
hasPytestIni,
|
|
67
|
+
hasPytestConfiguration: hasPytestIni || pytestSectionDetected,
|
|
68
|
+
projectName,
|
|
69
|
+
warnings,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function readSafely(targetRoot, relativePath, warnings) {
|
|
73
|
+
try {
|
|
74
|
+
return fs.readFileSync(path.join(targetRoot, relativePath), "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
warnings.push(`Could not read ${relativePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -55,6 +55,7 @@ export function summarizeSourceFacts(sourceFacts) {
|
|
|
55
55
|
filesByLanguage: sourceFacts.filesByLanguage,
|
|
56
56
|
filesByParseStatus: sourceFacts.filesByParseStatus,
|
|
57
57
|
analyzerDiagnosticCount: sourceFacts.analyzerDiagnostics.length,
|
|
58
|
+
filesWithDiagnosticsCount: sourceFacts.files.filter((f) => f.diagnostics.length > 0).length,
|
|
58
59
|
warnings: sourceFacts.warnings,
|
|
59
60
|
};
|
|
60
61
|
}
|
|
@@ -188,6 +189,8 @@ export function buildAuditReportModel(result, opts) {
|
|
|
188
189
|
inventory: summarizeInventory(result.inventory),
|
|
189
190
|
sourceOfTruth: summarizeSourceOfTruth(result.sourceOfTruth),
|
|
190
191
|
sourceFacts: summarizeSourceFacts(result.sourceFacts),
|
|
192
|
+
pythonProjectMetadata: result.pythonProjectMetadata,
|
|
193
|
+
securitySummary: result.securitySummary,
|
|
191
194
|
detectors,
|
|
192
195
|
issues,
|
|
193
196
|
skippedDetectors: result.skippedDetectors,
|
|
@@ -19,6 +19,8 @@ export function renderAuditJsonReport(model) {
|
|
|
19
19
|
inventory: model.inventory,
|
|
20
20
|
sourceOfTruth: model.sourceOfTruth,
|
|
21
21
|
sourceFacts: model.sourceFacts,
|
|
22
|
+
pythonProjectMetadata: model.pythonProjectMetadata,
|
|
23
|
+
securitySummary: model.securitySummary,
|
|
22
24
|
detectors: model.detectors,
|
|
23
25
|
issues: model.issues,
|
|
24
26
|
skippedDetectors: model.skippedDetectors,
|
|
@@ -83,6 +83,20 @@ export function renderAuditTextReport(model) {
|
|
|
83
83
|
if (model.sourceFacts.analyzerDiagnosticCount > 0) {
|
|
84
84
|
lines.push(` analyzer diagnostics: ${model.sourceFacts.analyzerDiagnosticCount}`);
|
|
85
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}`);
|
|
86
100
|
lines.push("");
|
|
87
101
|
lines.push("Source-of-truth summary");
|
|
88
102
|
lines.push(divider("-"));
|
|
@@ -93,6 +107,32 @@ export function renderAuditTextReport(model) {
|
|
|
93
107
|
lines.push(` warnings: ${model.sourceOfTruth.warnings.length}`);
|
|
94
108
|
}
|
|
95
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("");
|
|
96
136
|
lines.push("Issue summary");
|
|
97
137
|
lines.push(divider("-"));
|
|
98
138
|
lines.push(` Total issues: ${model.summary.totalIssues}`);
|
|
@@ -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
|
+
}
|