codebase-onboarder 0.1.0
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/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/onboarder.js +14 -0
- package/bin/postinstall.js +6 -0
- package/cli/commands.js +436 -0
- package/cli/main.js +129 -0
- package/cli/prompt.js +101 -0
- package/cli/ui.js +55 -0
- package/cli/wizard.js +325 -0
- package/package.json +48 -0
- package/public/app.js +1339 -0
- package/public/index.html +455 -0
- package/public/js/about.js +284 -0
- package/public/js/aiDraft.js +163 -0
- package/public/js/analysisPanel.js +270 -0
- package/public/js/analysisReport.js +154 -0
- package/public/js/api.js +264 -0
- package/public/js/atlas.js +98 -0
- package/public/js/blameView.js +41 -0
- package/public/js/codeTab.js +256 -0
- package/public/js/codeViewer.js +184 -0
- package/public/js/components/FileChip.js +102 -0
- package/public/js/components/MetricSparkline.js +89 -0
- package/public/js/components/RiskBadge.js +105 -0
- package/public/js/deepAnalysisView.js +496 -0
- package/public/js/diagramPane.js +154 -0
- package/public/js/diffView.js +352 -0
- package/public/js/docsView.js +566 -0
- package/public/js/fileSourceBrowser.js +47 -0
- package/public/js/flameGraph.js +75 -0
- package/public/js/forceGraph.js +592 -0
- package/public/js/heatmap.js +185 -0
- package/public/js/highlight.js +122 -0
- package/public/js/html.js +50 -0
- package/public/js/insightsView.js +236 -0
- package/public/js/inspector.js +535 -0
- package/public/js/llm.js +345 -0
- package/public/js/mapView.js +159 -0
- package/public/js/markdown.js +119 -0
- package/public/js/mindmap.js +289 -0
- package/public/js/repoFiles.js +44 -0
- package/public/js/sbomView.js +142 -0
- package/public/js/scanCache.js +76 -0
- package/public/js/search.js +874 -0
- package/public/js/serverSettings.js +169 -0
- package/public/js/state.js +192 -0
- package/public/js/tour.js +17 -0
- package/public/js/transitions.js +15 -0
- package/public/js/tree.js +200 -0
- package/public/js/workflowsView.js +95 -0
- package/public/styles.css +2767 -0
- package/public/vendor/mermaid.min.js +3587 -0
- package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/public/vendor/monaco/vs/loader.js +11 -0
- package/public/vendor/monaco/worker-boot.js +9 -0
- package/server/.fuse_hidden0000000800000001 +36 -0
- package/server/apiDiff.js +25 -0
- package/server/apiDocs.js +67 -0
- package/server/apiFile.js +36 -0
- package/server/apiGitBlame.js +63 -0
- package/server/apiMcp.js +110 -0
- package/server/apiScan.js +119 -0
- package/server/apiSearch.js +253 -0
- package/server/apiSettings.js +118 -0
- package/server/apiTools.js +90 -0
- package/server/config.js +227 -0
- package/server/fileSourceNode.js +44 -0
- package/server/gitClone.js +95 -0
- package/server/gitDiff.js +184 -0
- package/server/gitHistory.js +110 -0
- package/server/htmlText.js +44 -0
- package/server/http.js +55 -0
- package/server/httpGuards.js +87 -0
- package/server/index.js +156 -0
- package/server/llmProxy.js +162 -0
- package/server/logger.js +29 -0
- package/server/mcp/analysis.js +209 -0
- package/server/mcp/http.js +213 -0
- package/server/mcp/runner.js +278 -0
- package/server/mcp/server.js +241 -0
- package/server/mcp/standalone.js +42 -0
- package/server/mcp/tools.js +683 -0
- package/server/paths.js +39 -0
- package/server/router.js +218 -0
- package/server/searchIndex.js +118 -0
- package/server/sessions.js +163 -0
- package/server/static.js +59 -0
- package/server/tools/install.js +246 -0
- package/server/tools/parse.js +170 -0
- package/server/tools/platform.js +91 -0
- package/server/tools/registry.js +212 -0
- package/server/tools/scan.js +136 -0
- package/server/tools.js +212 -0
- package/server/tunnel.js +92 -0
- package/shared/analyzer/docs.js +135 -0
- package/shared/analyzer/explainLocal.js +163 -0
- package/shared/analyzer/graph.js +461 -0
- package/shared/analyzer/health.js +215 -0
- package/shared/analyzer/history.js +146 -0
- package/shared/analyzer/languages/csharp.js +39 -0
- package/shared/analyzer/languages/generic.js +131 -0
- package/shared/analyzer/languages/go.js +70 -0
- package/shared/analyzer/languages/index.js +43 -0
- package/shared/analyzer/languages/java.js +39 -0
- package/shared/analyzer/languages/javascript.js +240 -0
- package/shared/analyzer/languages/python.js +120 -0
- package/shared/analyzer/languages/rust.js +42 -0
- package/shared/analyzer/languages/typescript.js +138 -0
- package/shared/analyzer/licenses.js +151 -0
- package/shared/analyzer/metrics.js +120 -0
- package/shared/analyzer/pathUtil.js +69 -0
- package/shared/analyzer/patterns.js +272 -0
- package/shared/analyzer/scan.js +473 -0
- package/shared/analyzer/security.js +187 -0
- package/shared/analyzer/services.js +157 -0
- package/shared/analyzer/stack.js +173 -0
- package/shared/analyzer/tour.js +69 -0
- package/shared/analyzer/util.js +123 -0
- package/shared/analyzer/workflows.js +143 -0
- package/shared/diagram/aiFacts.js +145 -0
- package/shared/diagram/aiMermaid.js +59 -0
- package/shared/diagram/atlas.js +93 -0
- package/shared/diagram/mermaid.js +459 -0
- package/shared/search/query.js +518 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// The scanner: walks a FileSource, dispatches per-language analyzers,
|
|
2
|
+
// resolves imports into edges, and hands back one big plain-data result.
|
|
3
|
+
//
|
|
4
|
+
// FileSource interface (both adapters implement it):
|
|
5
|
+
// list(dir) -> [{ name, path, type: 'file'|'dir' }]
|
|
6
|
+
// read(path) -> string
|
|
7
|
+
// Paths are repo-relative, POSIX style ('' is the root).
|
|
8
|
+
|
|
9
|
+
import { languageFor } from './languages/index.js';
|
|
10
|
+
import { dirOf, baseName, extOf } from './pathUtil.js';
|
|
11
|
+
import { codeStats, complexityOf } from './metrics.js';
|
|
12
|
+
import { analyzeSecurityFile } from './security.js';
|
|
13
|
+
import { analyzeLicenses } from './licenses.js';
|
|
14
|
+
import { analyzeWorkflows } from './workflows.js';
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set([
|
|
17
|
+
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next',
|
|
18
|
+
'.nuxt', 'target', 'vendor', '__pycache__', '.venv', 'venv', 'env',
|
|
19
|
+
'.idea', '.vscode', 'coverage', '.cache', '.turbo', 'tmp', 'temp',
|
|
20
|
+
'DerivedData', 'Pods', '.gradle', 'bin', 'obj',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const SKIP_FILES = new Set([
|
|
24
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'composer.lock',
|
|
25
|
+
'Gemfile.lock', 'Cargo.lock', 'poetry.lock', '.DS_Store',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const SKIP_EXTS = new Set([
|
|
29
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.icns', '.svg',
|
|
30
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp4', '.mov', '.mp3',
|
|
31
|
+
'.wav', '.zip', '.gz', '.tar', '.rar', '.7z', '.pdf', '.jar', '.war',
|
|
32
|
+
'.class', '.o', '.a', '.so', '.dylib', '.dll', '.exe', '.pyc', '.wasm',
|
|
33
|
+
'.min.js', '.min.css', '.map', '.lock', '.bin', '.db', '.sqlite',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_LIMITS = { maxFiles: 4000, maxFileSize: 200 * 1024 };
|
|
37
|
+
|
|
38
|
+
function shouldSkipFile(name) {
|
|
39
|
+
if (SKIP_FILES.has(name)) return true;
|
|
40
|
+
if (SKIP_EXTS.has(extOf(name))) return true;
|
|
41
|
+
// extOf() only sees the final suffix, so name the common bundle shapes here.
|
|
42
|
+
return /\.min\.(js|css)$/i.test(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function scanRepo(source, options = {}) {
|
|
46
|
+
const limits = { ...DEFAULT_LIMITS, ...options };
|
|
47
|
+
const onProgress = options.onProgress || (() => {});
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
|
|
50
|
+
const ignore = await buildIgnoreMatcher(source);
|
|
51
|
+
|
|
52
|
+
// Every way a file or directory can drop out of the scan, counted apart.
|
|
53
|
+
// One integer for all of them hid the difference between "we ignored a
|
|
54
|
+
// lockfile" and "we could not read your source" — and only one of those is
|
|
55
|
+
// something the reader needs to know about.
|
|
56
|
+
const skips = {
|
|
57
|
+
ignored: 0, // matched a .gitignore rule
|
|
58
|
+
vendorDir: 0, // node_modules, dist, .git, any dotfolder
|
|
59
|
+
notSource: 0, // images, lockfiles, minified bundles
|
|
60
|
+
listFailed: 0, // the directory would not list
|
|
61
|
+
notCode: 0, // no analyzer for this extension (README.md, .yml, …)
|
|
62
|
+
readFailed: 0, // the file would not read
|
|
63
|
+
tooLarge: 0, // over maxFileSize
|
|
64
|
+
analyzeFailed: 0, // the language analyzer threw
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ---- Walk --------------------------------------------------------------
|
|
68
|
+
const allFiles = [];
|
|
69
|
+
let truncated = null;
|
|
70
|
+
const queue = [''];
|
|
71
|
+
walk: while (queue.length) {
|
|
72
|
+
const dir = queue.shift();
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = await source.list(dir);
|
|
76
|
+
} catch {
|
|
77
|
+
skips.listFailed++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (allFiles.length >= limits.maxFiles) {
|
|
82
|
+
// Stop the entire walk, not just this directory. A plain `break` here
|
|
83
|
+
// left every already-queued directory to be listed anyway, so the cap
|
|
84
|
+
// was both slower and less predictable than it looked.
|
|
85
|
+
truncated = { atFiles: allFiles.length, dirsQueued: queue.length };
|
|
86
|
+
break walk;
|
|
87
|
+
}
|
|
88
|
+
if (ignore(entry.path)) {
|
|
89
|
+
skips.ignored++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (entry.type === 'dir') {
|
|
93
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
|
|
94
|
+
skips.vendorDir++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
queue.push(entry.path);
|
|
98
|
+
} else {
|
|
99
|
+
if (shouldSkipFile(entry.name)) {
|
|
100
|
+
skips.notSource++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
allFiles.push(entry.path);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
onProgress({ phase: 'walk', found: allFiles.length });
|
|
107
|
+
}
|
|
108
|
+
allFiles.sort();
|
|
109
|
+
|
|
110
|
+
const fileSet = new Set(allFiles);
|
|
111
|
+
const dirSet = new Set(['']);
|
|
112
|
+
for (const p of allFiles) {
|
|
113
|
+
let d = dirOf(p);
|
|
114
|
+
while (true) {
|
|
115
|
+
if (dirSet.has(d)) break;
|
|
116
|
+
dirSet.add(d);
|
|
117
|
+
d = dirOf(d);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const has = (p) => fileSet.has(p);
|
|
121
|
+
const hasDir = (d) => dirSet.has(d);
|
|
122
|
+
const nameIndex = new Map();
|
|
123
|
+
for (const p of allFiles) {
|
|
124
|
+
const n = baseName(p);
|
|
125
|
+
if (!nameIndex.has(n)) nameIndex.set(n, p);
|
|
126
|
+
}
|
|
127
|
+
const findByName = (n) => nameIndex.get(n) || null;
|
|
128
|
+
|
|
129
|
+
const context = {
|
|
130
|
+
hasDir,
|
|
131
|
+
findByName,
|
|
132
|
+
modulePath: await readModulePath(source),
|
|
133
|
+
tsPaths: await readTsConfigPaths(source),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ---- Parse ---------------------------------------------------------------
|
|
137
|
+
// Reads run a window ahead of the analysis; the analysis stays strictly in
|
|
138
|
+
// path order. Waiting for one `read` to land before starting the next was the
|
|
139
|
+
// largest wall-clock cost of a scan and the one with the least to show for
|
|
140
|
+
// it — every adapter behind `FileSource` is I/O, and neither `node:fs` nor the
|
|
141
|
+
// File System Access API cares how many requests are outstanding.
|
|
142
|
+
//
|
|
143
|
+
// Order matters more than it looks: `files`, `edges` and every skip count
|
|
144
|
+
// have to come out identical no matter how the reads interleave, or two scans
|
|
145
|
+
// of one repo would disagree. So the window only prefetches — the loop below
|
|
146
|
+
// consumes it one file at a time, in the order `allFiles` was sorted into.
|
|
147
|
+
//
|
|
148
|
+
// The language is resolved here and kept. The resolve phase needs the same
|
|
149
|
+
// answer per file and used to ask `languageFor` for it a second time.
|
|
150
|
+
const files = [];
|
|
151
|
+
const langOf = new Map();
|
|
152
|
+
let parsed = 0;
|
|
153
|
+
const codeFiles = [];
|
|
154
|
+
for (const path of allFiles) {
|
|
155
|
+
const lang = languageFor(path);
|
|
156
|
+
if (!lang) {
|
|
157
|
+
skips.notCode++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
langOf.set(path, lang);
|
|
161
|
+
codeFiles.push(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for await (const { path, text, failed } of readAhead(source, codeFiles)) {
|
|
165
|
+
if (failed) {
|
|
166
|
+
skips.readFailed++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (text.length > limits.maxFileSize) {
|
|
170
|
+
skips.tooLarge++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const lang = langOf.get(path);
|
|
174
|
+
let analysis;
|
|
175
|
+
try {
|
|
176
|
+
analysis = lang.analyze(text, path);
|
|
177
|
+
} catch {
|
|
178
|
+
skips.analyzeFailed++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const cs = codeStats(text, lang.id);
|
|
182
|
+
const findings = analyzeSecurityFile(text, lang.id);
|
|
183
|
+
const docRatio = cs.code + cs.comment > 0 ? Math.round((cs.comment / (cs.code + cs.comment)) * 100) : 0;
|
|
184
|
+
files.push({
|
|
185
|
+
path,
|
|
186
|
+
dir: dirOf(path),
|
|
187
|
+
name: baseName(path),
|
|
188
|
+
ext: extOf(path),
|
|
189
|
+
lang: lang.id,
|
|
190
|
+
size: text.length,
|
|
191
|
+
loc: cs.code,
|
|
192
|
+
comment: cs.comment,
|
|
193
|
+
blank: cs.blank,
|
|
194
|
+
lines: cs.lines,
|
|
195
|
+
docRatio,
|
|
196
|
+
complexity: complexityOf(text, lang.id),
|
|
197
|
+
findings: findings.length ? findings : undefined,
|
|
198
|
+
...analysis,
|
|
199
|
+
});
|
|
200
|
+
parsed++;
|
|
201
|
+
if (parsed % 25 === 0) onProgress({ phase: 'parse', done: parsed });
|
|
202
|
+
}
|
|
203
|
+
onProgress({ phase: 'parse', done: parsed });
|
|
204
|
+
// ---- Resolve imports into edges -------------------------------------------
|
|
205
|
+
const edges = [];
|
|
206
|
+
const edgeKeys = new Map(); // key -> edge
|
|
207
|
+
const externals = new Map(); // package name -> Set of files
|
|
208
|
+
|
|
209
|
+
// What share of the imports we found did we manage to place? A regex-based
|
|
210
|
+
// analyzer owes its reader this number more than any other: it is the
|
|
211
|
+
// engine's own estimate of how much of the graph is missing.
|
|
212
|
+
const tally = { total: 0, internal: 0, external: 0, unresolved: 0 };
|
|
213
|
+
const unresolvedSpecs = new Map(); // spec -> { count, from }
|
|
214
|
+
|
|
215
|
+
// Go resolves an import to a *directory*, and every .go file in it is a
|
|
216
|
+
// target. Indexed once here rather than scanned per import — a Go repo with
|
|
217
|
+
// 1,000 files and 8,000 imports was doing eight million comparisons for it.
|
|
218
|
+
const goFilesByDir = new Map();
|
|
219
|
+
for (const f of files) {
|
|
220
|
+
if (f.lang !== 'go') continue;
|
|
221
|
+
const bucket = goFilesByDir.get(f.dir);
|
|
222
|
+
if (bucket) bucket.push(f.path);
|
|
223
|
+
else goFilesByDir.set(f.dir, [f.path]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const lang = langOf.get(file.path);
|
|
228
|
+
for (const imp of file.imports) {
|
|
229
|
+
tally.total++;
|
|
230
|
+
const res = lang.resolveImport(imp.spec, file.path, has, context, imp);
|
|
231
|
+
if (res.path) {
|
|
232
|
+
tally.internal++;
|
|
233
|
+
addEdge(edges, edgeKeys, file.path, res.path, 'imports', imp.symbols);
|
|
234
|
+
} else if (res.packageDir) {
|
|
235
|
+
tally.internal++;
|
|
236
|
+
for (const target of goFilesByDir.get(res.packageDir) || []) {
|
|
237
|
+
addEdge(edges, edgeKeys, file.path, target, 'imports', imp.symbols);
|
|
238
|
+
}
|
|
239
|
+
} else if (res.external) {
|
|
240
|
+
tally.external++;
|
|
241
|
+
if (!externals.has(res.external)) externals.set(res.external, new Set());
|
|
242
|
+
externals.get(res.external).add(file.path);
|
|
243
|
+
} else {
|
|
244
|
+
// Every resolver ends with `return { unresolved: spec }`. This branch
|
|
245
|
+
// is where that used to disappear for want of an `else` — the import
|
|
246
|
+
// was counted nowhere and the graph simply came up short in silence.
|
|
247
|
+
tally.unresolved++;
|
|
248
|
+
const spec = res.unresolved || imp.spec;
|
|
249
|
+
const seen = unresolvedSpecs.get(spec);
|
|
250
|
+
if (seen) seen.count++;
|
|
251
|
+
else unresolvedSpecs.set(spec, { spec, count: 1, from: file.path });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Resolved-or-correctly-external over everything we saw. An external package
|
|
257
|
+
// counts as a success: the resolver knew what it was looking at.
|
|
258
|
+
const confidence = tally.total
|
|
259
|
+
? Math.round(((tally.internal + tally.external) / tally.total) * 100)
|
|
260
|
+
: 100;
|
|
261
|
+
const worstUnresolved = [...unresolvedSpecs.values()]
|
|
262
|
+
.sort((a, b) => b.count - a.count || a.spec.localeCompare(b.spec))
|
|
263
|
+
.slice(0, 12);
|
|
264
|
+
|
|
265
|
+
const externalList = [...externals.entries()]
|
|
266
|
+
.map(([name, usedBySet]) => ({ name, usedBy: [...usedBySet].sort() }))
|
|
267
|
+
.sort((a, b) => b.usedBy.length - a.usedBy.length || a.name.localeCompare(b.name));
|
|
268
|
+
|
|
269
|
+
// Folder rollups with full size/LOC/documentation breakdown
|
|
270
|
+
const filesPerDir = new Map();
|
|
271
|
+
const folderData = new Map();
|
|
272
|
+
for (const p of allFiles) {
|
|
273
|
+
const d = dirOf(p);
|
|
274
|
+
filesPerDir.set(d, (filesPerDir.get(d) || 0) + 1);
|
|
275
|
+
}
|
|
276
|
+
for (const path of dirSet) {
|
|
277
|
+
if (!path) continue;
|
|
278
|
+
folderData.set(path, {
|
|
279
|
+
path,
|
|
280
|
+
name: baseName(path),
|
|
281
|
+
depth: path.split('/').length,
|
|
282
|
+
fileCount: filesPerDir.get(path) || 0,
|
|
283
|
+
loc: 0,
|
|
284
|
+
comment: 0,
|
|
285
|
+
blank: 0,
|
|
286
|
+
size: 0,
|
|
287
|
+
langs: {},
|
|
288
|
+
hasReadme: false,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
for (const p of allFiles) {
|
|
292
|
+
const d = dirOf(p);
|
|
293
|
+
const bn = baseName(p).toLowerCase();
|
|
294
|
+
if (bn.startsWith('readme.') || bn === 'readme') {
|
|
295
|
+
const entry = folderData.get(d);
|
|
296
|
+
if (entry) entry.hasReadme = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const f of files) {
|
|
300
|
+
const entry = folderData.get(f.dir);
|
|
301
|
+
if (entry) {
|
|
302
|
+
entry.loc += f.loc || 0;
|
|
303
|
+
entry.comment += f.comment || 0;
|
|
304
|
+
entry.blank += f.blank || 0;
|
|
305
|
+
entry.size += f.size || 0;
|
|
306
|
+
entry.langs[f.lang] = (entry.langs[f.lang] || 0) + (f.loc || 0);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const folders = [...folderData.values()]
|
|
310
|
+
.map((f) => ({
|
|
311
|
+
...f,
|
|
312
|
+
docRatio: f.loc + f.comment > 0 ? Math.round((f.comment / (f.loc + f.comment)) * 100) : 0,
|
|
313
|
+
}))
|
|
314
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
315
|
+
|
|
316
|
+
const skippedTotal = Object.values(skips).reduce((a, b) => a + b, 0);
|
|
317
|
+
|
|
318
|
+
const stats = {
|
|
319
|
+
filesTotal: allFiles.length,
|
|
320
|
+
filesParsed: parsed,
|
|
321
|
+
skipped: skippedTotal,
|
|
322
|
+
skips,
|
|
323
|
+
truncated,
|
|
324
|
+
edgeCount: edges.length,
|
|
325
|
+
imports: {
|
|
326
|
+
total: tally.total,
|
|
327
|
+
internal: tally.internal,
|
|
328
|
+
external: tally.external,
|
|
329
|
+
unresolved: tally.unresolved,
|
|
330
|
+
confidence,
|
|
331
|
+
worst: worstUnresolved,
|
|
332
|
+
},
|
|
333
|
+
tookMs: Date.now() - startedAt,
|
|
334
|
+
languages: countBy(files, (f) => f.lang),
|
|
335
|
+
languageLoc: files.reduce((acc, f) => {
|
|
336
|
+
acc[f.lang] = (acc[f.lang] || 0) + (f.loc || 0);
|
|
337
|
+
return acc;
|
|
338
|
+
}, {}),
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const [workflows, licenseReport] = await Promise.all([
|
|
342
|
+
analyzeWorkflows(source).catch(() => []),
|
|
343
|
+
analyzeLicenses(source, context.manifest || {}).catch(() => null),
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
root: source.root || '(unknown)',
|
|
348
|
+
name: source.name || baseName(source.root || '') || 'repo',
|
|
349
|
+
scannedAt: new Date().toISOString(),
|
|
350
|
+
stats,
|
|
351
|
+
allFiles,
|
|
352
|
+
files,
|
|
353
|
+
edges,
|
|
354
|
+
externals: externalList,
|
|
355
|
+
folders,
|
|
356
|
+
workflows: workflows || [],
|
|
357
|
+
sbom: licenseReport?.sbom || [],
|
|
358
|
+
license: licenseReport?.projectLicense || { id: 'UNKNOWN', name: 'Unknown' },
|
|
359
|
+
licenseReport: licenseReport || undefined,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function addEdge(edges, edgeKeys, from, to, kind, symbols = []) {
|
|
364
|
+
if (from === to) return;
|
|
365
|
+
const key = from + '->' + to;
|
|
366
|
+
const existing = edgeKeys.get(key);
|
|
367
|
+
if (existing) {
|
|
368
|
+
if (symbols && symbols.length) {
|
|
369
|
+
existing.symbols = Array.from(new Set([...(existing.symbols || []), ...symbols]));
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const edge = { from, to, kind, symbols: symbols ? symbols.slice() : [] };
|
|
374
|
+
edgeKeys.set(key, edge);
|
|
375
|
+
edges.push(edge);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// How many reads may be in flight at once. Small on purpose: a file is read
|
|
379
|
+
// whole before its size can be checked (a `FileSource` has no `stat`), so this
|
|
380
|
+
// is also a multiplier on peak memory. Eight is enough to keep a disk or the
|
|
381
|
+
// browser's file layer busy without holding a heap of large files at once.
|
|
382
|
+
const READ_AHEAD = 8;
|
|
383
|
+
|
|
384
|
+
// Yields `{ path, text, failed }` for each path, in the order given, while
|
|
385
|
+
// keeping up to READ_AHEAD reads in flight. Failures are yielded rather than
|
|
386
|
+
// thrown so the caller can count them and keep going — one unreadable file
|
|
387
|
+
// should not end a scan.
|
|
388
|
+
async function* readAhead(source, paths, width = READ_AHEAD) {
|
|
389
|
+
const read = (path) =>
|
|
390
|
+
Promise.resolve()
|
|
391
|
+
.then(() => source.read(path))
|
|
392
|
+
.then((text) => ({ path, text, failed: false }), () => ({ path, text: '', failed: true }));
|
|
393
|
+
|
|
394
|
+
const inFlight = [];
|
|
395
|
+
let next = 0;
|
|
396
|
+
while (next < paths.length && inFlight.length < width) inFlight.push(read(paths[next++]));
|
|
397
|
+
|
|
398
|
+
while (inFlight.length) {
|
|
399
|
+
// shift() before the await: the slot is refilled immediately, so the next
|
|
400
|
+
// read starts while this file is being analyzed rather than after it.
|
|
401
|
+
const settled = inFlight.shift();
|
|
402
|
+
if (next < paths.length) inFlight.push(read(paths[next++]));
|
|
403
|
+
yield await settled;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function countBy(items, fn) {
|
|
408
|
+
const out = {};
|
|
409
|
+
for (const item of items) {
|
|
410
|
+
const k = fn(item);
|
|
411
|
+
out[k] = (out[k] || 0) + 1;
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function readModulePath(source) {
|
|
417
|
+
try {
|
|
418
|
+
const text = await source.read('go.mod');
|
|
419
|
+
const m = text.match(/^module\s+(\S+)/m);
|
|
420
|
+
return m ? m[1] : '';
|
|
421
|
+
} catch {
|
|
422
|
+
return '';
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Simplified .gitignore: plain names, dir/, *.ext, /anchored. No negation —
|
|
427
|
+
// documented limitation.
|
|
428
|
+
async function buildIgnoreMatcher(source) {
|
|
429
|
+
let text = '';
|
|
430
|
+
try {
|
|
431
|
+
text = await source.read('.gitignore');
|
|
432
|
+
} catch {
|
|
433
|
+
return () => false;
|
|
434
|
+
}
|
|
435
|
+
const rules = [];
|
|
436
|
+
for (let line of text.split('\n')) {
|
|
437
|
+
line = line.trim();
|
|
438
|
+
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
|
|
439
|
+
const anchored = line.startsWith('/');
|
|
440
|
+
const pattern = line.replace(/^\//, '').replace(/\/$/, '');
|
|
441
|
+
if (!pattern) continue;
|
|
442
|
+
const re = new RegExp(
|
|
443
|
+
(anchored ? '^' : '(^|/)') +
|
|
444
|
+
pattern.split(/(\*\*|\*)/).map((part) =>
|
|
445
|
+
part === '**' ? '.*' : part === '*' ? '[^/]*' : escapeRe(part)
|
|
446
|
+
).join('') +
|
|
447
|
+
'($|/)'
|
|
448
|
+
);
|
|
449
|
+
rules.push({ re });
|
|
450
|
+
}
|
|
451
|
+
return (path) => rules.some((r) => r.re.test(path));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function escapeRe(s) {
|
|
455
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function readTsConfigPaths(source) {
|
|
459
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
460
|
+
try {
|
|
461
|
+
const text = await source.read(name);
|
|
462
|
+
const clean = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '');
|
|
463
|
+
const json = JSON.parse(clean);
|
|
464
|
+
const compilerOptions = json.compilerOptions || {};
|
|
465
|
+
const baseUrl = compilerOptions.baseUrl ? compilerOptions.baseUrl.replace(/^\.?\//, '').replace(/\/$/, '') : '';
|
|
466
|
+
const paths = compilerOptions.paths || {};
|
|
467
|
+
if (Object.keys(paths).length || baseUrl) {
|
|
468
|
+
return { baseUrl, paths };
|
|
469
|
+
}
|
|
470
|
+
} catch {}
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// A built-in vulnerability & code-quality rule engine — a Semgrep-lite that
|
|
2
|
+
// runs entirely locally, no external binary. Rules are regex/line based,
|
|
3
|
+
// language-scoped, each with a severity and a category. Per-file findings are
|
|
4
|
+
// attached during the scan; summarizeSecurity rolls them into a repo grade.
|
|
5
|
+
|
|
6
|
+
import { lineCounter } from './util.js';
|
|
7
|
+
|
|
8
|
+
export const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
|
|
9
|
+
export const sevRank = (s) => SEVERITIES.indexOf(s);
|
|
10
|
+
|
|
11
|
+
// Each rule: id, severity, category, message, rx. `byLine` rules test each
|
|
12
|
+
// line (and report the line number); others test the whole source once.
|
|
13
|
+
const RULES = [
|
|
14
|
+
{ id: 'hardcoded-secret', severity: 'critical', category: 'secret',
|
|
15
|
+
message: 'Hardcoded credential — move it to env/config.',
|
|
16
|
+
rx: /\b(api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token|private[_-]?key)\b\s*[:=]\s*['"`][^'"`\s]{8,}['"`]/i, byLine: true },
|
|
17
|
+
{ id: 'aws-key', severity: 'critical', category: 'secret',
|
|
18
|
+
message: 'Looks like an AWS access key id.',
|
|
19
|
+
rx: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/, byLine: true },
|
|
20
|
+
{ id: 'private-key-block', severity: 'critical', category: 'secret',
|
|
21
|
+
message: 'A private key is committed to the source.',
|
|
22
|
+
rx: /-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----/ },
|
|
23
|
+
{ id: 'eval', severity: 'high', category: 'injection', langs: ['javascript'],
|
|
24
|
+
message: 'eval() executes strings as code — classic injection sink.',
|
|
25
|
+
rx: /\beval\s*\(/, byLine: true },
|
|
26
|
+
{ id: 'new-function', severity: 'high', category: 'injection', langs: ['javascript'],
|
|
27
|
+
message: 'new Function() is eval in a trench coat.',
|
|
28
|
+
rx: /\bnew Function\s*\(/, byLine: true },
|
|
29
|
+
{ id: 'child-exec', severity: 'high', category: 'injection', langs: ['javascript'],
|
|
30
|
+
message: 'Shelling out — if any argument is user input, this is command injection.',
|
|
31
|
+
rx: /\b(child_process\.(exec|spawn)|execSync|spawnSync)\s*\(/, byLine: true },
|
|
32
|
+
{ id: 'os-system', severity: 'high', category: 'injection', langs: ['python'],
|
|
33
|
+
message: 'os.system / shell=True runs a shell — sanitize any input reaching it.',
|
|
34
|
+
rx: /\bos\.system\s*\(|shell\s*=\s*True/, byLine: true },
|
|
35
|
+
{ id: 'sql-concat', severity: 'high', category: 'injection',
|
|
36
|
+
message: 'SQL built by string concatenation/formatting — use parameterized queries.',
|
|
37
|
+
rx: /\b(execute|query|exec|raw)\s*\(\s*[^)]*(\+|\$\{|\.format\(|f["'])/, byLine: true },
|
|
38
|
+
{ id: 'innerhtml', severity: 'medium', category: 'xss', langs: ['javascript'],
|
|
39
|
+
message: 'Writing raw HTML — if the value is user-controlled, this is XSS.',
|
|
40
|
+
rx: /\b(innerHTML|outerHTML)\s*=|dangerouslySetInnerHTML|document\.write\s*\(/, byLine: true,
|
|
41
|
+
ignore: /\b(innerHTML|outerHTML)\s*=\s*(['"`])\2\s*;?\s*$/ },
|
|
42
|
+
{ id: 'weak-hash', severity: 'medium', category: 'crypto',
|
|
43
|
+
message: 'MD5/SHA-1 are broken — use SHA-256+, and bcrypt/argon2 for passwords.',
|
|
44
|
+
rx: /\b(md5|sha1)\s*\(|createHash\(\s*['"](md5|sha1)['"]/, byLine: true },
|
|
45
|
+
{ id: 'math-random', severity: 'low', category: 'crypto', langs: ['javascript'],
|
|
46
|
+
message: 'Math.random() is not cryptographically secure.',
|
|
47
|
+
rx: /\bMath\.random\s*\(/, byLine: true },
|
|
48
|
+
{ id: 'insecure-deser', severity: 'high', category: 'deserialization',
|
|
49
|
+
message: 'Insecure deserialization — untrusted data here is remote code execution.',
|
|
50
|
+
rx: /\b(pickle\.loads?|yaml\.load\s*\((?![^)]*SafeLoader)|unserialize\s*\(|ObjectInputStream)/, byLine: true },
|
|
51
|
+
{ id: 'open-redirect', severity: 'medium', category: 'redirect',
|
|
52
|
+
message: 'Redirect target comes from the request — open-redirect risk.',
|
|
53
|
+
rx: /\b(res\.redirect|redirect)\s*\(\s*(req\.|request\.)/, byLine: true },
|
|
54
|
+
{ id: 'localhost-url', severity: 'low', category: 'config',
|
|
55
|
+
message: 'Hardcoded localhost/127.0.0.1 — will break outside this machine.',
|
|
56
|
+
rx: /['"`](https?:\/\/(localhost|127\.0\.0\.1)|localhost:\d|127\.0\.0\.1)/, byLine: true },
|
|
57
|
+
{ id: 'debugger', severity: 'low', category: 'debug', langs: ['javascript'],
|
|
58
|
+
message: 'A debugger statement is left in.',
|
|
59
|
+
rx: /\bdebugger\s*;?/, byLine: true },
|
|
60
|
+
{ id: 'console-log', severity: 'info', category: 'debug', langs: ['javascript'],
|
|
61
|
+
message: 'console.log — fine locally, noise in production.',
|
|
62
|
+
rx: /\bconsole\.(log|debug|info)\s*\(/, byLine: true },
|
|
63
|
+
{ id: 'print-stmt', severity: 'info', category: 'debug', langs: ['python'],
|
|
64
|
+
message: 'print() — use the logger in production code.',
|
|
65
|
+
rx: /^\s*print\s*\(/, byLine: true },
|
|
66
|
+
{ id: 'todo', severity: 'info', category: 'quality',
|
|
67
|
+
message: 'TODO/FIXME left in the source.',
|
|
68
|
+
rx: /\b(TODO|FIXME|XXX|HACK)\b/, byLine: true },
|
|
69
|
+
{ id: 'loose-eq', severity: 'low', category: 'quality', langs: ['javascript'],
|
|
70
|
+
message: 'Loose equality (==) coerces types — prefer ===.',
|
|
71
|
+
rx: /[^=!<>]==[^=]/, byLine: true },
|
|
72
|
+
{ id: 'var-keyword', severity: 'low', category: 'quality', langs: ['javascript'],
|
|
73
|
+
message: 'var is function-scoped and hoisted — prefer let/const.',
|
|
74
|
+
rx: /\bvar\s+[A-Za-z_$]/, byLine: true },
|
|
75
|
+
{ id: 'empty-catch', severity: 'medium', category: 'quality',
|
|
76
|
+
message: 'Swallowed exception — errors vanish silently here.',
|
|
77
|
+
rx: /(catch\s*\([^)]*\)\s*\{\s*\}|except\s*\w*\s*:\s*pass\b)/, byLine: true },
|
|
78
|
+
// JSON Web Tokens start with a base64-encoded header that is always
|
|
79
|
+
// `eyJ…` ("{"). A line that contains one and treats it as a literal
|
|
80
|
+
// string — rather than reading it from a vault — is the same shape of
|
|
81
|
+
// mistake as a hardcoded API key. Three base64 segments separated by
|
|
82
|
+
// dots is the JWT signature; we only require the first segment to keep
|
|
83
|
+
// the rule cheap.
|
|
84
|
+
{ id: 'jwt-secret', severity: 'critical', category: 'secret',
|
|
85
|
+
message: 'Looks like a hardcoded JWT — treat it like a credential, not a string.',
|
|
86
|
+
rx: /['"`](eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})['"`]/, byLine: true },
|
|
87
|
+
// The `os-system` rule already catches `os.system` and `shell=True` on
|
|
88
|
+
// subprocess calls, but `subprocess.Popen(..., shell=True)` and
|
|
89
|
+
// `subprocess.run(..., shell=True)` are the modern Python way to do
|
|
90
|
+
// the same thing, and a regex on `shell=True` alone matches both
|
|
91
|
+
// without the false-positive rate. A separate rule for Popen keeps the
|
|
92
|
+
// message specific and the langs filter honest.
|
|
93
|
+
{ id: 'subprocess-shell', severity: 'high', category: 'injection', langs: ['python'],
|
|
94
|
+
message: 'subprocess.* with shell=True runs a shell — sanitize any input reaching it.',
|
|
95
|
+
rx: /\bsubprocess\.(Popen|run|call|check_output|check_call)\s*\([^)]*shell\s*=\s*True/, byLine: true },
|
|
96
|
+
// Go's `http.ListenAndServe` defaults to binding all interfaces when
|
|
97
|
+
// given a `":port"` address (the colon makes it a wildcard), and a
|
|
98
|
+
// literal `0.0.0.0` does the same. That is fine inside a container with
|
|
99
|
+
// a tight network policy and a bug everywhere else: the dev box, the
|
|
100
|
+
// staging cluster, the CI runner, the laptop on coffee-shop Wi-Fi. The
|
|
101
|
+
// rule does not distinguish "0.0.0.0" from "127.0.0.1" because the
|
|
102
|
+
// safer pattern is always `127.0.0.1:<port>`, so the finding is a
|
|
103
|
+
// nudge rather than an alarm.
|
|
104
|
+
{ id: 'go-public-listen', severity: 'medium', category: 'config', langs: ['go'],
|
|
105
|
+
message: 'Go listener binds all interfaces (":port" or "0.0.0.0") — bind 127.0.0.1 explicitly.',
|
|
106
|
+
rx: /(ListenAndServe|Listen|TLS\s*\.\s*Listen|ListenAndServeTLS)\s*\(\s*["'](:[0-9]+|0\.0\.0\.0(?::[0-9]+)?|::)["']/, byLine: true },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// Findings for one file, run at scan time while the source is in hand.
|
|
110
|
+
export function analyzeSecurityFile(source, lang) {
|
|
111
|
+
const findings = [];
|
|
112
|
+
const text = String(source);
|
|
113
|
+
const lines = text.split('\n');
|
|
114
|
+
// Whole-file rules report a byte offset and need it as a line number. Built
|
|
115
|
+
// once and lazily: most files trip none of these rules and never build it.
|
|
116
|
+
const lineAt = lineCounter(text);
|
|
117
|
+
for (const rule of RULES) {
|
|
118
|
+
if (rule.langs && !rule.langs.includes(lang)) continue;
|
|
119
|
+
if (rule.byLine) {
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
if (rule.ignore && rule.ignore.test(lines[i])) continue;
|
|
122
|
+
const m = lines[i].match(rule.rx);
|
|
123
|
+
if (m) {
|
|
124
|
+
findings.push({ rule: rule.id, severity: rule.severity, category: rule.category, message: rule.message, line: i + 1, excerpt: lines[i].trim().slice(0, 90) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
const m = text.match(rule.rx);
|
|
129
|
+
if (m) {
|
|
130
|
+
findings.push({ rule: rule.id, severity: rule.severity, category: rule.category, message: rule.message, line: lineAt(m.index), excerpt: (m[0] || '').slice(0, 90) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Repo-level roll-up: counts by severity and category, the files carrying
|
|
138
|
+
// findings (worst-first), and a 0–100 score + grade.
|
|
139
|
+
export function summarizeSecurity(scan) {
|
|
140
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
141
|
+
const byCat = {};
|
|
142
|
+
const files = [];
|
|
143
|
+
for (const f of scan.files) {
|
|
144
|
+
const finds = f.findings || [];
|
|
145
|
+
if (!finds.length) continue;
|
|
146
|
+
let worst = 'info';
|
|
147
|
+
for (const x of finds) {
|
|
148
|
+
counts[x.severity]++;
|
|
149
|
+
byCat[x.category] = (byCat[x.category] || 0) + 1;
|
|
150
|
+
if (sevRank(x.severity) > sevRank(worst)) worst = x.severity;
|
|
151
|
+
}
|
|
152
|
+
files.push({ path: f.path, name: f.name, findings: finds, worst, count: finds.length });
|
|
153
|
+
}
|
|
154
|
+
files.sort((a, b) => sevRank(b.worst) - sevRank(a.worst) || b.count - a.count);
|
|
155
|
+
|
|
156
|
+
let score = 100;
|
|
157
|
+
score -= counts.critical * 15;
|
|
158
|
+
score -= counts.high * 6;
|
|
159
|
+
score -= counts.medium * 2;
|
|
160
|
+
score -= Math.min(10, counts.low);
|
|
161
|
+
score = Math.max(0, Math.round(score));
|
|
162
|
+
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 45 ? 'D' : 'F';
|
|
163
|
+
|
|
164
|
+
return { counts, byCat, files, score, grade, total: files.reduce((s, f) => s + f.count, 0) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Fold an external engine's findings into the scan, in place, so the security
|
|
168
|
+
// view — which reads `scan.files[].findings` and re-summarizes — sees one list.
|
|
169
|
+
// Dedup is on (path, line, rule): two engines flagging the same line for the
|
|
170
|
+
// same reason are one problem. This is the shared, pure half of the merge; the
|
|
171
|
+
// fetching and spawning live on the server.
|
|
172
|
+
export function mergeFindingsIntoScan(scan, external) {
|
|
173
|
+
if (!scan || !Array.isArray(external) || !external.length) return 0;
|
|
174
|
+
const byPath = new Map(scan.files.map((f) => [f.path, f]));
|
|
175
|
+
let added = 0;
|
|
176
|
+
for (const finding of external) {
|
|
177
|
+
const file = byPath.get(finding.path);
|
|
178
|
+
if (!file) continue; // a path the scanner never accepted is not ours to show
|
|
179
|
+
const list = file.findings || (file.findings = []);
|
|
180
|
+
const dupe = list.some((x) => x.line === finding.line && x.rule === finding.rule);
|
|
181
|
+
if (dupe) continue;
|
|
182
|
+
list.push(finding);
|
|
183
|
+
added++;
|
|
184
|
+
}
|
|
185
|
+
return added;
|
|
186
|
+
}
|
|
187
|
+
|